From 9ba1a29749bc4ffcd884577fb6f464671279ee28 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Tue, 14 Apr 2026 15:38:59 +0100 Subject: [PATCH 01/30] feat: rgb --- ext/package-lock.json | 253 +++++++++++++++- ext/package.json | 2 + ext/src/modules/background-caller.ts | 10 +- .../modules/background-message-controller.ts | 4 +- ext/src/modules/rgb-adapter.ts | 20 ++ ext/src/pages/Popup/Popup.tsx | 1 + ext/src/typings/globals.d.ts | 2 + mobile/app/_layout.tsx | 1 + mobile/package-lock.json | 281 ++++++++++++++++-- mobile/package.json | 2 + mobile/src/modules/background-executor.ts | 18 +- mobile/src/modules/rgb-adapter.ts | 33 ++ mobile/src/types.d.ts | 3 + mobile/utils/networkAssets.ts | 6 + shared/class/wallets/rgb-wallet.ts | 264 ++++++++++++++++ shared/hooks/useBalance.ts | 15 + shared/hooks/useTokenBalance.ts | 16 +- shared/hooks/useTokenDiscovery.ts | 21 +- shared/hooks/useTransactions.ts | 14 + shared/models/all-network-infos.ts | 25 ++ shared/models/network-getters.ts | 4 +- shared/modules/wallet-utils.ts | 26 +- .../tests/integration-vi/rgb-wallet.test.ts | 132 ++++++++ shared/tests/unit-vi/rgb-wallet.test.ts | 212 +++++++++++++ shared/types/networks.ts | 4 + shared/types/rgb-adapter.ts | 76 +++++ 26 files changed, 1407 insertions(+), 38 deletions(-) create mode 100644 ext/src/modules/rgb-adapter.ts create mode 100644 mobile/src/modules/rgb-adapter.ts create mode 100644 shared/class/wallets/rgb-wallet.ts create mode 100644 shared/tests/integration-vi/rgb-wallet.test.ts create mode 100644 shared/tests/unit-vi/rgb-wallet.test.ts create mode 100644 shared/types/rgb-adapter.ts diff --git a/ext/package-lock.json b/ext/package-lock.json index ab66518f4..7af89cae0 100644 --- a/ext/package-lock.json +++ b/ext/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "layerzwallet", - "version": "1.5.0", + "version": "1.5.3", "hasInstallScript": true, "dependencies": { "@arkade-os/boltz-swap": "0.3.9", @@ -24,6 +24,8 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", + "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-web": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", @@ -2205,6 +2207,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bitcoindevkit/bdk-wallet-web": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bitcoindevkit/bdk-wallet-web/-/bdk-wallet-web-0.2.0.tgz", + "integrity": "sha512-aa6AOJeYMpGakrA/a2JKMkIp6YF87RDILmFJ6CMHNq3zYKVZKkP6MQacyO7unMgME3uhxwHM9Fd97rvmY5XVSg==", + "license": "MIT OR Apache-2.0" + }, "node_modules/@bitcoinerlab/miniscript": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@bitcoinerlab/miniscript/-/miniscript-1.4.3.tgz", @@ -6445,6 +6453,182 @@ "dev": true, "license": "ISC" }, + "node_modules/@utexo/rgb-lib-wasm": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@utexo/rgb-lib-wasm/-/rgb-lib-wasm-1.0.0-beta.2.tgz", + "integrity": "sha512-EN/3xLlEejoFQ4dfiw8r/xe2R0JUbIKuJYjQtxlf3YqaHmdiGR2eX/8MQ6PJw+YF0NlenC/UAPsCHtytwhrQjg==" + }, + "node_modules/@utexo/rgb-sdk-core": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-core/-/rgb-sdk-core-1.0.0-beta.2.tgz", + "integrity": "sha512-GgzfpP3r8IbK/29xP/ifUYtjEg7+zcJkRorVPe7F1UMOj4sHKQnrTse5FTmmSKvKhWNHYrx/uJWIyGjpgFbK1Q==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "3.0.0", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "axios": "^1.8.4" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@noble/secp256k1": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-web": { + "version": "1.0.0-beta.8", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-web/-/rgb-sdk-web-1.0.0-beta.8.tgz", + "integrity": "sha512-584jztV2lR4ybop7oCIBAHfxi5XUHQ/o9yGekdxGYhOgA1/sMI4hV69Y32oMG9I7K70paNnJ3JsPq1kjkq25tA==", + "license": "MIT", + "dependencies": { + "@bitcoindevkit/bdk-wallet-web": "^0.2.0", + "@noble/hashes": "^2.0.1", + "@scure/btc-signer": "^2.0.1", + "@utexo/rgb-lib-wasm": "^1.0.0-beta.1", + "@utexo/rgb-sdk-core": "^1.0.0-beta.2", + "bitcoinjs-lib": "^6.1.7" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bip174": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bitcoinjs-lib": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", + "integrity": "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^2.1.1", + "bs58check": "^3.0.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bitcoinjs-lib/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, + "node_modules/@utexo/rgb-sdk-web/node_modules/bs58check/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -7358,6 +7542,12 @@ "tslib": "^2.4.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7383,6 +7573,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -8939,6 +9140,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -9696,6 +9909,15 @@ "node": ">=6" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -10612,7 +10834,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12055,7 +12276,6 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, "funding": [ { "type": "individual", @@ -12087,6 +12307,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -14556,7 +14792,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14566,7 +14801,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -16128,6 +16362,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", diff --git a/ext/package.json b/ext/package.json index 5d6a2d22b..6168558fe 100755 --- a/ext/package.json +++ b/ext/package.json @@ -36,6 +36,8 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", + "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-web": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", diff --git a/ext/src/modules/background-caller.ts b/ext/src/modules/background-caller.ts index 09b3c708a..1929508f4 100644 --- a/ext/src/modules/background-caller.ts +++ b/ext/src/modules/background-caller.ts @@ -3,7 +3,8 @@ import { GetBtcSendDataResponse, IBackgroundCaller, MessageType, GetCommonTransa import { ENCRYPTED_PREFIX, STORAGE_KEY_MNEMONIC, STORAGE_KEY_SEED_VERIFIED } from '@shared/types/IStorage'; import { LayerzStorage } from '../class/layerz-storage'; import { SecureStorage } from '../class/secure-storage'; -import { NETWORK_SPARK } from '@shared/types/networks'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET, NETWORK_SPARK } from '@shared/types/networks'; +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; import { SparkWallet } from '@shared/class/wallets/spark-wallet'; import { lazyInitWallet as lazyInitWalletOrig, @@ -71,6 +72,13 @@ export const BackgroundCaller: IBackgroundCaller = { return String(await sp.getOffchainReceiveAddress()); } + if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + // RGB SDK loads WASM + uses IndexedDB, both unavailable in the MV3 service worker. + const rw = await BackgroundCaller.lazyInitWallet(network, accountNumber); + assert(rw instanceof RgbWallet); + return await rw.getOffchainReceiveAddress(); + } + return await Messenger.sendGenericMessageToBackground(MessageType.GET_ADDRESS, params); }, diff --git a/ext/src/modules/background-message-controller.ts b/ext/src/modules/background-message-controller.ts index 0940c85b8..878167c1d 100644 --- a/ext/src/modules/background-message-controller.ts +++ b/ext/src/modules/background-message-controller.ts @@ -8,7 +8,7 @@ import { getDeviceID } from '@shared/modules/device-id'; import { clearWalletCache, lazyInitWallet, sanitizeAndValidateMnemonic, saveBitcoinXpubs, saveWalletState, setMasterSeed, getMasterSeed } from '@shared/modules/wallet-utils'; import { IBackgroundCaller, MessageType, MessageTypeMap, OpenPopupRequest, ProcessRPCRequest } from '@shared/types/IBackgroundCaller'; import { ENCRYPTED_PREFIX, STORAGE_KEY_EVM_XPUB, STORAGE_KEY_MNEMONIC } from '@shared/types/IStorage'; -import { NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_SPARK, NETWORK_STACKS } from '@shared/types/networks'; +import { NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_RGB, NETWORK_RGB_TESTNET, NETWORK_SPARK, NETWORK_STACKS } from '@shared/types/networks'; import { Csprng } from '../../src/class/rng'; import { LayerzStorage } from '../class/layerz-storage'; import { SecureStorage } from '../class/secure-storage'; @@ -87,6 +87,8 @@ export const BackgroundExtensionExecutor: Pick = { return await aw.getOffchainReceiveAddress(); } else if (network === NETWORK_SPARK) { throw new Error('this should never happen: temporarily executed on the spot in the BackgroundCaller'); // fixme + } else if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + throw new Error('this should never happen: RGB runs in the Popup context (WASM + IndexedDB) via BackgroundCaller'); } else if (network === NETWORK_LIQUID || network === NETWORK_LIQUID_TESTNET) { const wallet = await lazyInitWallet(network, accountNumber, LayerzStorage, SecureStorage); assert(wallet instanceof BreezWallet); diff --git a/ext/src/modules/rgb-adapter.ts b/ext/src/modules/rgb-adapter.ts new file mode 100644 index 000000000..0ab5ca945 --- /dev/null +++ b/ext/src/modules/rgb-adapter.ts @@ -0,0 +1,20 @@ +import { UTEXOWallet, restoreUtxoWalletFromVss } from '@utexo/rgb-sdk-web'; + +import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '@shared/types/rgb-adapter'; + +class RgbAdapter implements IRgbAdapter { + readonly capabilities = { lightning: false } as const; + + async createWallet({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + const wallet = new UTEXOWallet(mnemonic, { network, vssServerUrl }); + await wallet.initialize(); + return wallet; + } + + async restoreFromVss({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + await restoreUtxoWalletFromVss({ mnemonic, networkPreset: network, vssServerUrl }); + return this.createWallet({ mnemonic, network, vssServerUrl }); + } +} + +globalThis.rgbAdapter = new RgbAdapter(); diff --git a/ext/src/pages/Popup/Popup.tsx b/ext/src/pages/Popup/Popup.tsx index db95de44b..28b42d059 100644 --- a/ext/src/pages/Popup/Popup.tsx +++ b/ext/src/pages/Popup/Popup.tsx @@ -5,6 +5,7 @@ import { SWRConfig } from 'swr'; import '../../modules/breeze-adapter'; // needed to be imported before we can use BreezWallet import '../../modules/error-handler'; +import '../../modules/rgb-adapter'; // needed to be imported before we can use RgbWallet import '../../modules/spark-adapter'; // needed to be imported before we can use SparkWallet import { AccountNumberContextProvider } from '@shared/hooks/AccountNumberContext'; diff --git a/ext/src/typings/globals.d.ts b/ext/src/typings/globals.d.ts index cfa551c96..42946a2e2 100644 --- a/ext/src/typings/globals.d.ts +++ b/ext/src/typings/globals.d.ts @@ -1,11 +1,13 @@ import { IBreezAdapter } from '@shared/class/wallets/breez-wallet'; import { ISparkAdapter } from '@shared/class/wallets/spark-wallet'; +import { IRgbAdapter } from '@shared/types/rgb-adapter'; declare function alert(message: string): void; declare global { var breezAdapter: IBreezAdapter; var sparkAdapter: ISparkAdapter; + var rgbAdapter: IRgbAdapter; var handleError: ((error: unknown, context?: string) => void | Promise) | undefined; var __DEV__: boolean | undefined; } diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 2020c083f..86024982e 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -11,6 +11,7 @@ import BigNumber from 'bignumber.js'; import '../src/modules/breeze-adapter'; // needed to be imported before we can use BreezWallet import '../src/modules/spark-adapter'; // needed to be imported before we can use SparkWallet +import '../src/modules/rgb-adapter'; // needed to be imported before we can use RgbWallet import AutoClaimMonitor from '@/components/AutoClaimMonitor'; import { ErrorBoundary } from '@/components/ErrorBoundary'; diff --git a/mobile/package-lock.json b/mobile/package-lock.json index bc3b2808f..d9d94ea52 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -32,6 +32,8 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", + "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-rn": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", @@ -8436,6 +8438,185 @@ "win32" ] }, + "node_modules/@utexo/rgb-sdk-core": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-core/-/rgb-sdk-core-1.0.0-beta.2.tgz", + "integrity": "sha512-GgzfpP3r8IbK/29xP/ifUYtjEg7+zcJkRorVPe7F1UMOj4sHKQnrTse5FTmmSKvKhWNHYrx/uJWIyGjpgFbK1Q==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "3.0.0", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "axios": "^1.8.4" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@noble/secp256k1": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-core/node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-rn": { + "version": "1.0.0-beta.8", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-rn/-/rgb-sdk-rn-1.0.0-beta.8.tgz", + "integrity": "sha512-7S2gtfRtTJmAGXR0bp3wEOQ16HW0y7uVceOA0uOIwM6ttwMGBCcY8KGMlZMH04Qc2MrA9pwArTZHWiblJWZhcQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "3.0.0", + "@scure/base": "^2.0.0", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "@scure/btc-signer": "^2.0.1", + "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "axios": "^1.8.4", + "bdk-rn": "https://github.com/UTEXO-Protocol/bdk-rn/releases/download/v2.2.0-Alpha.1/bdk-rn-2.2.0-Alpha.1.tgz" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@utexo/rgb-sdk-rn/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-rn/node_modules/@noble/secp256k1": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-rn/node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-rn/node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-rn/node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@utexo/rgb-sdk-rn/node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@vitest/expect": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz", @@ -9322,7 +9503,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -9340,6 +9520,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/b4a": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", @@ -9915,6 +10106,29 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bdk-rn": { + "version": "2.2.0-Alpha.1", + "resolved": "https://github.com/UTEXO-Protocol/bdk-rn/releases/download/v2.2.0-Alpha.1/bdk-rn-2.2.0-Alpha.1.tgz", + "integrity": "sha512-hAEUWh8nP4846tuzJBak4jUm5OwIkHQniGg6S4ZT5frCzkSxGzmn8lWBGF8xdH4xE4PDs2C0te5cUzf11GF3lA==", + "license": "MIT", + "dependencies": { + "uniffi-bindgen-react-native": "https://github.com/jhugman/uniffi-bindgen-react-native/archive/b9301797ef697331d29edb9d2402ea35c218571e.tar.gz" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/bdk-rn/node_modules/uniffi-bindgen-react-native": { + "version": "0.29.3-1", + "resolved": "https://github.com/jhugman/uniffi-bindgen-react-native/archive/b9301797ef697331d29edb9d2402ea35c218571e.tar.gz", + "integrity": "sha512-ZJPXOs2wpEreM/1jFnKEj5QWZp8HF9sYeURix90Eq87gmPhpsinOg9dBgQ425aQkhOzgxdycVK8L+2fj8Q4Oxg==", + "license": "MPL-2.0", + "bin": { + "ubrn": "bin/cli.cjs", + "uniffi-bindgen-react-native": "bin/cli.cjs" + } + }, "node_modules/bech32": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", @@ -11139,7 +11353,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -11803,7 +12016,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -12428,7 +12640,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -14866,6 +15077,26 @@ "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fontfaceobserver": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", @@ -14887,6 +15118,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -18505,23 +18752,6 @@ } } }, - "node_modules/jsdom/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -21215,6 +21445,15 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 853b743c1..d34962a65 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -65,6 +65,8 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", + "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-rn": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", diff --git a/mobile/src/modules/background-executor.ts b/mobile/src/modules/background-executor.ts index d25a5eeab..455d7e945 100644 --- a/mobile/src/modules/background-executor.ts +++ b/mobile/src/modules/background-executor.ts @@ -19,13 +19,25 @@ import { } from '@shared/modules/wallet-utils'; import { IBackgroundCaller, OpenPopupRequest } from '@shared/types/IBackgroundCaller'; import { ENCRYPTED_PREFIX, STORAGE_KEY_ACCEPTED_TOS, STORAGE_KEY_EVM_XPUB, STORAGE_KEY_MNEMONIC, STORAGE_KEY_WHITELIST, STORAGE_KEY_SEED_VERIFIED } from '@shared/types/IStorage'; -import { Networks, NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_SPARK, NETWORK_STACKS } from '@shared/types/networks'; +import { + Networks, + NETWORK_ARK, + NETWORK_ARK_MUTINYNET, + NETWORK_BITCOIN, + NETWORK_LIQUID, + NETWORK_LIQUID_TESTNET, + NETWORK_RGB, + NETWORK_RGB_TESTNET, + NETWORK_SPARK, + NETWORK_STACKS, +} from '@shared/types/networks'; import { BrowserBridge } from '../class/browser-bridge'; import { LayerzStorage } from '../class/layerz-storage'; import { Csprng } from '../class/rng'; import { SecureStorage } from '../class/secure-storage'; import { decrypt, encrypt } from '../modules/encryption'; import { ArkWallet } from '@shared/class/wallets/ark-wallet'; +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; import { StacksWallet } from '@shared/class/wallets/stacks-wallet'; import { HDSegwitBech32Wallet } from '@shared/class/wallets/hd-segwit-bech32-wallet'; @@ -92,6 +104,10 @@ export const BackgroundExecutor: IBackgroundCaller = { const sp = await BackgroundExecutor.lazyInitWallet(network, accountNumber); assert(sp instanceof StacksWallet); return String(await sp.getOffchainReceiveAddress()); + } else if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + const rw = await BackgroundExecutor.lazyInitWallet(network, accountNumber); + assert(rw instanceof RgbWallet); + return await rw.getOffchainReceiveAddress(); } else if (network === NETWORK_LIQUID || network === NETWORK_LIQUID_TESTNET) { const wallet = await BackgroundExecutor.lazyInitWallet(network, accountNumber); assert(wallet instanceof BreezWallet); diff --git a/mobile/src/modules/rgb-adapter.ts b/mobile/src/modules/rgb-adapter.ts new file mode 100644 index 000000000..8c0bca9ed --- /dev/null +++ b/mobile/src/modules/rgb-adapter.ts @@ -0,0 +1,33 @@ +import { UTEXOWallet } from '@utexo/rgb-sdk-rn'; +import { Directory, Paths } from 'expo-file-system'; + +import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '@shared/types/rgb-adapter'; + +const RGB_DATA_ROOT = 'rgb'; + +function dataDirFor(network: IRgbAdapterCreateParams['network']): string { + const root = new Directory(Paths.document, RGB_DATA_ROOT, network); + if (!root.exists) root.create({ intermediates: true }); + return root.uri.replace(/^file:\/\//, ''); +} + +class RgbAdapter implements IRgbAdapter { + readonly capabilities = { lightning: false } as const; + + async createWallet({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + const wallet = new UTEXOWallet(mnemonic, { + network, + dataDir: dataDirFor(network), + vssServerUrl, + }); + await wallet.initialize(); + return wallet; + } + + async restoreFromVss({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + await UTEXOWallet.restoreFromVss(mnemonic, dataDirFor(network), vssServerUrl ? { serverUrl: vssServerUrl } : undefined); + return this.createWallet({ mnemonic, network, vssServerUrl }); + } +} + +global.rgbAdapter = new RgbAdapter(); diff --git a/mobile/src/types.d.ts b/mobile/src/types.d.ts index b5f096b2d..98196e5a4 100644 --- a/mobile/src/types.d.ts +++ b/mobile/src/types.d.ts @@ -1,5 +1,6 @@ import { IBreezAdapter } from '@shared/class/wallets/breez-wallet'; import { ISparkAdapter } from '@shared/class/wallets/spark-wallet'; +import { IRgbAdapter } from '@shared/types/rgb-adapter'; /* eslint-disable no-var */ @@ -8,5 +9,7 @@ declare global { var sparkAdapter: ISparkAdapter; + var rgbAdapter: IRgbAdapter; + var handleError: ((error: unknown, context?: string) => void | Promise) | undefined; } diff --git a/mobile/utils/networkAssets.ts b/mobile/utils/networkAssets.ts index 4cce2e3ad..94e6154c6 100644 --- a/mobile/utils/networkAssets.ts +++ b/mobile/utils/networkAssets.ts @@ -15,6 +15,8 @@ import { NETWORK_ARK, NETWORK_STACKS, NETWORK_CITREA, + NETWORK_RGB, + NETWORK_RGB_TESTNET, } from '@shared/types/networks'; import { AssetId } from '@shared/types/asset'; import { SO_LIQUID_USDT, SO_STACKS_STX } from '@shared/types/swap'; @@ -84,6 +86,10 @@ export const getNetworkImageAsset = (network: string): string | null => { case NETWORK_ARK_MUTINYNET: case NETWORK_ARK: return require('../assets/images/ui/network/ark.png'); + case NETWORK_RGB: + case NETWORK_RGB_TESTNET: + // Reuse the Bitcoin icon until a dedicated RGB asset is added. + return require('../assets/images/ui/network/bitcoin.png'); case NETWORK_CITREA: case NETWORK_CITREA_TESTNET: return require('../assets/images/ui/network/citrea.png'); diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts new file mode 100644 index 000000000..5a886d8f6 --- /dev/null +++ b/shared/class/wallets/rgb-wallet.ts @@ -0,0 +1,264 @@ +import assert from 'assert'; + +import { AllNetworkInfos } from '../../models/all-network-infos'; +import { CommonTokenTransfer, CommonTransaction } from '../../types/common-transaction'; +import { Networks, NETWORK_RGB, NETWORK_RGB_TESTNET } from '../../types/networks'; +import { CachedTokenInfo } from '../../types/token-info'; +import { IRgbAdapter, IRgbWallet, RgbNetwork } from '../../types/rgb-adapter'; +import { IStorage } from '../../types/IStorage'; +import { AbstractWallet } from './abstract-wallet'; +import { InterfaceAccountBasedWallet } from './interface-account-based-wallet'; +import { InterfaceCanHaveTokens } from './interface-can-have-tokens'; + +type AnyAsset = { + assetId: string; + ticker?: string; + name: string; + precision: number; + balance: { settled: number; future: number; spendable: number }; + media?: { filePath?: string; mime?: string } | null; +}; + +export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWallet, InterfaceCanHaveTokens { + static readonly type = 'rgb'; + static readonly typeReadable = 'RGB'; + // @ts-ignore: override + public readonly type = RgbWallet.type; + // @ts-ignore: override + public readonly typeReadable = RgbWallet.typeReadable; + + protected adapter: IRgbAdapter; + private readonly _network: Networks; + private readonly _sdkNetwork: RgbNetwork; + private _accountNumber: number = 0; + private _sdkWallet: IRgbWallet | undefined; + private _tokens: CachedTokenInfo[] = []; + private _address: string | false = false; + public _lastTokensFetch: number = 0; + + constructor(network: Networks = NETWORK_RGB) { + super(); + assert(network === NETWORK_RGB || network === NETWORK_RGB_TESTNET, `Unsupported RGB network: ${network}`); + this._network = network; + this._sdkNetwork = network === NETWORK_RGB_TESTNET ? 'testnet' : 'mainnet'; + assert(globalThis.rgbAdapter, 'RGB adapter not installed on globalThis.rgbAdapter'); + this.adapter = globalThis.rgbAdapter; + } + + setAccountNumber(n: number): void { + this._accountNumber = n; + } + + getAccountNumber(): number { + return this._accountNumber; + } + + getNetwork(): Networks { + return this._network; + } + + /** + * Bring the SDK wallet online. Tries a VSS restore first (idempotent: no-op + * when no backup exists) so that a fresh install using an existing mnemonic + * picks up state created on another device. + */ + async init(_storage: IStorage): Promise { + assert(this.secret, 'Cant init RGB wallet: secret is not set.'); + const params = { mnemonic: this.secret, network: this._sdkNetwork }; + try { + this._sdkWallet = await this.adapter.restoreFromVss(params); + } catch (e) { + // First-run with no VSS backup; the adapter's restoreFromVss throws here. + // Fall back to a fresh wallet — vssBackup() after the first mutation will + // seed the backup. + globalThis.handleError?.(e, 'rgb-wallet.ts:init'); + this._sdkWallet = await this.adapter.createWallet(params); + } + } + + private sdk(): IRgbWallet { + assert(this._sdkWallet, 'RGB wallet not initialized'); + return this._sdkWallet; + } + + async getOffchainReceiveAddress(): Promise { + if (!this._address) this._address = await this.sdk().getAddress(); + return this._address as string; + } + + async getOffchainBalance(): Promise { + const bal = await this.sdk().getBtcBalance(); + this._lastBalanceFetch = Date.now(); + return Number(bal.vanilla.spendable) + Number(bal.colored.spendable); + } + + async pay(receiverAddress: string, amountSats: number): Promise { + const txid = await this.sdk().sendBtc({ + address: receiverAddress, + amount: amountSats, + feeRate: await this.defaultFeeRate(), + }); + await this.tryBackup(); + return txid; + } + + /** + * For RGB, `address` is a full `rgb:` invoice. `tokenId` is the asset id. + * Amount is in the asset's base units. + */ + async transferToken(tokenId: string, amount: bigint, invoice: string): Promise { + const result = await this.sdk().send({ + invoice, + assetId: tokenId, + amount: Number(amount), + feeRate: await this.defaultFeeRate(), + }); + await this.tryBackup(); + return result.txid; + } + + async fetchTokenBalances(): Promise { + const list = await this.sdk().listAssets(); + const assets: AnyAsset[] = [...(list.nia ?? []), ...(list.cfa ?? []), ...(list.ifa ?? []), ...(list.uda ?? [])]; + this._tokens = assets.map((a) => ({ + id: a.assetId, + chainId: AllNetworkInfos[this._network].chainId, + name: a.name, + symbol: a.ticker ?? a.name, + decimals: a.precision, + balance: String(a.balance.spendable), + logoURI: a.media?.filePath, + })); + this._lastTokensFetch = Date.now(); + } + + getTokenBalances(): CachedTokenInfo[] { + return this._tokens; + } + + async getCommonTransactions(): Promise { + const sdk = this.sdk(); + const [txs, transfers] = await Promise.all([sdk.listTransactions(), sdk.listTransfers()]); + + const transfersByTxid = new Map(); + for (const t of transfers) { + if (!t.txid) continue; + const arr = transfersByTxid.get(t.txid) ?? []; + arr.push(t); + transfersByTxid.set(t.txid, arr); + } + + const explorerBase = AllNetworkInfos[this._network].explorerUrl; + const common: CommonTransaction[] = []; + const seen = new Set(); + + for (const tx of txs) { + seen.add(tx.txid); + const netSats = tx.received - tx.sent; + const related = transfersByTxid.get(tx.txid) ?? []; + const tokenTransfers = this.assetTransfersToCommon(related); + const direction: CommonTransaction['direction'] = netSats === 0 && tokenTransfers.length > 0 ? (related.some((r) => r.kind === 'Send') ? 'send' : 'receive') : netSats > 0 ? 'receive' : 'send'; + common.push({ + network: this._network, + txid: tx.txid, + timestamp: tx.confirmationTime?.timestamp ?? Math.floor(Date.now() / 1000), + direction, + amount: Math.abs(netSats), + fee: tx.fee, + status: tx.confirmationTime ? 'confirmed' : 'pending', + blockHeight: tx.confirmationTime?.height, + tokenTransfers: tokenTransfers.length > 0 ? tokenTransfers : undefined, + explorerUrl: explorerBase ? `${explorerBase}/tx/${tx.txid}` : undefined, + }); + } + + // Transfers without a BTC txid (e.g., blind-receive pending, failed) — surface separately. + for (const t of transfers) { + if (t.txid && seen.has(t.txid)) continue; + const id = t.txid ?? t.invoiceString ?? String(t.idx); + if (seen.has(id)) continue; + seen.add(id); + const tokenTransfers = this.assetTransfersToCommon([t]); + common.push({ + network: this._network, + txid: id, + timestamp: Math.floor((t.updatedAt || t.createdAt) / 1000), + direction: t.kind === 'Send' ? 'send' : 'receive', + status: transferStatusToCommon(t.status), + tokenTransfers: tokenTransfers.length > 0 ? tokenTransfers : undefined, + explorerUrl: explorerBase && t.txid ? `${explorerBase}/tx/${t.txid}` : undefined, + }); + } + + common.sort((a, b) => b.timestamp - a.timestamp); + return common; + } + + private assetTransfersToCommon(list: Array<{ assignments?: Array<{ type: string; amount?: number }>; recipientId?: string; kind: string }>): CommonTokenTransfer[] { + const out: CommonTokenTransfer[] = []; + for (const t of list) { + for (const a of t.assignments ?? []) { + if (a.type !== 'Fungible' && a.type !== 'NonFungible') continue; + // Per-transfer the asset id isn't on the transfer itself; the UI needs + // the token id to fetch metadata. RGB transfers are typically single-asset. + // We leave tokenId blank when not resolvable and let the caller look it up + // via assetId on the parent scope when needed. See fetchTokenBalances() for + // the asset list. + const matched = this._tokens[0]; + out.push({ + tokenId: matched?.id ?? '', + amount: a.amount, + decimals: matched?.decimals ?? 0, + name: matched?.name, + symbol: matched?.symbol, + address: t.recipientId, + logoURI: matched?.logoURI, + }); + } + } + return out; + } + + private async defaultFeeRate(): Promise { + // Conservative fallback; fee estimation UI can override by passing a higher rate + // through a future sendBtc/send parameter. + return this._sdkNetwork === 'testnet' ? 1 : 5; + } + + private async tryBackup(): Promise { + try { + await this.sdk().vssBackup(); + } catch (e) { + globalThis.handleError?.(e, 'rgb-wallet.ts:vssBackup'); + } + } + + /** + * Validates either a Bitcoin taproot address or an `rgb:`/`utxob:` invoice. + * Used by shared `validateAddress(network, input)` for the RGB networks. + */ + static isAddressValid(input: string): boolean { + const s = input.trim(); + if (!s) return false; + if (s.startsWith('rgb:') || s.startsWith('utxob:')) return true; + // taproot p2tr: bc1p... (mainnet) or tb1p... (testnet/signet) + return /^(bc1p|tb1p|bcrt1p)[0-9a-z]{40,}$/i.test(s); + } + + isAddressValid(input: string): boolean { + return RgbWallet.isAddressValid(input); + } +} + +function transferStatusToCommon(status: string): CommonTransaction['status'] { + switch (status) { + case 'Settled': + return 'confirmed'; + case 'Failed': + return 'failed'; + case 'WaitingCounterparty': + case 'WaitingConfirmations': + default: + return 'pending'; + } +} diff --git a/shared/hooks/useBalance.ts b/shared/hooks/useBalance.ts index 3fc99c6be..101776dab 100644 --- a/shared/hooks/useBalance.ts +++ b/shared/hooks/useBalance.ts @@ -4,6 +4,7 @@ import useSWR from 'swr'; import { SparkWallet } from '../class/wallets/spark-wallet'; import { BreezWallet } from '../class/wallets/breez-wallet'; import { ArkWallet } from '../class/wallets/ark-wallet'; +import { RgbWallet } from '../class/wallets/rgb-wallet'; import { StacksWallet } from '../class/wallets/stacks-wallet'; import { getRpcProvider } from '../models/network-getters'; import { IBackgroundCaller } from '../types/IBackgroundCaller'; @@ -15,6 +16,8 @@ import { NETWORK_LIGHTNING_TESTNET, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, + NETWORK_RGB, + NETWORK_RGB_TESTNET, NETWORK_SPARK, NETWORK_STACKS, Networks, @@ -102,6 +105,13 @@ export const balanceFetcher = async (arg: balanceFetcherArg): Promise 0) { + await storage.setItem(cacheKey, JSON.stringify(tokenInfos)); + } + return tokenInfos; } else if (network === NETWORK_STACKS) { if (!backgroundCaller.lazyInitWalletReady(network, accountNumber)) { // wallet not ready, definitely can use cached tokens (if any) @@ -102,7 +118,8 @@ export const tokenDiscoveryFetcher = async (arg: tokenDiscoveryFetcherArg): Prom }; export function useTokenDiscovery(network: Networks, accountNumber: number, backgroundCaller: IBackgroundCaller, storage: IStorage, refreshInterval = 5_000) { - const shouldRefresh = network === NETWORK_SPARK || network === NETWORK_STACKS || network === NETWORK_ARK || network === NETWORK_ARK_MUTINYNET; + const shouldRefresh = + network === NETWORK_SPARK || network === NETWORK_STACKS || network === NETWORK_ARK || network === NETWORK_ARK_MUTINYNET || network === NETWORK_RGB || network === NETWORK_RGB_TESTNET; const arg: tokenDiscoveryFetcherArg = { cacheKey: 'tokenDiscoveryFetcher', diff --git a/shared/hooks/useTransactions.ts b/shared/hooks/useTransactions.ts index 1a334e40f..fd8bf7718 100644 --- a/shared/hooks/useTransactions.ts +++ b/shared/hooks/useTransactions.ts @@ -4,6 +4,7 @@ import useSWR from 'swr'; import { EvmWallet } from '@shared/class/evm-wallet'; import { SparkWallet } from '@shared/class/wallets/spark-wallet'; import { ArkWallet } from '@shared/class/wallets/ark-wallet'; +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; import { StacksWallet } from '@shared/class/wallets/stacks-wallet'; import { CommonTransaction } from '@shared/types/common-transaction'; import { AllNetworkInfos } from '../models/all-network-infos'; @@ -16,6 +17,8 @@ import { NETWORK_LIGHTNING_TESTNET, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, + NETWORK_RGB, + NETWORK_RGB_TESTNET, NETWORK_ROOTSTOCK, NETWORK_SPARK, NETWORK_STACKS, @@ -101,6 +104,12 @@ export const txFetcher = async (arg: txFetcherArg): Promise return await wallet.getCommonTransactions(); } + if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + const wallet = await backgroundCaller.lazyInitWallet(network, accountNumber); + assert(wallet instanceof RgbWallet, 'Not an RGB wallet'); + return await wallet.getCommonTransactions(); + } + if (network === NETWORK_USDT) { // join Liquid and Rootstock, filter by token transactions const liquidTxs = await backgroundCaller.getCommonTransactions(NETWORK_LIQUID, accountNumber); @@ -139,6 +148,11 @@ export function useTransactions(network: Networks, accountNumber: number, backgr refreshInterval = 20_000; break; + case NETWORK_RGB: + case NETWORK_RGB_TESTNET: + refreshInterval = 60_000; + break; + case NETWORK_LIGHTNING: case NETWORK_LIGHTNING_TESTNET: case NETWORK_LIQUID: diff --git a/shared/models/all-network-infos.ts b/shared/models/all-network-infos.ts index f20528944..a8e3796ed 100644 --- a/shared/models/all-network-infos.ts +++ b/shared/models/all-network-infos.ts @@ -18,6 +18,8 @@ import { NETWORK_USDT, NETWORK_STACKS, NETWORK_CITREA, + NETWORK_RGB, + NETWORK_RGB_TESTNET, } from '../types/networks'; export const AllNetworkInfos: Record = { @@ -225,4 +227,27 @@ export const AllNetworkInfos: Record = { isEVM: false, sortIndex: 70, }, + [NETWORK_RGB]: { + chainId: 20, + displayName: 'RGB', + ticker: 'BTC', + decimals: 8, + explorerUrl: 'https://layerz.mempool.space', + rpcUrl: '', + knowMoreUrl: 'https://rgb.tech', + isEVM: false, + sortIndex: 25, + }, + [NETWORK_RGB_TESTNET]: { + chainId: 21, + displayName: 'RGB Testnet', + ticker: 'tBTC', + decimals: 8, + explorerUrl: 'https://mempool.space/testnet', + rpcUrl: '', + knowMoreUrl: 'https://rgb.tech', + isTestnet: true, + isEVM: false, + sortIndex: 85, + }, }; diff --git a/shared/models/network-getters.ts b/shared/models/network-getters.ts index 0ef386fa1..0c833ef0c 100644 --- a/shared/models/network-getters.ts +++ b/shared/models/network-getters.ts @@ -2,7 +2,7 @@ import BigNumber from 'bignumber.js'; import { ethers } from 'ethers'; import { hexStr } from '../modules/string-utils'; -import { getAvailableNetworks, NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_SPARK, NETWORK_STACKS, Networks } from '../types/networks'; +import { getAvailableNetworks, NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_RGB, NETWORK_RGB_TESTNET, NETWORK_SPARK, NETWORK_STACKS, Networks } from '../types/networks'; import { AllNetworkInfos } from './all-network-infos'; /** @@ -96,5 +96,5 @@ export function getIsEVM(network: Networks): boolean { } export function getIsAccountBased(network: Networks): boolean { - return network === NETWORK_ARK || network === NETWORK_ARK_MUTINYNET || network === NETWORK_SPARK || network === NETWORK_STACKS; + return network === NETWORK_ARK || network === NETWORK_ARK_MUTINYNET || network === NETWORK_SPARK || network === NETWORK_STACKS || network === NETWORK_RGB || network === NETWORK_RGB_TESTNET; } diff --git a/shared/modules/wallet-utils.ts b/shared/modules/wallet-utils.ts index 03508e2ec..262a24b85 100644 --- a/shared/modules/wallet-utils.ts +++ b/shared/modules/wallet-utils.ts @@ -6,6 +6,7 @@ import { ArkWallet } from '../class/wallets/ark-wallet'; import { BreezWallet, getBreezNetwork } from '../class/wallets/breez-wallet'; import { HDSegwitBech32Wallet } from '../class/wallets/hd-segwit-bech32-wallet'; import { LegacyWallet } from '../class/wallets/legacy-wallet'; +import { RgbWallet } from '../class/wallets/rgb-wallet'; import { SparkWallet } from '../class/wallets/spark-wallet'; import { StacksWallet } from '../class/wallets/stacks-wallet'; import { WatchOnlyWallet } from '../class/wallets/watch-only-wallet'; @@ -20,6 +21,8 @@ import { NETWORK_CITREA_TESTNET, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, + NETWORK_RGB, + NETWORK_RGB_TESTNET, NETWORK_ROOTSTOCK, NETWORK_SEPOLIA, NETWORK_SPARK, @@ -40,6 +43,8 @@ const cachedWallets: Record = {}; @@ -91,8 +96,10 @@ export type TSupportedLazyInitWalletNetworks = | typeof NETWORK_LIQUID_TESTNET | typeof NETWORK_ARK_MUTINYNET | typeof NETWORK_STACKS - | typeof NETWORK_ARK; -export type TLazyInitedWallets = WatchOnlyWallet | SparkWallet | BreezWallet | ArkWallet | StacksWallet; + | typeof NETWORK_ARK + | typeof NETWORK_RGB + | typeof NETWORK_RGB_TESTNET; +export type TLazyInitedWallets = WatchOnlyWallet | SparkWallet | BreezWallet | ArkWallet | StacksWallet | RgbWallet; /** * Initialize and cache a wallet for the given network/account, using serialization if available. @@ -104,7 +111,7 @@ export type TLazyInitedWallets = WatchOnlyWallet | SparkWallet | BreezWallet | A * @returns The initialized wallet instance */ export async function lazyInitWallet(network: TSupportedLazyInitWalletNetworks, accountNumber: number, storage: IStorage, secureStorage: IStorage): Promise { - if (![NETWORK_BITCOIN, NETWORK_SPARK, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_ARK_MUTINYNET, NETWORK_ARK, NETWORK_STACKS].includes(network)) { + if (![NETWORK_BITCOIN, NETWORK_SPARK, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_ARK_MUTINYNET, NETWORK_ARK, NETWORK_STACKS, NETWORK_RGB, NETWORK_RGB_TESTNET].includes(network)) { throw new Error(`Unsupported network for lazyInitWallet: ${network}`); } @@ -192,6 +199,16 @@ export async function lazyInitWallet(network: TSupportedLazyInitWalletNetworks, return sw; } + if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + assert(masterSeed, 'Master seed is not available'); + const rw = new RgbWallet(network); + rw.setSecret(masterSeed); + rw.setAccountNumber(accountNumber); + await rw.init(storage); + cachedWallets[network][accountNumber] = rw; + return rw; + } + if (network === NETWORK_LIQUID || network === NETWORK_LIQUID_TESTNET) { // we dont save it to storage assert(masterSeed, 'Master seed is not available'); @@ -293,6 +310,9 @@ export function validateAddress(network: Networks, address: string): boolean { return ArkWallet.isAddressValid(a); case NETWORK_STACKS: return StacksWallet.isAddressValid(a); + case NETWORK_RGB: + case NETWORK_RGB_TESTNET: + return RgbWallet.isAddressValid(a); // EVM networks case NETWORK_ROOTSTOCK: case NETWORK_BOTANIX: diff --git a/shared/tests/integration-vi/rgb-wallet.test.ts b/shared/tests/integration-vi/rgb-wallet.test.ts new file mode 100644 index 000000000..bd5d85b29 --- /dev/null +++ b/shared/tests/integration-vi/rgb-wallet.test.ts @@ -0,0 +1,132 @@ +import assert from 'assert'; +import { describe, test, beforeAll } from 'vitest'; + +import { RgbWallet } from '../../class/wallets/rgb-wallet'; +import { NETWORK_RGB_TESTNET } from '../../types/networks'; +import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '../../types/rgb-adapter'; + +/** + * Integration test for RgbWallet against the real testnet. + * + * This test is gated on two env vars: + * TEST_MNEMONIC — any valid BIP39 seed (shared with other integration tests) + * RGB_INTEGRATION=1 — opt-in flag to actually install `@utexo/rgb-sdk` (Node) + * and talk to VSS + the testnet indexer. + * + * The Node SDK is not a regular dependency of `ext` or `mobile` (it ships native + * binaries), so we import it dynamically here and install a thin Node adapter on + * `globalThis.rgbAdapter` for the duration of the test. This mirrors the SparkWallet + * integration test's approach of relying on setupFiles to provision `globalThis`. + */ + +const SHOULD_RUN = !!process.env.TEST_MNEMONIC && process.env.RGB_INTEGRATION === '1'; + +async function installNodeAdapter(): Promise { + // Lazy import — only pulled in when the test actually runs. + // @ts-expect-error optional devDependency (not declared in package.json) + const rgb = await import('@utexo/rgb-sdk'); + const { UTEXOWallet } = rgb; + + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + async createWallet({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + const wallet = new UTEXOWallet(mnemonic, { + network, + dataDir: `/tmp/rgb-integration-${network}-${Date.now()}`, + vssServerUrl, + }); + await wallet.initialize(); + return wallet as unknown as IRgbWallet; + }, + async restoreFromVss({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { + // @utexo/rgb-sdk exposes restoreUtxoWalletFromVss as a top-level helper + const targetDir = `/tmp/rgb-integration-restore-${network}-${Date.now()}`; + await rgb.restoreUtxoWalletFromVss({ mnemonic, targetDir, vssServerUrl }); + const wallet = new UTEXOWallet(mnemonic, { network, dataDir: targetDir, vssServerUrl }); + await wallet.initialize(); + return wallet as unknown as IRgbWallet; + }, + }; + + (globalThis as any).rgbAdapter = adapter; +} + +describe('RgbWallet integration (testnet)', () => { + beforeAll(async () => { + if (!SHOULD_RUN) return; + await installNodeAdapter(); + }); + + test('creates a wallet and fetches a taproot receive address', async (context) => { + if (!SHOULD_RUN) { + console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); + context.skip(); + return; + } + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(process.env.TEST_MNEMONIC!); + await w.init({} as any); + + const address = await w.getOffchainReceiveAddress(); + assert.ok(/^(bc1p|tb1p|bcrt1p)/i.test(address), `Expected taproot address, got: ${address}`); + }); + + test('getOffchainBalance and fetchTokenBalances run against testnet', async (context) => { + if (!SHOULD_RUN) { + console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); + context.skip(); + return; + } + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(process.env.TEST_MNEMONIC!); + await w.init({} as any); + + const balance = await w.getOffchainBalance(); + assert.ok(typeof balance === 'number' && balance >= 0, `unexpected balance: ${balance}`); + + await w.fetchTokenBalances(); + const tokens = w.getTokenBalances(); + assert.ok(Array.isArray(tokens)); + }); + + test('getCommonTransactions merges on-chain txs with RGB transfers', async (context) => { + if (!SHOULD_RUN) { + console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); + context.skip(); + return; + } + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(process.env.TEST_MNEMONIC!); + await w.init({} as any); + + const txs = await w.getCommonTransactions(); + assert.ok(Array.isArray(txs)); + if (txs.length > 0) { + assert.ok(typeof txs[0].timestamp === 'number'); + assert.ok(['confirmed', 'pending', 'failed', 'cancelled'].includes(txs[0].status ?? 'confirmed')); + } + }); + + test('VSS restore-then-createWallet parity: same address from two fresh data dirs', async (context) => { + if (!SHOULD_RUN) { + console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); + context.skip(); + return; + } + + const w1 = new RgbWallet(NETWORK_RGB_TESTNET); + w1.setSecret(process.env.TEST_MNEMONIC!); + await w1.init({} as any); + const a1 = await w1.getOffchainReceiveAddress(); + + const w2 = new RgbWallet(NETWORK_RGB_TESTNET); + w2.setSecret(process.env.TEST_MNEMONIC!); + await w2.init({} as any); + const a2 = await w2.getOffchainReceiveAddress(); + + assert.strictEqual(a1, a2, 'Deterministic address mismatch across adapter instances'); + }); +}); diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts new file mode 100644 index 000000000..6802112f9 --- /dev/null +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RgbWallet } from '../../class/wallets/rgb-wallet'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET } from '../../types/networks'; +import type { IRgbAdapter, IRgbWallet } from '../../types/rgb-adapter'; + +function installAdapter(overrides: Partial = {}) { + const sdkWallet: IRgbWallet = { + dispose: vi.fn(), + getAddress: vi.fn().mockResolvedValue('bc1pexampletaprootexampletaprootexampletaprootexampletaprootex'), + getXpub: vi.fn().mockReturnValue({ xpubVan: '', xpubCol: '' }), + getBtcBalance: vi.fn().mockResolvedValue({ + vanilla: { settled: 100, future: 100, spendable: 100 }, + colored: { settled: 25, future: 25, spendable: 25 }, + }), + listAssets: vi.fn().mockResolvedValue({ nia: [], uda: [], cfa: [], ifa: [] }), + getAssetBalance: vi.fn(), + listUnspents: vi.fn(), + listTransactions: vi.fn().mockResolvedValue([]), + listTransfers: vi.fn().mockResolvedValue([]), + blindReceive: vi.fn(), + witnessReceive: vi.fn(), + decodeRGBInvoice: vi.fn(), + send: vi.fn(), + sendBegin: vi.fn(), + sendEnd: vi.fn(), + sendBtc: vi.fn().mockResolvedValue('btc-txid-abc'), + sendBtcBegin: vi.fn(), + sendBtcEnd: vi.fn(), + createUtxos: vi.fn(), + createUtxosBegin: vi.fn(), + createUtxosEnd: vi.fn(), + signPsbt: vi.fn(), + refreshWallet: vi.fn(), + syncWallet: vi.fn(), + failTransfers: vi.fn(), + vssBackup: vi.fn().mockResolvedValue(1), + vssBackupInfo: vi.fn(), + configureVssBackup: vi.fn(), + disableVssAutoBackup: vi.fn(), + getDefaultVssConfig: vi.fn(), + ...overrides, + } as unknown as IRgbWallet; + + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn().mockResolvedValue(sdkWallet), + restoreFromVss: vi.fn().mockResolvedValue(sdkWallet), + }; + + (globalThis as any).rgbAdapter = adapter; + return { adapter, sdkWallet }; +} + +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +describe('RgbWallet', () => { + beforeEach(() => { + delete (globalThis as any).rgbAdapter; + }); + + describe('isAddressValid', () => { + it('accepts rgb: invoices', () => { + installAdapter(); + expect(RgbWallet.isAddressValid('rgb:utxob:abcdef123456')).toBe(true); + }); + + it('accepts utxob: invoices', () => { + installAdapter(); + expect(RgbWallet.isAddressValid('utxob:1abc2def3ghi')).toBe(true); + }); + + it('accepts taproot addresses', () => { + installAdapter(); + expect(RgbWallet.isAddressValid('bc1ppkpnr0m9avzkpzrra6q57zdsrtzcf39qrzhxag9m4lq30v7r6a3srw0fxj')).toBe(true); + expect(RgbWallet.isAddressValid('tb1ppkpnr0m9avzkpzrra6q57zdsrtzcf39qrzhxag9m4lq30v7r6a3s29ukdd')).toBe(true); + }); + + it('rejects plain bc1q (segwit v0)', () => { + installAdapter(); + expect(RgbWallet.isAddressValid('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(false); + }); + + it('rejects garbage', () => { + installAdapter(); + expect(RgbWallet.isAddressValid('nope')).toBe(false); + expect(RgbWallet.isAddressValid('')).toBe(false); + expect(RgbWallet.isAddressValid(' ')).toBe(false); + }); + }); + + describe('constructor', () => { + it('throws without an adapter installed', () => { + expect(() => new RgbWallet(NETWORK_RGB)).toThrowError(/RGB adapter/); + }); + + it('maps NETWORK_RGB_TESTNET to sdk testnet', async () => { + const { adapter } = installAdapter(); + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + expect(adapter.restoreFromVss).toHaveBeenCalledWith(expect.objectContaining({ network: 'testnet' })); + expect(w.getNetwork()).toBe(NETWORK_RGB_TESTNET); + }); + + it('maps NETWORK_RGB to sdk mainnet', async () => { + const { adapter } = installAdapter(); + const w = new RgbWallet(NETWORK_RGB); + w.setSecret(MNEMONIC); + await w.init({} as any); + expect(adapter.restoreFromVss).toHaveBeenCalledWith(expect.objectContaining({ network: 'mainnet' })); + }); + }); + + describe('init', () => { + it('falls back to createWallet when VSS restore throws', async () => { + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn().mockResolvedValue({} as IRgbWallet), + restoreFromVss: vi.fn().mockRejectedValue(new Error('no vss backup yet')), + }; + (globalThis as any).rgbAdapter = adapter; + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + expect(adapter.restoreFromVss).toHaveBeenCalledOnce(); + expect(adapter.createWallet).toHaveBeenCalledOnce(); + }); + }); + + describe('balance + send', () => { + it('returns vanilla + colored spendable sats', async () => { + installAdapter(); + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + expect(await w.getOffchainBalance()).toBe(125); + }); + + it('pay() delegates to sendBtc and triggers a backup attempt', async () => { + const { sdkWallet } = installAdapter(); + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txid = await w.pay('tb1ppkpnr0m9avzkpzrra6q57zdsrtzcf39qrzhxag9m4lq30v7r6a3s29ukdd', 1000); + expect(txid).toBe('btc-txid-abc'); + expect(sdkWallet.sendBtc).toHaveBeenCalledWith(expect.objectContaining({ amount: 1000 })); + expect(sdkWallet.vssBackup).toHaveBeenCalledOnce(); + }); + }); + + describe('tokens', () => { + it('fetchTokenBalances maps NIA + CFA + IFA assets to CachedTokenInfo', async () => { + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [{ assetId: 'nia-1', name: 'Token A', ticker: 'A', precision: 2, balance: { settled: 10, future: 10, spendable: 10 } }], + cfa: [{ assetId: 'cfa-1', name: 'Collectible', precision: 0, balance: { settled: 1, future: 1, spendable: 1 } }], + ifa: [], + uda: [], + }); + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + await w.fetchTokenBalances(); + const balances = w.getTokenBalances(); + expect(balances).toHaveLength(2); + expect(balances[0].id).toBe('nia-1'); + expect(balances[0].symbol).toBe('A'); + expect(balances[0].decimals).toBe(2); + expect(balances[0].balance).toBe('10'); + expect(balances[1].id).toBe('cfa-1'); + expect(balances[1].symbol).toBe('Collectible'); // no ticker for CFA, falls back to name + }); + }); + + describe('getCommonTransactions', () => { + it('merges listTransactions with listTransfers and flags pending state', async () => { + const { sdkWallet } = installAdapter(); + (sdkWallet.listTransactions as any) = vi.fn().mockResolvedValue([ + { transactionType: 'User', txid: 'tx1', received: 5000, sent: 0, fee: 0, confirmationTime: { height: 100, timestamp: 1700000000 } }, + { transactionType: 'RgbSend', txid: 'tx2', received: 0, sent: 1200, fee: 200, confirmationTime: { height: 101, timestamp: 1700000100 } }, + ]); + (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([ + { idx: 1, batchTransferIdx: 1, createdAt: 1700000000000, updatedAt: 1700000000000, status: 'Settled', kind: 'ReceiveBlind', assignments: [], transportEndpoints: [], txid: 'tx1' }, + { + idx: 2, + batchTransferIdx: 2, + createdAt: 1700000200000, + updatedAt: 1700000200000, + status: 'WaitingCounterparty', + kind: 'ReceiveBlind', + assignments: [], + transportEndpoints: [], + invoiceString: 'rgb:abc', + }, + ]); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs).toHaveLength(3); + // sorted descending by timestamp + expect(txs[0].status).toBe('pending'); + expect(txs[0].txid).toBe('rgb:abc'); + expect(txs[1].txid).toBe('tx2'); + expect(txs[1].direction).toBe('send'); + expect(txs[2].txid).toBe('tx1'); + expect(txs[2].direction).toBe('receive'); + }); + }); +}); diff --git a/shared/types/networks.ts b/shared/types/networks.ts index 23eb2480a..014793a62 100644 --- a/shared/types/networks.ts +++ b/shared/types/networks.ts @@ -15,6 +15,8 @@ export const NETWORK_LIGHTNING = 'lightning' as const; export const NETWORK_LIGHTNING_TESTNET = 'lightning_testnet' as const; export const NETWORK_USDT = 'USDT' as const; export const NETWORK_STACKS = 'stacks' as const; +export const NETWORK_RGB = 'rgb' as const; +export const NETWORK_RGB_TESTNET = 'rgb_testnet' as const; const NetworksIterator = { BITCOIN: NETWORK_BITCOIN, @@ -34,6 +36,8 @@ const NetworksIterator = { LIGHTNING_TESTNET: NETWORK_LIGHTNING_TESTNET, USDT: NETWORK_USDT, STACKS: NETWORK_STACKS, + RGB: NETWORK_RGB, + RGB_TESTNET: NETWORK_RGB_TESTNET, } as const; export type Networks = (typeof NetworksIterator)[keyof typeof NetworksIterator]; diff --git a/shared/types/rgb-adapter.ts b/shared/types/rgb-adapter.ts new file mode 100644 index 000000000..74fc6c9f2 --- /dev/null +++ b/shared/types/rgb-adapter.ts @@ -0,0 +1,76 @@ +import type { UTEXOWalletCore, VssBackupConfig } from '@utexo/rgb-sdk-core'; + +export type RgbNetwork = 'mainnet' | 'testnet'; + +/** + * Subset of UTEXOWalletCore that `shared/` consumes. Platform adapters return + * either the real UTEXOWallet instance (mobile) or a forwarding shim (extension + * popup → offscreen document), both of which satisfy this shape. + */ +export type IRgbWallet = Pick< + UTEXOWalletCore, + | 'dispose' + | 'getAddress' + | 'getXpub' + | 'getBtcBalance' + | 'listAssets' + | 'getAssetBalance' + | 'listUnspents' + | 'listTransactions' + | 'listTransfers' + | 'blindReceive' + | 'witnessReceive' + | 'decodeRGBInvoice' + | 'send' + | 'sendBegin' + | 'sendEnd' + | 'sendBtc' + | 'sendBtcBegin' + | 'sendBtcEnd' + | 'createUtxos' + | 'createUtxosBegin' + | 'createUtxosEnd' + | 'signPsbt' + | 'refreshWallet' + | 'syncWallet' + | 'failTransfers' + | 'vssBackup' + | 'vssBackupInfo' + | 'configureVssBackup' + | 'disableVssAutoBackup' + | 'getDefaultVssConfig' +>; + +export interface IRgbAdapterCreateParams { + mnemonic: string; + network: RgbNetwork; + /** Optional override; defaults to the SDK's DEFAULT_VSS_SERVER_URL. */ + vssServerUrl?: string; +} + +/** + * Platform adapter: the shared RgbWallet talks to the SDK through this factory. + * Mobile uses `@utexo/rgb-sdk-rn`, extension uses `@utexo/rgb-sdk-web` (hosted + * in an offscreen document). Adapters are installed on globalThis at startup. + */ +export interface IRgbAdapter { + /** + * Create or reopen a wallet. On mobile the wallet lives on the filesystem; on + * the extension it lives in IndexedDB inside the offscreen document. + */ + createWallet(params: IRgbAdapterCreateParams): Promise; + + /** + * Restore wallet state from VSS cloud backup, then return an initialised wallet. + * Idempotent: if VSS has no backup for this mnemonic yet, the returned wallet + * is a fresh instance (same state as createWallet would produce). + */ + restoreFromVss(params: IRgbAdapterCreateParams): Promise; + + /** Feature flags so shared code can gate UI. Lightning is not supported in v1. */ + readonly capabilities: { + lightning: boolean; + }; +} + +export type { VssBackupConfig }; From 28eff093eadbb9d4ff6c3f7cb416e2eec993b0a6 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Tue, 14 Apr 2026 15:46:49 +0100 Subject: [PATCH 02/30] feat: rgb --- ext/package-lock.json | 8 ++++---- ext/package.json | 5 ++++- mobile/package-lock.json | 8 ++++---- mobile/package.json | 5 ++++- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/ext/package-lock.json b/ext/package-lock.json index 7af89cae0..8caeb7e22 100644 --- a/ext/package-lock.json +++ b/ext/package-lock.json @@ -24,7 +24,7 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", - "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-core": "1.0.0-beta.3", "@utexo/rgb-sdk-web": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", @@ -6459,9 +6459,9 @@ "integrity": "sha512-EN/3xLlEejoFQ4dfiw8r/xe2R0JUbIKuJYjQtxlf3YqaHmdiGR2eX/8MQ6PJw+YF0NlenC/UAPsCHtytwhrQjg==" }, "node_modules/@utexo/rgb-sdk-core": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-core/-/rgb-sdk-core-1.0.0-beta.2.tgz", - "integrity": "sha512-GgzfpP3r8IbK/29xP/ifUYtjEg7+zcJkRorVPe7F1UMOj4sHKQnrTse5FTmmSKvKhWNHYrx/uJWIyGjpgFbK1Q==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-core/-/rgb-sdk-core-1.0.0-beta.3.tgz", + "integrity": "sha512-TlHnTOp2/sHmjspXI6XYgt/ca3wDtOMXGESdACDY0ocGu6WyGHgI3ia1a5eRwq7H4Q+6Zjx4vT2rurujFKjNdA==", "license": "MIT", "dependencies": { "@noble/hashes": "^2.0.1", diff --git a/ext/package.json b/ext/package.json index 6168558fe..1617b8e28 100755 --- a/ext/package.json +++ b/ext/package.json @@ -20,6 +20,9 @@ "lint:fix": "npm run lint:write", "prepare": "cd .. && husky ext/.husky" }, + "overrides": { + "@utexo/rgb-sdk-core": "1.0.0-beta.3" + }, "dependencies": { "@arkade-os/boltz-swap": "0.3.9", "@arkade-os/sdk": "0.4.10", @@ -36,7 +39,7 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", - "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-core": "1.0.0-beta.3", "@utexo/rgb-sdk-web": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", diff --git a/mobile/package-lock.json b/mobile/package-lock.json index d9d94ea52..d937765b4 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -32,7 +32,7 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", - "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-core": "1.0.0-beta.3", "@utexo/rgb-sdk-rn": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", @@ -8439,9 +8439,9 @@ ] }, "node_modules/@utexo/rgb-sdk-core": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-core/-/rgb-sdk-core-1.0.0-beta.2.tgz", - "integrity": "sha512-GgzfpP3r8IbK/29xP/ifUYtjEg7+zcJkRorVPe7F1UMOj4sHKQnrTse5FTmmSKvKhWNHYrx/uJWIyGjpgFbK1Q==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-core/-/rgb-sdk-core-1.0.0-beta.3.tgz", + "integrity": "sha512-TlHnTOp2/sHmjspXI6XYgt/ca3wDtOMXGESdACDY0ocGu6WyGHgI3ia1a5eRwq7H4Q+6Zjx4vT2rurujFKjNdA==", "license": "MIT", "dependencies": { "@noble/hashes": "^2.0.1", diff --git a/mobile/package.json b/mobile/package.json index d34962a65..3c8f852ec 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -41,6 +41,9 @@ "jest": { "preset": "jest-expo" }, + "overrides": { + "@utexo/rgb-sdk-core": "1.0.0-beta.3" + }, "dependencies": { "@aptabase/react-native": "^0.4.0", "@arkade-os/boltz-swap": "0.3.9", @@ -65,7 +68,7 @@ "@stacks/blockchain-api-client": "8.13.6", "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", - "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-core": "1.0.0-beta.3", "@utexo/rgb-sdk-rn": "1.0.0-beta.8", "assert": "2.1.0", "bignumber.js": "9.3.1", From 4261c5049a56e7cb9a49dbfa9fa32a1f1089d6e2 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Tue, 14 Apr 2026 16:15:36 +0100 Subject: [PATCH 03/30] feat: rgb --- ext/src/modules/background-caller.ts | 7 + mobile/src/modules/rgb-adapter.ts | 25 ++- shared/class/wallets/rgb-wallet.ts | 130 ++++++++++----- shared/hooks/useTokenDiscovery.ts | 9 +- .../tests/integration-vi/rgb-wallet.test.ts | 57 ++++--- shared/tests/unit-vi/rgb-wallet.test.ts | 154 ++++++++++++++---- 6 files changed, 279 insertions(+), 103 deletions(-) diff --git a/ext/src/modules/background-caller.ts b/ext/src/modules/background-caller.ts index 1929508f4..f1214d225 100644 --- a/ext/src/modules/background-caller.ts +++ b/ext/src/modules/background-caller.ts @@ -154,6 +154,13 @@ export const BackgroundCaller: IBackgroundCaller = { }, async getCommonTransactions(...params): Promise { + const [network, accountNumber] = params; + if (network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { + // RGB SDK is WASM/IndexedDB-bound; has to run in Popup, not the SW. + const rw = await BackgroundCaller.lazyInitWallet(network, accountNumber); + assert(rw instanceof RgbWallet); + return await rw.getCommonTransactions(); + } return await Messenger.sendGenericMessageToBackground(MessageType.GET_COMMON_TRANSACTIONS, params); }, diff --git a/mobile/src/modules/rgb-adapter.ts b/mobile/src/modules/rgb-adapter.ts index 8c0bca9ed..be0d17d34 100644 --- a/mobile/src/modules/rgb-adapter.ts +++ b/mobile/src/modules/rgb-adapter.ts @@ -1,3 +1,4 @@ +import { sha256 } from '@noble/hashes/sha256'; import { UTEXOWallet } from '@utexo/rgb-sdk-rn'; import { Directory, Paths } from 'expo-file-system'; @@ -5,8 +6,22 @@ import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '@shared/t const RGB_DATA_ROOT = 'rgb'; -function dataDirFor(network: IRgbAdapterCreateParams['network']): string { - const root = new Directory(Paths.document, RGB_DATA_ROOT, network); +function mnemonicFingerprint(mnemonic: string): string { + const digest = sha256(new TextEncoder().encode(mnemonic.trim().toLowerCase())); + let hex = ''; + for (let i = 0; i < 8; i++) hex += digest[i].toString(16).padStart(2, '0'); + return hex; +} + +/** + * Returns a per-mnemonic data directory. Isolating state by mnemonic prevents + * the SDK from re-opening a previous wallet's rgb-lib sled store when the user + * wipes the app and imports a different seed — the pattern mirrors + * `breeze-adapter.ts` which uses `sha256(mnemonic)` for the same reason. Strips + * the `file://` URI prefix because the RN SDK expects a POSIX path. + */ +function dataDirFor(mnemonic: string, network: IRgbAdapterCreateParams['network']): string { + const root = new Directory(Paths.document, RGB_DATA_ROOT, network, mnemonicFingerprint(mnemonic)); if (!root.exists) root.create({ intermediates: true }); return root.uri.replace(/^file:\/\//, ''); } @@ -17,7 +32,7 @@ class RgbAdapter implements IRgbAdapter { async createWallet({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { const wallet = new UTEXOWallet(mnemonic, { network, - dataDir: dataDirFor(network), + dataDir: dataDirFor(mnemonic, network), vssServerUrl, }); await wallet.initialize(); @@ -25,9 +40,9 @@ class RgbAdapter implements IRgbAdapter { } async restoreFromVss({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { - await UTEXOWallet.restoreFromVss(mnemonic, dataDirFor(network), vssServerUrl ? { serverUrl: vssServerUrl } : undefined); + await UTEXOWallet.restoreFromVss(mnemonic, dataDirFor(mnemonic, network), vssServerUrl ? { serverUrl: vssServerUrl } : undefined); return this.createWallet({ mnemonic, network, vssServerUrl }); } } -global.rgbAdapter = new RgbAdapter(); +globalThis.rgbAdapter = new RgbAdapter(); diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 5a886d8f6..32b83c544 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -19,6 +19,9 @@ type AnyAsset = { media?: { filePath?: string; mime?: string } | null; }; +type SdkTransfer = Awaited>[number]; +type AnnotatedTransfer = SdkTransfer & { assetId?: string }; + export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWallet, InterfaceCanHaveTokens { static readonly type = 'rgb'; static readonly typeReadable = 'RGB'; @@ -33,7 +36,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa private _accountNumber: number = 0; private _sdkWallet: IRgbWallet | undefined; private _tokens: CachedTokenInfo[] = []; - private _address: string | false = false; + private _receiveAddress: string | undefined; public _lastTokensFetch: number = 0; constructor(network: Networks = NETWORK_RGB) { @@ -58,22 +61,25 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } /** - * Bring the SDK wallet online. Tries a VSS restore first (idempotent: no-op - * when no backup exists) so that a fresh install using an existing mnemonic - * picks up state created on another device. + * Bring the SDK wallet online. Tries a VSS restore first so a fresh install + * with an existing mnemonic picks up state created on another device. Any + * "backup not found" signal falls through to a fresh wallet; any other error + * rethrows so we don't silently overwrite remote backups with an empty + * local state on the next mutation. */ async init(_storage: IStorage): Promise { assert(this.secret, 'Cant init RGB wallet: secret is not set.'); const params = { mnemonic: this.secret, network: this._sdkNetwork }; try { this._sdkWallet = await this.adapter.restoreFromVss(params); + return; } catch (e) { - // First-run with no VSS backup; the adapter's restoreFromVss throws here. - // Fall back to a fresh wallet — vssBackup() after the first mutation will - // seed the backup. - globalThis.handleError?.(e, 'rgb-wallet.ts:init'); - this._sdkWallet = await this.adapter.createWallet(params); + if (!isVssBackupMissing(e)) { + globalThis.handleError?.(e, 'rgb-wallet.ts:init:vss'); + throw e; + } } + this._sdkWallet = await this.adapter.createWallet(params); } private sdk(): IRgbWallet { @@ -82,14 +88,19 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } async getOffchainReceiveAddress(): Promise { - if (!this._address) this._address = await this.sdk().getAddress(); - return this._address as string; + if (!this._receiveAddress) this._receiveAddress = await this.sdk().getAddress(); + return this._receiveAddress; } + /** + * Returns the spendable **vanilla** BTC balance. Colored sats (the 1000-sat + * commitment outputs bound to each RGB allocation) are excluded: the user + * can't actually spend them as BTC without destroying an asset allocation. + */ async getOffchainBalance(): Promise { - const bal = await this.sdk().getBtcBalance(); + const [bal] = await Promise.all([this.sdk().getBtcBalance(), this.fetchTokenBalances()]); this._lastBalanceFetch = Date.now(); - return Number(bal.vanilla.spendable) + Number(bal.colored.spendable); + return Number(bal.vanilla.spendable); } async pay(receiverAddress: string, amountSats: number): Promise { @@ -103,10 +114,13 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } /** - * For RGB, `address` is a full `rgb:` invoice. `tokenId` is the asset id. - * Amount is in the asset's base units. + * `invoice` must be a full `rgb:`/`utxob:` invoice. `tokenId` is the RGB asset id. + * Amount is in the asset's base units; the `_memo` parameter is accepted to + * satisfy InterfaceCanHaveTokens but is ignored — the RGB send API has no memo + * field. */ - async transferToken(tokenId: string, amount: bigint, invoice: string): Promise { + async transferToken(tokenId: string, amount: bigint, invoice: string, _memo?: string): Promise { + assert(amount <= BigInt(Number.MAX_SAFE_INTEGER), 'RGB send amount exceeds 2^53 — not representable as JS number'); const result = await this.sdk().send({ invoice, assetId: tokenId, @@ -138,9 +152,15 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa async getCommonTransactions(): Promise { const sdk = this.sdk(); - const [txs, transfers] = await Promise.all([sdk.listTransactions(), sdk.listTransfers()]); + // Get on-chain tx metadata (fee, confirmation) and per-asset transfers. + // `listTransfers()` without an assetId yields transfers with no asset id + // attached, which is useless for UI attribution — we iterate the known + // assets and annotate each transfer with its assetId at the source. + const [txs, assetIds] = await Promise.all([sdk.listTransactions(), this.knownAssetIds()]); + const perAssetTransfers = await Promise.all(assetIds.map(async (aid) => (await sdk.listTransfers(aid)).map((t) => ({ ...t, assetId: aid }) as AnnotatedTransfer))); + const transfers: AnnotatedTransfer[] = perAssetTransfers.flat(); - const transfersByTxid = new Map(); + const transfersByTxid = new Map(); for (const t of transfers) { if (!t.txid) continue; const arr = transfersByTxid.get(t.txid) ?? []; @@ -150,13 +170,14 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa const explorerBase = AllNetworkInfos[this._network].explorerUrl; const common: CommonTransaction[] = []; - const seen = new Set(); + const seenTxids = new Set(); + const seenTransferIds = new Set(); for (const tx of txs) { - seen.add(tx.txid); + seenTxids.add(tx.txid); const netSats = tx.received - tx.sent; const related = transfersByTxid.get(tx.txid) ?? []; - const tokenTransfers = this.assetTransfersToCommon(related); + const tokenTransfers = this.annotatedTransfersToCommon(related); const direction: CommonTransaction['direction'] = netSats === 0 && tokenTransfers.length > 0 ? (related.some((r) => r.kind === 'Send') ? 'send' : 'receive') : netSats > 0 ? 'receive' : 'send'; common.push({ network: this._network, @@ -172,16 +193,18 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa }); } - // Transfers without a BTC txid (e.g., blind-receive pending, failed) — surface separately. + // Pending / failed transfers without a mined txid (blind-receive awaiting + // counterparty, etc.) — surface separately, keyed in a distinct namespace + // so we can't collide with raw txids. for (const t of transfers) { - if (t.txid && seen.has(t.txid)) continue; - const id = t.txid ?? t.invoiceString ?? String(t.idx); - if (seen.has(id)) continue; - seen.add(id); - const tokenTransfers = this.assetTransfersToCommon([t]); + if (t.txid && seenTxids.has(t.txid)) continue; + const key = `transfer:${t.txid ?? t.invoiceString ?? `idx-${t.idx}`}`; + if (seenTransferIds.has(key)) continue; + seenTransferIds.add(key); + const tokenTransfers = this.annotatedTransfersToCommon([t]); common.push({ network: this._network, - txid: id, + txid: t.txid ?? key, timestamp: Math.floor((t.updatedAt || t.createdAt) / 1000), direction: t.kind === 'Send' ? 'send' : 'receive', status: transferStatusToCommon(t.status), @@ -194,25 +217,30 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa return common; } - private assetTransfersToCommon(list: Array<{ assignments?: Array<{ type: string; amount?: number }>; recipientId?: string; kind: string }>): CommonTokenTransfer[] { + /** + * Returns asset ids known to the wallet. Populates `_tokens` as a side effect + * if the cache is empty, so that transfer attribution has metadata to join + * against. + */ + private async knownAssetIds(): Promise { + if (this._tokens.length === 0) await this.fetchTokenBalances(); + return this._tokens.map((t) => t.id); + } + + private annotatedTransfersToCommon(list: AnnotatedTransfer[]): CommonTokenTransfer[] { const out: CommonTokenTransfer[] = []; for (const t of list) { + const metadata = t.assetId ? this._tokens.find((m) => m.id === t.assetId) : undefined; for (const a of t.assignments ?? []) { if (a.type !== 'Fungible' && a.type !== 'NonFungible') continue; - // Per-transfer the asset id isn't on the transfer itself; the UI needs - // the token id to fetch metadata. RGB transfers are typically single-asset. - // We leave tokenId blank when not resolvable and let the caller look it up - // via assetId on the parent scope when needed. See fetchTokenBalances() for - // the asset list. - const matched = this._tokens[0]; out.push({ - tokenId: matched?.id ?? '', + tokenId: t.assetId ?? '', amount: a.amount, - decimals: matched?.decimals ?? 0, - name: matched?.name, - symbol: matched?.symbol, + decimals: metadata?.decimals ?? 0, + name: metadata?.name, + symbol: metadata?.symbol, address: t.recipientId, - logoURI: matched?.logoURI, + logoURI: metadata?.logoURI, }); } } @@ -220,8 +248,6 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } private async defaultFeeRate(): Promise { - // Conservative fallback; fee estimation UI can override by passing a higher rate - // through a future sendBtc/send parameter. return this._sdkNetwork === 'testnet' ? 1 : 5; } @@ -235,13 +261,15 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa /** * Validates either a Bitcoin taproot address or an `rgb:`/`utxob:` invoice. - * Used by shared `validateAddress(network, input)` for the RGB networks. + * The invoice shape check requires at least some payload after the scheme + * prefix — we leave deep decoding to `sdk().decodeRGBInvoice()` at send time. */ static isAddressValid(input: string): boolean { const s = input.trim(); if (!s) return false; - if (s.startsWith('rgb:') || s.startsWith('utxob:')) return true; - // taproot p2tr: bc1p... (mainnet) or tb1p... (testnet/signet) + if (/^rgb:[a-zA-Z0-9:_+$-]{10,}$/i.test(s)) return true; + if (/^utxob:[a-zA-Z0-9$!+_-]{10,}$/i.test(s)) return true; + // taproot p2tr: bc1p... / tb1p... / bcrt1p... return /^(bc1p|tb1p|bcrt1p)[0-9a-z]{40,}$/i.test(s); } @@ -250,6 +278,18 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } } +function isVssBackupMissing(e: unknown): boolean { + // The SDK raises a NotFoundError (or wraps an HTTP 404) when the VSS bucket + // has no backup for this mnemonic yet. Matching by name — rather than + // `instanceof` — keeps us resilient to core version drift. + if (!e) return false; + const err = e as { name?: string; message?: string; statusCode?: number }; + if (err.name === 'NotFoundError') return true; + if (err.statusCode === 404) return true; + if (typeof err.message === 'string' && /not.?found|no.?backup|does not exist/i.test(err.message)) return true; + return false; +} + function transferStatusToCommon(status: string): CommonTransaction['status'] { switch (status) { case 'Settled': diff --git a/shared/hooks/useTokenDiscovery.ts b/shared/hooks/useTokenDiscovery.ts index bb457d427..f685e9180 100644 --- a/shared/hooks/useTokenDiscovery.ts +++ b/shared/hooks/useTokenDiscovery.ts @@ -71,7 +71,14 @@ export const tokenDiscoveryFetcher = async (arg: tokenDiscoveryFetcherArg): Prom const wallet = await backgroundCaller.lazyInitWallet(network, accountNumber); assert(wallet instanceof RgbWallet, 'Not an RGB wallet'); - await wallet.fetchTokenBalances(); + // Rely on the in-memory cache populated by `getOffchainBalance` (useBalance + // fires on a 30s interval and refreshes both BTC + asset balances in one + // round-trip). If balance has never been fetched yet, fall back to storage. + if (!wallet._lastBalanceFetch) { + const cachedTokens = await restoreCachedTokens(cacheKey, storage); + if (cachedTokens) return cachedTokens; + } + const tokenInfos = wallet.getTokenBalances(); if (tokenInfos.length > 0) { await storage.setItem(cacheKey, JSON.stringify(tokenInfos)); diff --git a/shared/tests/integration-vi/rgb-wallet.test.ts b/shared/tests/integration-vi/rgb-wallet.test.ts index bd5d85b29..64ffbe784 100644 --- a/shared/tests/integration-vi/rgb-wallet.test.ts +++ b/shared/tests/integration-vi/rgb-wallet.test.ts @@ -20,11 +20,20 @@ import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '../../typ */ const SHOULD_RUN = !!process.env.TEST_MNEMONIC && process.env.RGB_INTEGRATION === '1'; - -async function installNodeAdapter(): Promise { - // Lazy import — only pulled in when the test actually runs. - // @ts-expect-error optional devDependency (not declared in package.json) - const rgb = await import('@utexo/rgb-sdk'); +const INSTALL_HINT = 'RGB_INTEGRATION=1 requires the Node SDK. Run: `npm i -D @utexo/rgb-sdk@1.0.0-beta.8` in ext/ or mobile/ before running this test.'; + +async function installNodeAdapter(): Promise { + let rgb: any; + try { + // Dynamic import — the package is not a declared dependency (it ships native + // binaries, pulling it into production would bloat the wallet bundle). + // Contributors who opt in via RGB_INTEGRATION=1 are expected to install it + // locally; see INSTALL_HINT above. + rgb = await import(/* @vite-ignore */ '@utexo/rgb-sdk' as any); + } catch (e) { + console.warn(`RGB integration test cannot load @utexo/rgb-sdk — ${INSTALL_HINT}`); + return false; + } const { UTEXOWallet } = rgb; const adapter: IRgbAdapter = { @@ -39,7 +48,6 @@ async function installNodeAdapter(): Promise { return wallet as unknown as IRgbWallet; }, async restoreFromVss({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { - // @utexo/rgb-sdk exposes restoreUtxoWalletFromVss as a top-level helper const targetDir = `/tmp/rgb-integration-restore-${network}-${Date.now()}`; await rgb.restoreUtxoWalletFromVss({ mnemonic, targetDir, vssServerUrl }); const wallet = new UTEXOWallet(mnemonic, { network, dataDir: targetDir, vssServerUrl }); @@ -49,20 +57,33 @@ async function installNodeAdapter(): Promise { }; (globalThis as any).rgbAdapter = adapter; + return true; } +let adapterReady = false; + describe('RgbWallet integration (testnet)', () => { beforeAll(async () => { if (!SHOULD_RUN) return; - await installNodeAdapter(); + adapterReady = await installNodeAdapter(); }); - test('creates a wallet and fetches a taproot receive address', async (context) => { + function maybeSkip(context: { skip: () => void }): boolean { if (!SHOULD_RUN) { console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); context.skip(); - return; + return true; + } + if (!adapterReady) { + console.warn(INSTALL_HINT); + context.skip(); + return true; } + return false; + } + + test('creates a wallet and fetches a taproot receive address', async (context) => { + if (maybeSkip(context)) return; const w = new RgbWallet(NETWORK_RGB_TESTNET); w.setSecret(process.env.TEST_MNEMONIC!); @@ -73,11 +94,7 @@ describe('RgbWallet integration (testnet)', () => { }); test('getOffchainBalance and fetchTokenBalances run against testnet', async (context) => { - if (!SHOULD_RUN) { - console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); - context.skip(); - return; - } + if (maybeSkip(context)) return; const w = new RgbWallet(NETWORK_RGB_TESTNET); w.setSecret(process.env.TEST_MNEMONIC!); @@ -92,11 +109,7 @@ describe('RgbWallet integration (testnet)', () => { }); test('getCommonTransactions merges on-chain txs with RGB transfers', async (context) => { - if (!SHOULD_RUN) { - console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); - context.skip(); - return; - } + if (maybeSkip(context)) return; const w = new RgbWallet(NETWORK_RGB_TESTNET); w.setSecret(process.env.TEST_MNEMONIC!); @@ -111,11 +124,7 @@ describe('RgbWallet integration (testnet)', () => { }); test('VSS restore-then-createWallet parity: same address from two fresh data dirs', async (context) => { - if (!SHOULD_RUN) { - console.warn('TEST_MNEMONIC or RGB_INTEGRATION not set, skipping'); - context.skip(); - return; - } + if (maybeSkip(context)) return; const w1 = new RgbWallet(NETWORK_RGB_TESTNET); w1.setSecret(process.env.TEST_MNEMONIC!); diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index 6802112f9..ec0cfa65e 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -76,6 +76,13 @@ describe('RgbWallet', () => { expect(RgbWallet.isAddressValid('tb1ppkpnr0m9avzkpzrra6q57zdsrtzcf39qrzhxag9m4lq30v7r6a3s29ukdd')).toBe(true); }); + it('rejects bare prefixes (no payload)', () => { + installAdapter(); + expect(RgbWallet.isAddressValid('rgb:')).toBe(false); + expect(RgbWallet.isAddressValid('utxob:')).toBe(false); + expect(RgbWallet.isAddressValid('rgb:abc')).toBe(false); // too short + }); + it('rejects plain bc1q (segwit v0)', () => { installAdapter(); expect(RgbWallet.isAddressValid('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(false); @@ -113,11 +120,11 @@ describe('RgbWallet', () => { }); describe('init', () => { - it('falls back to createWallet when VSS restore throws', async () => { + it('falls back to createWallet when VSS reports backup missing', async () => { const adapter: IRgbAdapter = { capabilities: { lightning: false }, createWallet: vi.fn().mockResolvedValue({} as IRgbWallet), - restoreFromVss: vi.fn().mockRejectedValue(new Error('no vss backup yet')), + restoreFromVss: vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { name: 'NotFoundError' })), }; (globalThis as any).rgbAdapter = adapter; const w = new RgbWallet(NETWORK_RGB_TESTNET); @@ -126,15 +133,28 @@ describe('RgbWallet', () => { expect(adapter.restoreFromVss).toHaveBeenCalledOnce(); expect(adapter.createWallet).toHaveBeenCalledOnce(); }); + + it('rethrows non-missing-backup errors from VSS', async () => { + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn(), + restoreFromVss: vi.fn().mockRejectedValue(Object.assign(new Error('network unreachable'), { statusCode: 503 })), + }; + (globalThis as any).rgbAdapter = adapter; + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await expect(w.init({} as any)).rejects.toThrow(/network unreachable/); + expect(adapter.createWallet).not.toHaveBeenCalled(); + }); }); describe('balance + send', () => { - it('returns vanilla + colored spendable sats', async () => { + it('returns vanilla spendable sats only (excludes colored allocations)', async () => { installAdapter(); const w = new RgbWallet(NETWORK_RGB_TESTNET); w.setSecret(MNEMONIC); await w.init({} as any); - expect(await w.getOffchainBalance()).toBe(125); + expect(await w.getOffchainBalance()).toBe(100); }); it('pay() delegates to sendBtc and triggers a backup attempt', async () => { @@ -147,6 +167,15 @@ describe('RgbWallet', () => { expect(sdkWallet.sendBtc).toHaveBeenCalledWith(expect.objectContaining({ amount: 1000 })); expect(sdkWallet.vssBackup).toHaveBeenCalledOnce(); }); + + it('transferToken rejects amounts above Number.MAX_SAFE_INTEGER', async () => { + installAdapter(); + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const huge = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + await expect(w.transferToken('nia-1', huge, 'rgb:abc')).rejects.toThrow(/exceeds 2\^53/); + }); }); describe('tokens', () => { @@ -174,39 +203,108 @@ describe('RgbWallet', () => { }); describe('getCommonTransactions', () => { - it('merges listTransactions with listTransfers and flags pending state', async () => { + it('attributes per-transfer tokenId correctly across multiple assets', async () => { const { sdkWallet } = installAdapter(); + // Two assets in the wallet + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [ + { assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 2, balance: { settled: 0, future: 0, spendable: 0 } }, + { assetId: 'nia-B', name: 'Token B', ticker: 'B', precision: 0, balance: { settled: 0, future: 0, spendable: 0 } }, + ], + cfa: [], + ifa: [], + uda: [], + }); + // On-chain txs (sdkWallet.listTransactions as any) = vi.fn().mockResolvedValue([ - { transactionType: 'User', txid: 'tx1', received: 5000, sent: 0, fee: 0, confirmationTime: { height: 100, timestamp: 1700000000 } }, - { transactionType: 'RgbSend', txid: 'tx2', received: 0, sent: 1200, fee: 200, confirmationTime: { height: 101, timestamp: 1700000100 } }, - ]); - (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([ - { idx: 1, batchTransferIdx: 1, createdAt: 1700000000000, updatedAt: 1700000000000, status: 'Settled', kind: 'ReceiveBlind', assignments: [], transportEndpoints: [], txid: 'tx1' }, - { - idx: 2, - batchTransferIdx: 2, - createdAt: 1700000200000, - updatedAt: 1700000200000, - status: 'WaitingCounterparty', - kind: 'ReceiveBlind', - assignments: [], - transportEndpoints: [], - invoiceString: 'rgb:abc', - }, + { transactionType: 'RgbSend', txid: 'txA', received: 0, sent: 0, fee: 50, confirmationTime: { height: 100, timestamp: 1700000000 } }, + { transactionType: 'RgbSend', txid: 'txB', received: 0, sent: 0, fee: 50, confirmationTime: { height: 101, timestamp: 1700000100 } }, ]); + // Per-asset transfers: listTransfers is called once per asset id with that id passed in + (sdkWallet.listTransfers as any) = vi.fn().mockImplementation(async (assetId?: string) => { + if (assetId === 'nia-A') { + return [ + { + idx: 1, + batchTransferIdx: 1, + createdAt: 1700000000000, + updatedAt: 1700000000000, + status: 'Settled', + kind: 'Send', + assignments: [{ type: 'Fungible', amount: 10 }], + transportEndpoints: [], + txid: 'txA', + }, + ]; + } + if (assetId === 'nia-B') { + return [ + { + idx: 2, + batchTransferIdx: 2, + createdAt: 1700000100000, + updatedAt: 1700000100000, + status: 'Settled', + kind: 'ReceiveBlind', + assignments: [{ type: 'Fungible', amount: 7 }], + transportEndpoints: [], + txid: 'txB', + }, + ]; + } + return []; + }); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs).toHaveLength(2); + // sorted descending by timestamp → txB (nia-B) first + expect(txs[0].txid).toBe('txB'); + expect(txs[0].tokenTransfers?.[0].tokenId).toBe('nia-B'); + expect(txs[0].tokenTransfers?.[0].symbol).toBe('B'); + expect(txs[0].tokenTransfers?.[0].amount).toBe(7); + expect(txs[1].txid).toBe('txA'); + expect(txs[1].tokenTransfers?.[0].tokenId).toBe('nia-A'); + expect(txs[1].tokenTransfers?.[0].symbol).toBe('A'); + expect(txs[1].tokenTransfers?.[0].amount).toBe(10); + }); + + it('emits pending transfers (no mined txid) under a namespaced key', async () => { + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [{ assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 0, balance: { settled: 0, future: 0, spendable: 0 } }], + cfa: [], + ifa: [], + uda: [], + }); + (sdkWallet.listTransactions as any) = vi.fn().mockResolvedValue([]); + (sdkWallet.listTransfers as any) = vi + .fn() + .mockResolvedValue([ + { + idx: 99, + batchTransferIdx: 1, + createdAt: 1700000200000, + updatedAt: 1700000200000, + status: 'WaitingCounterparty', + kind: 'ReceiveBlind', + assignments: [{ type: 'Fungible', amount: 5 }], + transportEndpoints: [], + invoiceString: 'rgb:pending-invoice-xyz', + }, + ]); const w = new RgbWallet(NETWORK_RGB_TESTNET); w.setSecret(MNEMONIC); await w.init({} as any); const txs = await w.getCommonTransactions(); - expect(txs).toHaveLength(3); - // sorted descending by timestamp + expect(txs).toHaveLength(1); expect(txs[0].status).toBe('pending'); - expect(txs[0].txid).toBe('rgb:abc'); - expect(txs[1].txid).toBe('tx2'); - expect(txs[1].direction).toBe('send'); - expect(txs[2].txid).toBe('tx1'); - expect(txs[2].direction).toBe('receive'); + expect(txs[0].direction).toBe('receive'); + expect(txs[0].txid).toMatch(/^transfer:/); + expect(txs[0].tokenTransfers?.[0].tokenId).toBe('nia-A'); }); }); }); From 13067aa8c89697a6ec4240f50c94e01c6f269cf1 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Tue, 14 Apr 2026 17:01:38 +0100 Subject: [PATCH 04/30] feat: rgb --- ext/src/modules/rgb-adapter.ts | 4 + mobile/src/modules/rgb-adapter.ts | 9 +- shared/class/wallets/rgb-wallet.ts | 90 ++++++++++----- shared/tests/unit-vi/rgb-wallet.test.ts | 143 +++++++++++++++++++++--- 4 files changed, 201 insertions(+), 45 deletions(-) diff --git a/ext/src/modules/rgb-adapter.ts b/ext/src/modules/rgb-adapter.ts index 0ab5ca945..d9ed7fe0e 100644 --- a/ext/src/modules/rgb-adapter.ts +++ b/ext/src/modules/rgb-adapter.ts @@ -2,6 +2,10 @@ import { UTEXOWallet, restoreUtxoWalletFromVss } from '@utexo/rgb-sdk-web'; import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '@shared/types/rgb-adapter'; +// The web SDK persists wallet state in IndexedDB, which is scoped per-origin +// by the browser — so a per-mnemonic subdirectory (as the mobile adapter uses) +// isn't needed here. Switching mnemonic requires clearing the IndexedDB store +// via the SDK's own dispose/restore flow, not a filesystem path trick. class RgbAdapter implements IRgbAdapter { readonly capabilities = { lightning: false } as const; diff --git a/mobile/src/modules/rgb-adapter.ts b/mobile/src/modules/rgb-adapter.ts index be0d17d34..a0f4af4cd 100644 --- a/mobile/src/modules/rgb-adapter.ts +++ b/mobile/src/modules/rgb-adapter.ts @@ -6,8 +6,15 @@ import type { IRgbAdapter, IRgbAdapterCreateParams, IRgbWallet } from '@shared/t const RGB_DATA_ROOT = 'rgb'; +/** + * Hashes the raw mnemonic — no trim/lowercase — to match the normalization used + * by `mobile/src/modules/breeze-adapter.ts` (`sha256(mnemonic)`). Upstream, + * `sanitizeAndValidateMnemonic` in `shared/modules/wallet-utils.ts` canonicalises + * mnemonics before they reach storage, so by the time this runs the mnemonic + * string is already trimmed + lowercased. + */ function mnemonicFingerprint(mnemonic: string): string { - const digest = sha256(new TextEncoder().encode(mnemonic.trim().toLowerCase())); + const digest = sha256(new TextEncoder().encode(mnemonic)); let hex = ''; for (let i = 0; i < 8; i++) hex += digest[i].toString(16).padStart(2, '0'); return hex; diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 32b83c544..6a8bbfb5d 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -38,6 +38,8 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa private _tokens: CachedTokenInfo[] = []; private _receiveAddress: string | undefined; public _lastTokensFetch: number = 0; + /** Dedupes concurrent `fetchTokenBalances` calls from different hooks. */ + private _tokensFetchInFlight: Promise | undefined; constructor(network: Networks = NETWORK_RGB) { super(); @@ -96,10 +98,14 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa * Returns the spendable **vanilla** BTC balance. Colored sats (the 1000-sat * commitment outputs bound to each RGB allocation) are excluded: the user * can't actually spend them as BTC without destroying an asset allocation. + * + * Kicks off an opportunistic asset-list refresh but does not await it — a + * transient indexer failure on the asset side must not fail the BTC balance. */ async getOffchainBalance(): Promise { - const [bal] = await Promise.all([this.sdk().getBtcBalance(), this.fetchTokenBalances()]); + const bal = await this.sdk().getBtcBalance(); this._lastBalanceFetch = Date.now(); + this.fetchTokenBalances().catch((e) => globalThis.handleError?.(e, 'rgb-wallet.ts:fetchTokens')); return Number(bal.vanilla.spendable); } @@ -120,7 +126,10 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa * field. */ async transferToken(tokenId: string, amount: bigint, invoice: string, _memo?: string): Promise { - assert(amount <= BigInt(Number.MAX_SAFE_INTEGER), 'RGB send amount exceeds 2^53 — not representable as JS number'); + // The SDK's send API takes `amount: number`. High-precision assets (e.g. + // precision 18 + a large holding) can exceed JS's safe-integer range; the + // error surfaces here rather than producing a silently rounded send. + assert(amount <= BigInt(Number.MAX_SAFE_INTEGER), `RGB send amount ${amount} exceeds Number.MAX_SAFE_INTEGER (2^53). ` + 'Reduce the amount or wait for SDK bigint support.'); const result = await this.sdk().send({ invoice, assetId: tokenId, @@ -132,18 +141,28 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } async fetchTokenBalances(): Promise { - const list = await this.sdk().listAssets(); - const assets: AnyAsset[] = [...(list.nia ?? []), ...(list.cfa ?? []), ...(list.ifa ?? []), ...(list.uda ?? [])]; - this._tokens = assets.map((a) => ({ - id: a.assetId, - chainId: AllNetworkInfos[this._network].chainId, - name: a.name, - symbol: a.ticker ?? a.name, - decimals: a.precision, - balance: String(a.balance.spendable), - logoURI: a.media?.filePath, - })); - this._lastTokensFetch = Date.now(); + // Dedupes concurrent callers (useBalance + useTransactions + useTokenDiscovery + // can all fire at once on cold start) and returns the same in-flight promise. + if (this._tokensFetchInFlight) return this._tokensFetchInFlight; + this._tokensFetchInFlight = (async () => { + try { + const list = await this.sdk().listAssets(); + const assets: AnyAsset[] = [...(list.nia ?? []), ...(list.cfa ?? []), ...(list.ifa ?? []), ...(list.uda ?? [])]; + this._tokens = assets.map((a) => ({ + id: a.assetId, + chainId: AllNetworkInfos[this._network].chainId, + name: a.name, + symbol: a.ticker ?? a.name, + decimals: a.precision, + balance: String(a.balance.spendable), + logoURI: a.media?.filePath, + })); + this._lastTokensFetch = Date.now(); + } finally { + this._tokensFetchInFlight = undefined; + } + })(); + return this._tokensFetchInFlight; } getTokenBalances(): CachedTokenInfo[] { @@ -229,10 +248,19 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa private annotatedTransfersToCommon(list: AnnotatedTransfer[]): CommonTokenTransfer[] { const out: CommonTokenTransfer[] = []; + // Dedupe against the case where the SDK returns the same transfer (same + // assignments) for multiple per-asset `listTransfers(aid)` calls — e.g. a + // batch transfer that touches two assets might surface in both queries. + // Keying by (tokenId, amount, recipientId, kind) collapses exact repeats + // without masking legitimately-distinct assignments. + const seen = new Set(); for (const t of list) { const metadata = t.assetId ? this._tokens.find((m) => m.id === t.assetId) : undefined; for (const a of t.assignments ?? []) { if (a.type !== 'Fungible' && a.type !== 'NonFungible') continue; + const key = `${t.assetId ?? ''}|${a.amount ?? ''}|${t.recipientId ?? ''}|${t.kind}`; + if (seen.has(key)) continue; + seen.add(key); out.push({ tokenId: t.assetId ?? '', amount: a.amount, @@ -260,16 +288,21 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } /** - * Validates either a Bitcoin taproot address or an `rgb:`/`utxob:` invoice. - * The invoice shape check requires at least some payload after the scheme - * prefix — we leave deep decoding to `sdk().decodeRGBInvoice()` at send time. + * Cheap shape check: confirms the string *looks* like an RGB invoice or a + * taproot address so we don't submit obvious garbage to the SDK. Deep + * validation (invoice consistency, transport endpoint reachability, + * checksum) happens inside `sdk().decodeRGBInvoice()` and bech32m decoding + * at send time — there's no value in duplicating that here. */ static isAddressValid(input: string): boolean { const s = input.trim(); if (!s) return false; - if (/^rgb:[a-zA-Z0-9:_+$-]{10,}$/i.test(s)) return true; - if (/^utxob:[a-zA-Z0-9$!+_-]{10,}$/i.test(s)) return true; - // taproot p2tr: bc1p... / tb1p... / bcrt1p... + // RGB invoices: require the scheme prefix plus a reasonably-long payload. + // Invoice payloads can contain any base64url-like or URL-safe chars + // depending on encoding, so the permissive payload check is intentional. + if (/^rgb:\S{10,}$/i.test(s)) return true; + if (/^utxob:\S{10,}$/i.test(s)) return true; + // Taproot p2tr: bc1p / tb1p / bcrt1p bech32m. return /^(bc1p|tb1p|bcrt1p)[0-9a-z]{40,}$/i.test(s); } @@ -279,14 +312,19 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } function isVssBackupMissing(e: unknown): boolean { - // The SDK raises a NotFoundError (or wraps an HTTP 404) when the VSS bucket - // has no backup for this mnemonic yet. Matching by name — rather than - // `instanceof` — keeps us resilient to core version drift. + // Only treat "VSS has no backup for this mnemonic" as an expected path into + // fresh-wallet creation. Every other error rethrows so we never silently + // overwrite a real remote backup with an empty local state. + // + // Prefer structured signals (error name, HTTP status) over message matching. + // String matches require whole phrases — `/not\s+found/i` intentionally does + // NOT match "host not found" (DNS) or "certificate does not exist" (TLS), + // which are transport errors that must rethrow. if (!e) return false; - const err = e as { name?: string; message?: string; statusCode?: number }; + const err = e as { name?: string; message?: string; statusCode?: number; status?: number; code?: string }; if (err.name === 'NotFoundError') return true; - if (err.statusCode === 404) return true; - if (typeof err.message === 'string' && /not.?found|no.?backup|does not exist/i.test(err.message)) return true; + if (err.statusCode === 404 || err.status === 404) return true; + if (typeof err.message === 'string' && /\bbackup\s+(not\s+found|does\s+not\s+exist|missing)\b/i.test(err.message)) return true; return false; } diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index ec0cfa65e..a0b042adf 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -146,6 +146,33 @@ describe('RgbWallet', () => { await expect(w.init({} as any)).rejects.toThrow(/network unreachable/); expect(adapter.createWallet).not.toHaveBeenCalled(); }); + + it('rethrows transport errors whose message contains "not found" (e.g. DNS/TLS)', async () => { + // The previous, looser regex `/not.?found/i` would have swallowed this. + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn(), + restoreFromVss: vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND vss.example.com — host not found')), + }; + (globalThis as any).rgbAdapter = adapter; + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await expect(w.init({} as any)).rejects.toThrow(/host not found/); + expect(adapter.createWallet).not.toHaveBeenCalled(); + }); + + it('falls back on HTTP 404 from VSS', async () => { + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn().mockResolvedValue({} as IRgbWallet), + restoreFromVss: vi.fn().mockRejectedValue(Object.assign(new Error('vss bucket unavailable'), { statusCode: 404 })), + }; + (globalThis as any).rgbAdapter = adapter; + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + expect(adapter.createWallet).toHaveBeenCalledOnce(); + }); }); describe('balance + send', () => { @@ -174,7 +201,7 @@ describe('RgbWallet', () => { w.setSecret(MNEMONIC); await w.init({} as any); const huge = BigInt(Number.MAX_SAFE_INTEGER) + 1n; - await expect(w.transferToken('nia-1', huge, 'rgb:abc')).rejects.toThrow(/exceeds 2\^53/); + await expect(w.transferToken('nia-1', huge, 'rgb:abc')).rejects.toThrow(/MAX_SAFE_INTEGER/); }); }); @@ -271,30 +298,39 @@ describe('RgbWallet', () => { expect(txs[1].tokenTransfers?.[0].amount).toBe(10); }); - it('emits pending transfers (no mined txid) under a namespaced key', async () => { + it('emits pending transfers (no mined txid) under a namespaced key, with two assets in the wallet', async () => { const { sdkWallet } = installAdapter(); + // Two assets in the wallet: the pending transfer belongs to one (nia-A) + // and listTransfers('nia-B') returns an empty array. This exercises the + // per-asset iteration actually filtering to the right asset. (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ - nia: [{ assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 0, balance: { settled: 0, future: 0, spendable: 0 } }], + nia: [ + { assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 0, balance: { settled: 0, future: 0, spendable: 0 } }, + { assetId: 'nia-B', name: 'Token B', ticker: 'B', precision: 0, balance: { settled: 0, future: 0, spendable: 0 } }, + ], cfa: [], ifa: [], uda: [], }); (sdkWallet.listTransactions as any) = vi.fn().mockResolvedValue([]); - (sdkWallet.listTransfers as any) = vi - .fn() - .mockResolvedValue([ - { - idx: 99, - batchTransferIdx: 1, - createdAt: 1700000200000, - updatedAt: 1700000200000, - status: 'WaitingCounterparty', - kind: 'ReceiveBlind', - assignments: [{ type: 'Fungible', amount: 5 }], - transportEndpoints: [], - invoiceString: 'rgb:pending-invoice-xyz', - }, - ]); + (sdkWallet.listTransfers as any) = vi.fn().mockImplementation(async (assetId?: string) => { + if (assetId === 'nia-A') { + return [ + { + idx: 99, + batchTransferIdx: 1, + createdAt: 1700000200000, + updatedAt: 1700000200000, + status: 'WaitingCounterparty', + kind: 'ReceiveBlind', + assignments: [{ type: 'Fungible', amount: 5 }], + transportEndpoints: [], + invoiceString: 'rgb:pending-invoice-xyz', + }, + ]; + } + return []; + }); const w = new RgbWallet(NETWORK_RGB_TESTNET); w.setSecret(MNEMONIC); @@ -306,5 +342,76 @@ describe('RgbWallet', () => { expect(txs[0].txid).toMatch(/^transfer:/); expect(txs[0].tokenTransfers?.[0].tokenId).toBe('nia-A'); }); + + it('dedupes repeated assignments within a single transfer', async () => { + // Defense-in-depth: if the SDK (or a future pagination bug) returns the + // same assignment twice, our (assetId, amount, recipient, kind) key + // collapses the repeat rather than double-reporting the transfer. + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [{ assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 0, balance: { settled: 0, future: 0, spendable: 0 } }], + cfa: [], + ifa: [], + uda: [], + }); + (sdkWallet.listTransactions as any) = vi + .fn() + .mockResolvedValue([{ transactionType: 'RgbSend', txid: 'tx1', received: 0, sent: 0, fee: 10, confirmationTime: { height: 1, timestamp: 1700000000 } }]); + (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([ + { + idx: 1, + batchTransferIdx: 1, + createdAt: 1700000000000, + updatedAt: 1700000000000, + status: 'Settled', + kind: 'Send', + assignments: [ + { type: 'Fungible', amount: 42 }, + { type: 'Fungible', amount: 42 }, // duplicate entry + ], + transportEndpoints: [], + txid: 'tx1', + recipientId: 'rcp-1', + }, + ]); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs).toHaveLength(1); + expect(txs[0].tokenTransfers).toHaveLength(1); // duplicate collapsed + }); + }); + + describe('fetchTokenBalances', () => { + it('dedupes concurrent callers into a single SDK round-trip', async () => { + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 10)); + return { nia: [], cfa: [], ifa: [], uda: [] }; + }); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + + // Three concurrent callers, only one listAssets call should happen. + await Promise.all([w.fetchTokenBalances(), w.fetchTokenBalances(), w.fetchTokenBalances()]); + expect((sdkWallet.listAssets as any).mock.calls.length).toBe(1); + }); + + it('survives listAssets failure without nuking the wallet', async () => { + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockRejectedValueOnce(new Error('indexer down')); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + + // getOffchainBalance kicks off fetchTokenBalances but must not propagate its error. + const bal = await w.getOffchainBalance(); + expect(bal).toBe(100); + }); }); }); From b9f66199ac56079be7368953561fe58b6c6c406f Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Wed, 15 Apr 2026 13:40:33 +0100 Subject: [PATCH 05/30] feat: rgb --- mobile/package-lock.json | 16 +++------------- mobile/package.json | 3 ++- ...+breez-sdk-liquid-react-native+0.12.2.patch | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/mobile/package-lock.json b/mobile/package-lock.json index d937765b4..98c57d46e 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -10119,16 +10119,6 @@ "react-native": "*" } }, - "node_modules/bdk-rn/node_modules/uniffi-bindgen-react-native": { - "version": "0.29.3-1", - "resolved": "https://github.com/jhugman/uniffi-bindgen-react-native/archive/b9301797ef697331d29edb9d2402ea35c218571e.tar.gz", - "integrity": "sha512-ZJPXOs2wpEreM/1jFnKEj5QWZp8HF9sYeURix90Eq87gmPhpsinOg9dBgQ425aQkhOzgxdycVK8L+2fj8Q4Oxg==", - "license": "MPL-2.0", - "bin": { - "ubrn": "bin/cli.cjs", - "uniffi-bindgen-react-native": "bin/cli.cjs" - } - }, "node_modules/bech32": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", @@ -25250,9 +25240,9 @@ } }, "node_modules/uniffi-bindgen-react-native": { - "version": "0.28.3-5", - "resolved": "https://registry.npmjs.org/uniffi-bindgen-react-native/-/uniffi-bindgen-react-native-0.28.3-5.tgz", - "integrity": "sha512-IOgKr4D+iHYmigAoV03Yuti94l8egKLADXqw/sE0Oyk7FOVexczFr+Kztnx2aHiCBnNv39gSfRCmLGd3i9AFaw==", + "version": "0.29.3-1", + "resolved": "https://registry.npmjs.org/uniffi-bindgen-react-native/-/uniffi-bindgen-react-native-0.29.3-1.tgz", + "integrity": "sha512-o6gXZsAh55yuvhwF2WSFdIHV4phyfWcCmg4DuyfJWJ7CvUz1UcIz2S4u9SmXAz1jsuqvu6Xc9hexrRBB0a5osg==", "license": "MPL-2.0", "bin": { "ubrn": "bin/cli.cjs", diff --git a/mobile/package.json b/mobile/package.json index 3c8f852ec..212c687e3 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -42,7 +42,8 @@ "preset": "jest-expo" }, "overrides": { - "@utexo/rgb-sdk-core": "1.0.0-beta.3" + "@utexo/rgb-sdk-core": "1.0.0-beta.3", + "uniffi-bindgen-react-native": "0.29.3-1" }, "dependencies": { "@aptabase/react-native": "^0.4.0", diff --git a/mobile/patches/@breeztech+breez-sdk-liquid-react-native+0.12.2.patch b/mobile/patches/@breeztech+breez-sdk-liquid-react-native+0.12.2.patch index dae33db80..bba169472 100644 --- a/mobile/patches/@breeztech+breez-sdk-liquid-react-native+0.12.2.patch +++ b/mobile/patches/@breeztech+breez-sdk-liquid-react-native+0.12.2.patch @@ -26,3 +26,21 @@ index e547167..e9c6d94 100644 abiFilters (*reactNativeArchitectures()) } } +diff --git a/node_modules/@breeztech/breez-sdk-liquid-react-native/breeztech-breez-sdk-liquid-react-native.podspec b/node_modules/@breeztech/breez-sdk-liquid-react-native/breeztech-breez-sdk-liquid-react-native.podspec +index 56c3756..2974c7c 100644 +--- a/node_modules/@breeztech/breez-sdk-liquid-react-native/breeztech-breez-sdk-liquid-react-native.podspec ++++ b/node_modules/@breeztech/breez-sdk-liquid-react-native/breeztech-breez-sdk-liquid-react-native.podspec +@@ -17,7 +17,12 @@ Pod::Spec.new do |s| + + s.source_files = "ios/*.{h,m,mm,swift}", "ios/generated/**/*.{h}", "cpp/**/*.{hpp,cpp,c,h}", "cpp/generated/**/*.{hpp,cpp,c,h}" + s.vendored_frameworks = "build/RnBreezSdkLiquid.xcframework" +- s.dependency "uniffi-bindgen-react-native", "0.28.3-5" ++ # Patched: widened from "0.28.3-5" to accept uniffi 0.29.3-1 which ++ # @utexo/rgb-sdk-rn (via bdk-rn@2.2.0-Alpha.1) was generated against. Breez ++ # ships a vendored xcframework whose generated bindings link against uniffi ++ # at build time; only the thin JS/Swift bridge in the npm package picks up ++ # the newer runtime here. ++ s.dependency "uniffi-bindgen-react-native", ">= 0.28.3-5" + + # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. + # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. From 6b6f1892d18bb9d999cb8090efe067f087dfc5f0 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Wed, 15 Apr 2026 13:49:58 +0100 Subject: [PATCH 06/30] feat: rgb --- ext/package-lock.json | 12 ++++++------ ext/package.json | 5 +---- mobile/package-lock.json | 10 +++++----- mobile/package.json | 3 +-- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/ext/package-lock.json b/ext/package-lock.json index 8caeb7e22..b08173bdc 100644 --- a/ext/package-lock.json +++ b/ext/package-lock.json @@ -25,7 +25,7 @@ "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", "@utexo/rgb-sdk-core": "1.0.0-beta.3", - "@utexo/rgb-sdk-web": "1.0.0-beta.8", + "@utexo/rgb-sdk-web": "1.0.0-beta.9", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", @@ -6544,16 +6544,16 @@ } }, "node_modules/@utexo/rgb-sdk-web": { - "version": "1.0.0-beta.8", - "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-web/-/rgb-sdk-web-1.0.0-beta.8.tgz", - "integrity": "sha512-584jztV2lR4ybop7oCIBAHfxi5XUHQ/o9yGekdxGYhOgA1/sMI4hV69Y32oMG9I7K70paNnJ3JsPq1kjkq25tA==", + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-web/-/rgb-sdk-web-1.0.0-beta.9.tgz", + "integrity": "sha512-NkKXIVCp33redRPIUrHWT9FMQDfo7SUl9dzKlkRt17BDVoNgKT4EeuUuHxnIzLJpHRZ56MNGeQPAFhi99BjIrA==", "license": "MIT", "dependencies": { "@bitcoindevkit/bdk-wallet-web": "^0.2.0", "@noble/hashes": "^2.0.1", "@scure/btc-signer": "^2.0.1", - "@utexo/rgb-lib-wasm": "^1.0.0-beta.1", - "@utexo/rgb-sdk-core": "^1.0.0-beta.2", + "@utexo/rgb-lib-wasm": "^1.0.0-beta.2", + "@utexo/rgb-sdk-core": "^1.0.0-beta.3", "bitcoinjs-lib": "^6.1.7" } }, diff --git a/ext/package.json b/ext/package.json index 1617b8e28..01a10960c 100755 --- a/ext/package.json +++ b/ext/package.json @@ -20,9 +20,6 @@ "lint:fix": "npm run lint:write", "prepare": "cd .. && husky ext/.husky" }, - "overrides": { - "@utexo/rgb-sdk-core": "1.0.0-beta.3" - }, "dependencies": { "@arkade-os/boltz-swap": "0.3.9", "@arkade-os/sdk": "0.4.10", @@ -40,7 +37,7 @@ "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", "@utexo/rgb-sdk-core": "1.0.0-beta.3", - "@utexo/rgb-sdk-web": "1.0.0-beta.8", + "@utexo/rgb-sdk-web": "1.0.0-beta.9", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", diff --git a/mobile/package-lock.json b/mobile/package-lock.json index ac40edbe7..f6ce56307 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -33,7 +33,7 @@ "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", "@utexo/rgb-sdk-core": "1.0.0-beta.3", - "@utexo/rgb-sdk-rn": "1.0.0-beta.8", + "@utexo/rgb-sdk-rn": "1.0.0-beta.9", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", @@ -8524,9 +8524,9 @@ } }, "node_modules/@utexo/rgb-sdk-rn": { - "version": "1.0.0-beta.8", - "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-rn/-/rgb-sdk-rn-1.0.0-beta.8.tgz", - "integrity": "sha512-7S2gtfRtTJmAGXR0bp3wEOQ16HW0y7uVceOA0uOIwM6ttwMGBCcY8KGMlZMH04Qc2MrA9pwArTZHWiblJWZhcQ==", + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@utexo/rgb-sdk-rn/-/rgb-sdk-rn-1.0.0-beta.9.tgz", + "integrity": "sha512-D/KHRDWc15YlovqC2RpnSpD275pMVTdq5wRvPONoelHKt7LrwGpbGDtbUljmb3CJwr4SK4zUqZnO1b0L26JC6A==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -8536,7 +8536,7 @@ "@scure/bip32": "^2.0.1", "@scure/bip39": "^2.0.1", "@scure/btc-signer": "^2.0.1", - "@utexo/rgb-sdk-core": "1.0.0-beta.2", + "@utexo/rgb-sdk-core": "1.0.0-beta.3", "axios": "^1.8.4", "bdk-rn": "https://github.com/UTEXO-Protocol/bdk-rn/releases/download/v2.2.0-Alpha.1/bdk-rn-2.2.0-Alpha.1.tgz" }, diff --git a/mobile/package.json b/mobile/package.json index ce2511d78..e25142519 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -42,7 +42,6 @@ "preset": "jest-expo" }, "overrides": { - "@utexo/rgb-sdk-core": "1.0.0-beta.3", "uniffi-bindgen-react-native": "0.29.3-1" }, "dependencies": { @@ -70,7 +69,7 @@ "@stacks/transactions": "7.3.1", "@stacks/wallet-sdk": "7.2.0", "@utexo/rgb-sdk-core": "1.0.0-beta.3", - "@utexo/rgb-sdk-rn": "1.0.0-beta.8", + "@utexo/rgb-sdk-rn": "1.0.0-beta.9", "assert": "2.1.0", "bignumber.js": "9.3.1", "bip21": "3.0.0", From f7fb71f717b0bd63719b3a8dc6769addd0bd46aa Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Wed, 15 Apr 2026 14:29:45 +0100 Subject: [PATCH 07/30] feat: rgb --- .../@utexo+rgb-sdk-rn+1.0.0-beta.9.patch | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 mobile/patches/@utexo+rgb-sdk-rn+1.0.0-beta.9.patch diff --git a/mobile/patches/@utexo+rgb-sdk-rn+1.0.0-beta.9.patch b/mobile/patches/@utexo+rgb-sdk-rn+1.0.0-beta.9.patch new file mode 100644 index 000000000..23a50a8cd --- /dev/null +++ b/mobile/patches/@utexo+rgb-sdk-rn+1.0.0-beta.9.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/@utexo/rgb-sdk-rn/ios/Rgb.mm b/node_modules/@utexo/rgb-sdk-rn/ios/Rgb.mm +index 8e2d685..7e82f8c 100644 +--- a/node_modules/@utexo/rgb-sdk-rn/ios/Rgb.mm ++++ b/node_modules/@utexo/rgb-sdk-rn/ios/Rgb.mm +@@ -362,7 +362,7 @@ - (void)backupInfo:(double)walletId + - (void)blindReceive:(double)walletId + assetId:(NSString * _Nullable)assetId + assignment:(JS::NativeRgb::SpecBlindReceiveAssignment &)assignment +- expirationTimestamp:(NSNumber * _Nullable)expirationTimestamp ++ expirationTimestamp:(NSNumber *)expirationTimestamp + transportEndpoints:(NSArray *)transportEndpoints + minConfirmations:(double)minConfirmations + resolve:(RCTPromiseResolveBlock)resolve +@@ -1736,7 +1736,7 @@ - (void)sync:(double)walletId + - (void)witnessReceive:(double)walletId + assetId:(NSString * _Nullable)assetId + assignment:(JS::NativeRgb::SpecWitnessReceiveAssignment &)assignment +- expirationTimestamp:(NSNumber * _Nullable)expirationTimestamp ++ expirationTimestamp:(NSNumber *)expirationTimestamp + transportEndpoints:(NSArray *)transportEndpoints + minConfirmations:(double)minConfirmations + resolve:(RCTPromiseResolveBlock)resolve From 10a9a621be9b61cc79c327ac273cf5b76ef6db14 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Wed, 15 Apr 2026 20:23:56 +0100 Subject: [PATCH 08/30] feat: rgb --- shared/class/wallets/rgb-wallet.ts | 13 ++++++++----- shared/tests/unit-vi/rgb-wallet.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 6a8bbfb5d..d368d1514 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -316,15 +316,18 @@ function isVssBackupMissing(e: unknown): boolean { // fresh-wallet creation. Every other error rethrows so we never silently // overwrite a real remote backup with an empty local state. // - // Prefer structured signals (error name, HTTP status) over message matching. - // String matches require whole phrases — `/not\s+found/i` intentionally does - // NOT match "host not found" (DNS) or "certificate does not exist" (TLS), - // which are transport errors that must rethrow. + // Each platform SDK signals this differently: + // • RN SDK throws an RgbError with `code === 'VssBackupNotFound'` and a + // message like `Rgb.RgbLibError.VssBackupNotFound`. + // • Web/Node SDKs tend to throw a NotFoundError or an HTTP 404. + // We check all three so existing users (no RGB state yet) get a fresh wallet + // on first unlock rather than a retry-loop error. if (!e) return false; const err = e as { name?: string; message?: string; statusCode?: number; status?: number; code?: string }; + if (err.code === 'VssBackupNotFound') return true; if (err.name === 'NotFoundError') return true; if (err.statusCode === 404 || err.status === 404) return true; - if (typeof err.message === 'string' && /\bbackup\s+(not\s+found|does\s+not\s+exist|missing)\b/i.test(err.message)) return true; + if (typeof err.message === 'string' && /VssBackupNotFound|backup\s*not\s*found|backup\s+(does\s+not\s+exist|missing)/i.test(err.message)) return true; return false; } diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index a0b042adf..41aca5db8 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -173,6 +173,23 @@ describe('RgbWallet', () => { await w.init({} as any); expect(adapter.createWallet).toHaveBeenCalledOnce(); }); + + it('falls back on RgbError code=VssBackupNotFound (RN SDK shape)', async () => { + // Shape emitted by @utexo/rgb-sdk-rn when the user has no prior VSS backup. + // This is the primary path for existing users opening an RGB wallet for + // the first time after an app update. + const err = Object.assign(new Error('Rgb.RgbLibError.VssBackupNotFound'), { code: 'VssBackupNotFound' }); + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn().mockResolvedValue({} as IRgbWallet), + restoreFromVss: vi.fn().mockRejectedValue(err), + }; + (globalThis as any).rgbAdapter = adapter; + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + expect(adapter.createWallet).toHaveBeenCalledOnce(); + }); }); describe('balance + send', () => { From 178d6e1d621d5afee0b452bfe87092b57162d152 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Wed, 22 Apr 2026 18:31:58 +0100 Subject: [PATCH 09/30] feat: rgb --- .agents/debug-ext-with-mcp.md | 152 ++++++++++++++++++++++++ CLAUDE.md | 1 + shared/class/wallets/rgb-wallet.ts | 11 +- shared/tests/unit-vi/rgb-wallet.test.ts | 18 +++ 4 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 .agents/debug-ext-with-mcp.md diff --git a/.agents/debug-ext-with-mcp.md b/.agents/debug-ext-with-mcp.md new file mode 100644 index 000000000..034091343 --- /dev/null +++ b/.agents/debug-ext-with-mcp.md @@ -0,0 +1,152 @@ +# Debugging the Extension with Chrome DevTools MCP + +This guide explains how to set up Chrome DevTools MCP so that Claude Code (or other AI coding agents) can interact with, debug, and take screenshots of the Layerz Wallet Chrome extension. + +## Why Chrome for Testing? + +Chrome 137+ removed `--load-extension` and related flags from branded Chrome builds for security reasons. Extensions cannot be loaded into automated Chrome sessions. **Chrome for Testing** is a dedicated build that retains all automation-friendly flags and is the recommended solution. + +## Prerequisites + +- Node.js v20.19+ +- npm +- Claude Code (or another MCP-compatible client) + +## Step 1: Install Chrome for Testing + +From the `ext/` directory: + +```bash +cd ext +npx @puppeteer/browsers install chrome@stable +``` + +This downloads Chrome for Testing into `ext/chrome/`. The binary path will be printed, e.g.: + +``` +chrome@145.0.7632.46 /Users//z/layerzwallet/ext/chrome/mac_arm-145.0.7632.46/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing +``` + +Save this path for the next step. + +> **Note:** The `ext/chrome/` directory is gitignored. + +## Step 2: Build the Extension + +Make sure you have a fresh build: + +```bash +cd ext +npm start # dev server (watches for changes) +# or +npm run build # one-time production build +``` + +The built extension lives in `ext/build/`. + +## Step 3: Configure Chrome DevTools MCP + +Add the MCP server to Claude Code (user-scoped so it works across projects): + +```bash +claude mcp add chrome-devtools --scope user -- npx chrome-devtools-mcp@latest \ + --executable-path="/Users//z/layerzwallet/ext/chrome/mac_arm-/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" \ + --chrome-arg=--enable-unsafe-extension-debugging \ + --chrome-arg=--disable-extensions-except=/Users//z/layerzwallet/ext/build \ + --chrome-arg=--load-extension=/Users//z/layerzwallet/ext/build \ + --chrome-arg=--window-size=400,600 +``` + +Replace `` and `` with your actual username and Chrome for Testing version. + +Or manually edit `~/.claude.json` and add to the `mcpServers` section: + +```json +{ + "mcpServers": { + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": [ + "chrome-devtools-mcp@latest", + "--executable-path=/Users//z/layerzwallet/ext/chrome/mac_arm-/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "--chrome-arg=--enable-unsafe-extension-debugging", + "--chrome-arg=--disable-extensions-except=/Users//z/layerzwallet/ext/build", + "--chrome-arg=--load-extension=/Users//z/layerzwallet/ext/build", + "--chrome-arg=--window-size=400,600" + ], + "env": {} + } + } +} +``` + +### Config Explained + +| Flag | Purpose | +|------|---------| +| `--executable-path` | Points to Chrome for Testing binary instead of branded Chrome | +| `--enable-unsafe-extension-debugging` | Re-enables extension loading in automated Chrome | +| `--disable-extensions-except` | Whitelists only the Layerz Wallet extension | +| `--load-extension` | Auto-loads the extension from `ext/build/` on startup | +| `--window-size=400,600` | Sets viewport to approximate the extension popup size | + +## Step 4: Restart Claude Code + +After changing the MCP config, restart Claude Code for the changes to take effect. + +## Step 5: Use It + +Once configured, Claude Code can: + +- **Navigate** to the extension popup: `chrome-extension://jfkjdddajnobopldmhfpgblcidgohkak/popup.html` +- **Take screenshots** of the extension UI +- **Read console errors** and network requests +- **Execute JavaScript** in the extension context +- **Click buttons**, fill forms, and interact with the UI +- **Record performance traces** + +### Example Prompts + +``` +Open the Layerz Wallet extension and take a screenshot +``` + +``` +Check for console errors in the extension +``` + +``` +Navigate to the send screen and inspect the network requests +``` + +## Troubleshooting + +### Extension not loading (list empty on chrome://extensions) + +- Make sure you're using **Chrome for Testing**, not branded Chrome +- Verify `ext/build/` exists and contains `manifest.json` and `popup.html` +- Each `--chrome-arg` must be a **separate** array entry (don't combine multiple flags into one string) + +### `--chrome-arg` not working + +Test with a simple visual flag like `--chrome-arg=--window-size=400,600`. If the window size doesn't change, the args aren't being passed. + +### Extension ID different than expected + +When loading an unpacked extension, Chrome assigns the ID based on the extension's path. The ID `jfkjdddajnobopldmhfpgblcidgohkak` is stable as long as the `ext/build/` path stays the same. If the ID changes, navigate to `chrome://extensions` to find the new one. + +### Branded Chrome won't load extensions + +This is expected on Chrome 137+. Use Chrome for Testing as described above. See the [Chromium RFC](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/aEHdhDZ-V0E) for background. + +### Updating Chrome for Testing + +To update to a newer version: + +```bash +cd ext +npx @puppeteer/browsers install chrome@stable +``` + +Then update the `--executable-path` in your MCP config to point to the new version. diff --git a/CLAUDE.md b/CLAUDE.md index 65d310df0..0f5fecd99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,6 +372,7 @@ To add a new Layer2 network: Detailed feature architecture docs live in `.agents/`: - `.agents/swap.md` — Transfer/Swap cross-chain system (TransferServiceManager, providers, UI flow) +- `.agents/debug-ext-with-mcp.md` — How to set up Chrome DevTools MCP for debugging the browser extension with AI agents ## Mobile MCP (Simulator Interaction) diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index d368d1514..8f1282787 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -319,15 +319,18 @@ function isVssBackupMissing(e: unknown): boolean { // Each platform SDK signals this differently: // • RN SDK throws an RgbError with `code === 'VssBackupNotFound'` and a // message like `Rgb.RgbLibError.VssBackupNotFound`. - // • Web/Node SDKs tend to throw a NotFoundError or an HTTP 404. - // We check all three so existing users (no RGB state yet) get a fresh wallet - // on first unlock rather than a retry-loop error. + // • Web SDK throws a non-Error object whose `toString()` returns + // `"VSS backup not found"` — no `message` property, just a stringifier. + // • Node/HTTP paths tend to throw a NotFoundError or an HTTP 404. + // We check all of them so existing users (no RGB state yet) get a fresh + // wallet on first unlock rather than a retry-loop error. if (!e) return false; const err = e as { name?: string; message?: string; statusCode?: number; status?: number; code?: string }; if (err.code === 'VssBackupNotFound') return true; if (err.name === 'NotFoundError') return true; if (err.statusCode === 404 || err.status === 404) return true; - if (typeof err.message === 'string' && /VssBackupNotFound|backup\s*not\s*found|backup\s+(does\s+not\s+exist|missing)/i.test(err.message)) return true; + const text = typeof err.message === 'string' ? err.message : String(e); + if (/VssBackupNotFound|backup\s*not\s*found|backup\s+(does\s+not\s+exist|missing)/i.test(text)) return true; return false; } diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index 41aca5db8..839fb44a3 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -190,6 +190,24 @@ describe('RgbWallet', () => { await w.init({} as any); expect(adapter.createWallet).toHaveBeenCalledOnce(); }); + + it('falls back on web SDK shape (non-Error object, toString = "VSS backup not found")', async () => { + // Observed shape thrown by @utexo/rgb-sdk-web when the user has no VSS + // backup: an object with numeric keys, no `message`, but a `toString()` + // that yields the error phrase. Verifies we fall back to String(e) when + // err.message is missing. + const err = Object.assign({ 0: 'V', 1: 'S', 2: 'S' }, { toString: () => 'VSS backup not found' }); + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn().mockResolvedValue({} as IRgbWallet), + restoreFromVss: vi.fn().mockRejectedValue(err), + }; + (globalThis as any).rgbAdapter = adapter; + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + expect(adapter.createWallet).toHaveBeenCalledOnce(); + }); }); describe('balance + send', () => { From cddee318a4228ac0321a5b16626a98883331133a Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Tue, 28 Apr 2026 14:32:58 +0100 Subject: [PATCH 10/30] fix(rgb): sync wallet before reads --- shared/class/wallets/rgb-wallet.ts | 37 +++++++++++++++++++ .../results.json | 1 + 2 files changed, 38 insertions(+) create mode 100644 shared/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 8f1282787..e47477aee 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -40,6 +40,10 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa public _lastTokensFetch: number = 0; /** Dedupes concurrent `fetchTokenBalances` calls from different hooks. */ private _tokensFetchInFlight: Promise | undefined; + /** Window for skipping a redundant chain sync (ms). */ + private static readonly SYNC_COOLDOWN_MS = 10_000; + private _lastSync: number = 0; + private _syncInFlight: Promise | undefined; constructor(network: Networks = NETWORK_RGB) { super(); @@ -89,6 +93,36 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa return this._sdkWallet; } + /** + * Pulls fresh chain state into the SDK's local store. The rgb-lib wallet + * holds a sled DB that only reflects what has been explicitly synced — so + * `getBtcBalance` / `listTransactions` / `listAssets` all return stale + * (often zero) data until this runs. We call both the BDK UTXO sync + * (`syncWallet`) and the RGB transfer-state refresh (`refreshWallet`); + * transient indexer errors are logged but swallowed so a degraded network + * still produces a readable (stale) balance instead of an empty screen. + */ + private async sync(): Promise { + if (this._syncInFlight) return this._syncInFlight; + if (Date.now() - this._lastSync < RgbWallet.SYNC_COOLDOWN_MS) return; + this._syncInFlight = (async () => { + try { + await this.sdk().syncWallet(); + } catch (e) { + globalThis.handleError?.(e, 'rgb-wallet.ts:syncWallet'); + } + try { + await this.sdk().refreshWallet(); + } catch (e) { + globalThis.handleError?.(e, 'rgb-wallet.ts:refreshWallet'); + } + this._lastSync = Date.now(); + })().finally(() => { + this._syncInFlight = undefined; + }); + return this._syncInFlight; + } + async getOffchainReceiveAddress(): Promise { if (!this._receiveAddress) this._receiveAddress = await this.sdk().getAddress(); return this._receiveAddress; @@ -103,6 +137,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa * transient indexer failure on the asset side must not fail the BTC balance. */ async getOffchainBalance(): Promise { + await this.sync(); const bal = await this.sdk().getBtcBalance(); this._lastBalanceFetch = Date.now(); this.fetchTokenBalances().catch((e) => globalThis.handleError?.(e, 'rgb-wallet.ts:fetchTokens')); @@ -146,6 +181,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa if (this._tokensFetchInFlight) return this._tokensFetchInFlight; this._tokensFetchInFlight = (async () => { try { + await this.sync(); const list = await this.sdk().listAssets(); const assets: AnyAsset[] = [...(list.nia ?? []), ...(list.cfa ?? []), ...(list.ifa ?? []), ...(list.uda ?? [])]; this._tokens = assets.map((a) => ({ @@ -171,6 +207,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa async getCommonTransactions(): Promise { const sdk = this.sdk(); + await this.sync(); // Get on-chain tx metadata (fee, confirmation) and per-asset transfers. // `listTransfers()` without an assetId yields transfers with no asset id // attached, which is useless for UI attribution — we iterate the known diff --git a/shared/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/shared/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 000000000..abe1a8b8e --- /dev/null +++ b/shared/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.5","results":[[":tests/unit-vi/rgb-wallet.test.ts",{"duration":0,"failed":true}]]} \ No newline at end of file From 42c08b38d6e3efb5f48b69c42d6ef5d030d2df44 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Tue, 28 Apr 2026 16:35:21 +0100 Subject: [PATCH 11/30] fix: send --- mobile/app/send/send-confirm.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/mobile/app/send/send-confirm.tsx b/mobile/app/send/send-confirm.tsx index c152bbb9d..5f8ee38f5 100644 --- a/mobile/app/send/send-confirm.tsx +++ b/mobile/app/send/send-confirm.tsx @@ -19,6 +19,7 @@ import * as BlueElectrum from '@shared/blue_modules/BlueElectrum'; import { EvmWallet } from '@shared/class/evm-wallet'; import { ArkWallet } from '@shared/class/wallets/ark-wallet'; import { BreezWallet } from '@shared/class/wallets/breez-wallet'; +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; import { SparkWallet } from '@shared/class/wallets/spark-wallet'; import { StacksWallet } from '@shared/class/wallets/stacks-wallet'; import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; @@ -26,7 +27,18 @@ import { NetworkContext } from '@shared/hooks/NetworkContext'; import { useCachedExchangeRate } from '@shared/hooks/useCachedExchangeRate'; import { getDecimalsByNetwork, getIsAccountBased, getIsEVM, getTickerByNetwork } from '@shared/models/network-getters'; import { formatBalance } from '@shared/modules/string-utils'; -import { NETWORK_ARK, NETWORK_ARK_MUTINYNET, NETWORK_BITCOIN, NETWORK_LIQUID, NETWORK_LIQUID_TESTNET, NETWORK_SPARK, NETWORK_STACKS, NETWORK_USDT } from '@shared/types/networks'; +import { + NETWORK_ARK, + NETWORK_ARK_MUTINYNET, + NETWORK_BITCOIN, + NETWORK_LIQUID, + NETWORK_LIQUID_TESTNET, + NETWORK_RGB, + NETWORK_RGB_TESTNET, + NETWORK_SPARK, + NETWORK_STACKS, + NETWORK_USDT, +} from '@shared/types/networks'; import { useSendFlow } from './_layout'; const SuccessRiveAnimation = () => { @@ -145,9 +157,9 @@ const SendConfirm: React.FC = ({ ticker, token }) => { setError(''); try { - if (network === NETWORK_ARK || network === NETWORK_ARK_MUTINYNET || network === NETWORK_SPARK || network === NETWORK_STACKS) { + if (network === NETWORK_ARK || network === NETWORK_ARK_MUTINYNET || network === NETWORK_SPARK || network === NETWORK_STACKS || network === NETWORK_RGB || network === NETWORK_RGB_TESTNET) { const wallet = await BackgroundExecutor.lazyInitWallet(network, accountNumber); - assert(wallet instanceof ArkWallet || wallet instanceof SparkWallet || wallet instanceof StacksWallet, 'Internal error: incorrect wallet instance'); + assert(wallet instanceof ArkWallet || wallet instanceof SparkWallet || wallet instanceof StacksWallet || wallet instanceof RgbWallet, 'Internal error: incorrect wallet instance'); // Check if we're sending a token and the wallet supports tokens if (token && walletCanHaveTokens(wallet)) { From 3cd469e97cadee2bfb8bd3861617351d955c227b Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Sat, 2 May 2026 13:30:29 +0100 Subject: [PATCH 12/30] fix: wip --- mobile/src/modules/rgb-adapter.ts | 62 ++++++++++++++++++++++++++---- shared/class/wallets/rgb-wallet.ts | 21 +++++++--- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/mobile/src/modules/rgb-adapter.ts b/mobile/src/modules/rgb-adapter.ts index a0f4af4cd..bd7b37f7f 100644 --- a/mobile/src/modules/rgb-adapter.ts +++ b/mobile/src/modules/rgb-adapter.ts @@ -33,21 +33,67 @@ function dataDirFor(mnemonic: string, network: IRgbAdapterCreateParams['network' return root.uri.replace(/^file:\/\//, ''); } +/** + * rgb-lib throws these messages when the local sled store can't be parsed — + * usually from an interrupted write (app killed mid-commit) or a schema bump + * across an SDK upgrade. The Android binding self-heals (see RgbModule.kt:271); + * iOS doesn't, so we mirror the recovery here. + */ +function isCorruptStore(e: unknown): boolean { + const msg = (e as { message?: string })?.message ?? String(e); + return /bincode error while reading entry/i.test(msg) || /failed to fill whole buffer/i.test(msg); +} + +/** + * Native dirs the iOS rgb-sdk-rn binding actually writes to. The `dataDir` we + * pass to `WalletManager` is **ignored** by `_initializeWallet` in + * `RgbSwiftHelper.swift:210`, which hardcodes `Documents//` (with + * `network = toNativeNetwork()`). Each `UTEXOWallet` opens TWO + * sub-wallets: a layer1 wallet on the bridge bitcoin chain, and a utexo + * sidechain wallet that the SDK maps to `signet`. So for either preset we + * have two on-disk dirs we have to wipe to recover. + * + * Tracked upstream: https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/20 + */ +function nativeWalletDirs(network: IRgbAdapterCreateParams['network']): Directory[] { + const layer1 = network === 'testnet' ? 'testnet' : 'mainnet'; + return [new Directory(Paths.document, layer1), new Directory(Paths.document, 'signet')]; +} + class RgbAdapter implements IRgbAdapter { readonly capabilities = { lightning: false } as const; async createWallet({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { - const wallet = new UTEXOWallet(mnemonic, { - network, - dataDir: dataDirFor(mnemonic, network), - vssServerUrl, - }); - await wallet.initialize(); - return wallet; + const open = async () => { + const wallet = new UTEXOWallet(mnemonic, { + network, + dataDir: dataDirFor(mnemonic, network), + vssServerUrl, + }); + await wallet.initialize(); + return wallet; + }; + try { + return await open(); + } catch (e) { + if (!isCorruptStore(e)) throw e; + for (const dir of nativeWalletDirs(network)) { + if (dir.exists) dir.delete(); + } + const adapterDir = new Directory(Paths.document, RGB_DATA_ROOT, network, mnemonicFingerprint(mnemonic)); + if (adapterDir.exists) adapterDir.delete(); + return open(); + } } async restoreFromVss({ mnemonic, network, vssServerUrl }: IRgbAdapterCreateParams): Promise { - await UTEXOWallet.restoreFromVss(mnemonic, dataDirFor(mnemonic, network), vssServerUrl ? { serverUrl: vssServerUrl } : undefined); + const dir = dataDirFor(mnemonic, network); + // rgb-lib refuses to restore over an existing wallet dir + // (`WalletDirAlreadyExists`). If we already have local state, skip the VSS + // step — `createWallet` will reopen the existing sled stores in place. + if (!new Directory(dir, 'layer1').exists) { + await UTEXOWallet.restoreFromVss(mnemonic, dir, vssServerUrl ? { serverUrl: vssServerUrl } : undefined); + } return this.createWallet({ mnemonic, network, vssServerUrl }); } } diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index e47477aee..88010df90 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -349,18 +349,26 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } function isVssBackupMissing(e: unknown): boolean { - // Only treat "VSS has no backup for this mnemonic" as an expected path into - // fresh-wallet creation. Every other error rethrows so we never silently - // overwrite a real remote backup with an empty local state. + // Only treat "VSS has no backup for this mnemonic" — or "the backup we have + // is unreadable" — as an expected path into fresh-wallet creation. Every + // other error rethrows so we never silently overwrite a real remote backup + // with an empty local state. // - // Each platform SDK signals this differently: + // Each platform SDK signals "missing" differently: // • RN SDK throws an RgbError with `code === 'VssBackupNotFound'` and a // message like `Rgb.RgbLibError.VssBackupNotFound`. // • Web SDK throws a non-Error object whose `toString()` returns // `"VSS backup not found"` — no `message` property, just a stringifier. // • Node/HTTP paths tend to throw a NotFoundError or an HTTP 404. - // We check all of them so existing users (no RGB state yet) get a fresh - // wallet on first unlock rather than a retry-loop error. + // + // "Unreadable" shows up as bincode/parse errors when an older SDK wrote the + // backup and the current one can't decode it. Beta-to-beta schema breaks + // are routine; treat them as missing so the next mutation rewrites the + // backup cleanly. Acceptable risk: a transient decoder bug could discard a + // good remote backup on first unlock — but the alternative is a permanent + // boot loop with no recovery path. + // + // Tracked upstream: https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/20 if (!e) return false; const err = e as { name?: string; message?: string; statusCode?: number; status?: number; code?: string }; if (err.code === 'VssBackupNotFound') return true; @@ -368,6 +376,7 @@ function isVssBackupMissing(e: unknown): boolean { if (err.statusCode === 404 || err.status === 404) return true; const text = typeof err.message === 'string' ? err.message : String(e); if (/VssBackupNotFound|backup\s*not\s*found|backup\s+(does\s+not\s+exist|missing)/i.test(text)) return true; + if (/bincode error while reading entry|failed to fill whole buffer/i.test(text)) return true; return false; } From ac53abb10833b6422a1171c65661361712acc8ab Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Sat, 2 May 2026 13:54:01 +0100 Subject: [PATCH 13/30] fix: token issue --- mobile/app/issue-asset.tsx | 257 ++++++++++++++++++++++ mobile/components/ActionButtons.tsx | 10 +- mobile/components/ProtectedRouteStack.tsx | 1 + shared/class/wallets/rgb-wallet.ts | 32 +++ shared/tests/unit-vi/rgb-wallet.test.ts | 31 ++- shared/types/rgb-adapter.ts | 1 + 6 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 mobile/app/issue-asset.tsx diff --git a/mobile/app/issue-asset.tsx b/mobile/app/issue-asset.tsx new file mode 100644 index 000000000..3a13e679a --- /dev/null +++ b/mobile/app/issue-asset.tsx @@ -0,0 +1,257 @@ +import * as Clipboard from 'expo-clipboard'; +import * as Haptics from 'expo-haptics'; +import { Stack, useRouter } from 'expo-router'; +import React, { useContext, useState } from 'react'; +import { Alert, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TextInput, View } from 'react-native'; + +import Button from '@/components/Button'; +import RadialGradientScreen from '@/components/RadialGradientScreen'; +import ScreenHeader from '@/components/navigation/ScreenHeader'; +import { ThemedText } from '@/components/ThemedText'; +import { BackgroundExecutor } from '@/src/modules/background-executor'; +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET } from '@shared/types/networks'; + +type IssuedAsset = { assetId: string; ticker: string; name: string; precision: number }; + +// rgb-lib's "no spendable colorable UTXO available for issuance" error name. The +// RN binding surfaces it via `code` and as a substring of the message; either +// form means the user needs to run createUtxos() first. +function isNoUtxoSlots(e: unknown): boolean { + const err = e as { code?: string; message?: string }; + if (err?.code === 'InsufficientAllocationSlots') return true; + const msg = String(err?.message ?? e); + return /InsufficientAllocationSlots|insufficient.*allocation/i.test(msg); +} + +export default function IssueAssetScreen() { + const router = useRouter(); + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + + const [ticker, setTicker] = useState(''); + const [name, setName] = useState(''); + const [precisionStr, setPrecisionStr] = useState('8'); + const [amountStr, setAmountStr] = useState(''); + + const [error, setError] = useState(null); + const [needsUtxos, setNeedsUtxos] = useState(false); + const [isCreatingUtxos, setIsCreatingUtxos] = useState(false); + const [isIssuing, setIsIssuing] = useState(false); + const [result, setResult] = useState(null); + + if (network !== NETWORK_RGB && network !== NETWORK_RGB_TESTNET) { + return ( + + + + + Switch to an RGB network to issue assets. + + + ); + } + + const precision = Number(precisionStr); + const amount = Number(amountStr); + const trimmedTicker = ticker.trim(); + const trimmedName = name.trim(); + + const validation = + !trimmedTicker || trimmedTicker.length > 8 + ? 'Ticker must be 1-8 characters.' + : !trimmedName + ? 'Name is required.' + : !Number.isInteger(precision) || precision < 0 || precision > 18 + ? 'Precision must be an integer between 0 and 18.' + : !Number.isFinite(amount) || amount <= 0 || !Number.isSafeInteger(amount) + ? 'Amount must be a positive integer (in base units).' + : null; + + const submit = async () => { + setError(null); + setIsIssuing(true); + try { + const wallet = await BackgroundExecutor.lazyInitWallet(network, accountNumber); + if (!(wallet instanceof RgbWallet)) throw new Error('Wallet is not an RgbWallet'); + const issued = await wallet.issueAssetNia({ + ticker: trimmedTicker, + name: trimmedName, + precision, + amounts: [amount], + }); + setResult(issued); + setNeedsUtxos(false); + } catch (e: any) { + console.warn('issueAssetNia failed:', e); + if (isNoUtxoSlots(e)) { + setNeedsUtxos(true); + setError('You need a colorable UTXO before issuing an asset. Tap "Create UTXO" to make one (uses a small amount of testnet sats).'); + } else { + setError(e?.message ?? 'Failed to issue asset'); + } + } finally { + setIsIssuing(false); + } + }; + + const createUtxos = async () => { + setError(null); + setIsCreatingUtxos(true); + try { + const wallet = await BackgroundExecutor.lazyInitWallet(network, accountNumber); + if (!(wallet instanceof RgbWallet)) throw new Error('Wallet is not an RgbWallet'); + await wallet.createUtxos(); + setNeedsUtxos(false); + // Auto-retry the issuance after the UTXO-creation tx is broadcast. The + // SDK confirms this synchronously enough that issueAssetNia can proceed. + await submit(); + } catch (e: any) { + console.warn('createUtxos failed:', e); + setError(e?.message ?? 'Failed to create UTXO. Make sure the wallet has some testnet sats.'); + } finally { + setIsCreatingUtxos(false); + } + }; + + const copyAssetId = async () => { + if (!result) return; + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + await Clipboard.setStringAsync(result.assetId); + Alert.alert('Copied', 'Asset ID copied to clipboard.'); + }; + + if (result) { + return ( + + + + + Ticker + {result.ticker} + Name + {result.name} + Asset ID + + {result.assetId} + + + + +

+ {result.type === 'blind' + ? 'Share this invoice with the sender. Their payment will land on a UTXO known only to you.' + : 'No free allocation slot was available, so this invoice creates a fresh slot when paid. Slightly less private than a blind invoice.'} +

+ + navigate('/')}>Done + + ); + } + + // Form view. + return ( +
+ Receive RGB Asset +
+ Asset +
+ +
+ +
+ Amount (base units) +
+ setAmountStr(e.target.value.replace(/[^0-9]/g, ''))} data-testid="ReceiveRgb.Amount" /> +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + {isGenerating ? 'Generating…' : 'Generate Invoice'} + +
+ +
+ +
+
+ ); +}; + +export default ReceiveRgbToken; diff --git a/ext/src/pages/Popup/SendRgb.tsx b/ext/src/pages/Popup/SendRgb.tsx new file mode 100644 index 000000000..a77821231 --- /dev/null +++ b/ext/src/pages/Popup/SendRgb.tsx @@ -0,0 +1,283 @@ +import assert from 'assert'; +import BigNumber from 'bignumber.js'; +import { Scan, SendIcon } from 'lucide-react'; +import React, { useContext, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { RgbWallet } from '@shared/class/wallets/rgb-wallet'; +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { formatBalance } from '@shared/modules/string-utils'; +import { NETWORK_RGB, NETWORK_RGB_TESTNET } from '@shared/types/networks'; +import { CachedTokenInfo } from '@shared/types/token-info'; + +import { AskMnemonicContext } from '../../hooks/AskMnemonicContext'; +import { useScanQR } from '../../hooks/ScanQrContext'; +import { BackgroundCaller } from '../../modules/background-caller'; +import { Button, HodlButton, Input, WideButton } from './DesignSystem'; + +enum Step { + Init, + Loading, + Prepared, + Sending, + Sent, +} + +type DecodedInvoiceState = { + isInvoice: true; + token: CachedTokenInfo; + amountBase: bigint; // amount in base units; carried by the invoice +}; + +type DecodedTaprootState = { + isInvoice: false; + amountSats: number; +}; + +const SendRgb: React.FC = () => { + const scanQr = useScanQR(); + const navigate = useNavigate(); + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + const { askMnemonic } = useContext(AskMnemonicContext); + + const [recipient, setRecipient] = useState(''); + const [amountStr, setAmountStr] = useState(''); + const [error, setError] = useState(''); + const [step, setStep] = useState(Step.Init); + const [txid, setTxid] = useState(''); + const [vanillaSatBalance, setVanillaSatBalance] = useState(null); + const decoded = useRef(null); + const walletRef = useRef(null); + + const isRgb = network === NETWORK_RGB || network === NETWORK_RGB_TESTNET; + + if (!isRgb) { + return ( +
+

Send

+

Switch to an RGB network to send RGB assets or tBTC.

+
+ ); + } + + const looksLikeInvoice = (s: string) => s.startsWith('rgb:') || s.startsWith('utxob:'); + + const prepare = async () => { + setError(''); + setStep(Step.Loading); + try { + const trimmed = recipient.trim(); + assert(trimmed, 'Recipient is required'); + assert(RgbWallet.isAddressValid(trimmed), 'Invalid address or invoice'); + + const wallet = await BackgroundCaller.lazyInitWallet(network, accountNumber); + assert(wallet instanceof RgbWallet, 'Wallet is not an RgbWallet'); + walletRef.current = wallet; + + if (looksLikeInvoice(trimmed)) { + const d = await wallet.decodeInvoice(trimmed); + assert(d, 'Could not decode invoice'); + await wallet.fetchTokenBalances(); + const tokens = wallet.getTokenBalances(); + const matched = d.assetId ? tokens.find((t) => t.id === d.assetId) : undefined; + assert(matched, d.assetId ? `Asset ${d.assetId} not in your wallet` : 'Invoice has no asset id'); + + // The invoice must carry an amount; otherwise we can't send a token + // without prompting the user for one. Mobile takes the same shortcut. + assert(typeof d.amount === 'number' && d.amount > 0, 'Invoice has no amount; cannot send.'); + + decoded.current = { + isInvoice: true, + token: matched, + amountBase: BigInt(d.amount), + }; + } else { + // Taproot tBTC send — require an amount field from the user. + const n = Number(amountStr); + assert(!isNaN(n) && n > 0, 'Amount must be a positive number'); + const sats = new BigNumber(amountStr).multipliedBy(new BigNumber(10).pow(8)).toNumber(); + assert(Number.isInteger(sats) && sats > 0, 'Amount must be a positive integer in sats'); + + const balance = await wallet.getOffchainBalance(); + setVanillaSatBalance(balance); + assert(sats <= balance, `Not enough vanilla sats (have ${balance})`); + + decoded.current = { isInvoice: false, amountSats: sats }; + } + + await askMnemonic(); + setStep(Step.Prepared); + } catch (e: any) { + setError(e?.message ?? 'Failed to prepare transaction'); + setStep(Step.Init); + } + }; + + const broadcast = async () => { + setStep(Step.Sending); + setError(''); + try { + const wallet = walletRef.current; + const d = decoded.current; + assert(wallet, 'Internal error: wallet missing'); + assert(d, 'Internal error: nothing prepared'); + + let id: string; + if (d.isInvoice) { + id = await wallet.transferToken(d.token.id, d.amountBase, recipient.trim()); + } else { + id = await wallet.pay(recipient.trim(), d.amountSats); + } + assert(id, 'Transaction failed'); + setTxid(id); + setStep(Step.Sent); + } catch (e: any) { + setError(e?.message ?? 'Send failed'); + setStep(Step.Prepared); + } + }; + + if (step === Step.Sent) { + return ( +
+
+

Sent!

+

Your transaction is on its way.

+ {txid ?

txid: {txid}

: null} + navigate('/')}>Back to Wallet +
+ ); + } + + const isInvoiceMode = looksLikeInvoice(recipient.trim()); + + return ( +
+

Send {isInvoiceMode ? 'RGB asset' : 'tBTC'}

+ + {step !== Step.Prepared ? ( + <> +
+ Recipient address or invoice +
+
+ setRecipient(e.target.value)} /> + +
+
+ + {!isInvoiceMode && recipient.trim().length > 0 ? ( +
+ Amount (tBTC) +
+ setAmountStr(e.target.value)} data-testid="SendRgb.Amount" /> + {vanillaSatBalance !== null ?
Available: {formatBalance(String(vanillaSatBalance), 8, 8)} tBTC
: null} +
+ ) : null} + + ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {step === Step.Loading ? Preparing… : null} + {step === Step.Sending ? Sending… : null} + + {step === Step.Init ? ( + + Continue + + ) : null} + + {step === Step.Prepared && decoded.current ? ( +
+
+ {decoded.current.isInvoice ? ( +
+
+ Asset: {decoded.current.token.symbol || decoded.current.token.name} +
+
+ Amount: {formatBalance(decoded.current.amountBase.toString(), decoded.current.token.decimals, 8)} {decoded.current.token.symbol} +
+
+ To: {recipient.trim()} +
+
+ ) : ( +
+
+ Amount: {amountStr} tBTC ({decoded.current.amountSats} sats) +
+
+ To: {recipient.trim()} +
+
+ )} +
+ + + Hold to confirm send + + + +
+ ) : null} +
+ ); +}; + +export default SendRgb; From 22c56b64495acc479919aeaed10601164bedbbd3 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Sun, 3 May 2026 23:02:49 +0100 Subject: [PATCH 22/30] fix: backup --- shared/class/wallets/rgb-wallet.ts | 29 ++++++++++++++---- shared/tests/unit-vi/rgb-wallet.test.ts | 40 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 92579dbd2..550e5c081 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -206,12 +206,20 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa try { info = await candidate.vssBackupInfo(); } catch (probeErr) { - // Probe itself failed. Anything that isn't a clear "server says no - // backup here" is treated as unreachable — the alternative is silently - // overwriting a real backup with empty state, which is unrecoverable. - await disposeQuiet(candidate); - globalThis.handleError?.(probeErr, 'rgb-wallet.ts:init:vssBackupInfoProbe'); - throw new RgbBackupServerUnreachableError(); + // The web SDK throws on a 404 from `getObject` rather than returning + // `{ backupExists: false }`. If the throw matches our "missing" detector + // (same shape as the original restore error), treat it as a confirmed + // "no backup" answer — same outcome as the success branch with + // `info.backupExists === false`. Anything else is treated as unreachable + // — the alternative is silently overwriting a real backup with empty + // state, which is unrecoverable. + if (isVssBackupMissing(probeErr)) { + info = { backupExists: false } as Awaited>; + } else { + await disposeQuiet(candidate); + globalThis.handleError?.(probeErr, 'rgb-wallet.ts:init:vssBackupInfoProbe'); + throw new RgbBackupServerUnreachableError(); + } } if (info.backupExists) { @@ -843,6 +851,14 @@ function isVssBackupMissing(e: unknown): boolean { // good remote backup on first unlock — but the alternative is a permanent // boot loop with no recovery path. // + // The "VSS backup not configured" / "VSS not initialized" message is the + // web SDK's response when `vssBackupInfo()` is called on a wallet that + // hasn't run `restoreFromVss` first — i.e. exactly the candidate built in + // `acquireFreshWalletAfterProbe`. The web wasm exposes no probe API that + // works without prior VSS setup, so this throw means "probe unavailable, + // fall back to the restore verdict (which was 'missing')." Same outcome as + // a real "missing" answer, so we merge them here. + // // Tracked upstream: https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/20 if (!e) return false; const err = e as { name?: string; message?: string; statusCode?: number; status?: number; code?: string }; @@ -851,6 +867,7 @@ function isVssBackupMissing(e: unknown): boolean { if (err.statusCode === 404 || err.status === 404) return true; const text = typeof err.message === 'string' ? err.message : String(e); if (/VssBackupNotFound|backup\s*not\s*found|backup\s+(does\s+not\s+exist|missing)/i.test(text)) return true; + if (/VSS\s+backup\s+not\s+configured|VSS\s+not\s+initialized/i.test(text)) return true; if (/bincode error while reading entry|failed to fill whole buffer/i.test(text)) return true; return false; } diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index e34c0bb7d..2d0df8d89 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -821,6 +821,46 @@ describe('RgbWallet', () => { expect(freshWallet.dispose).not.toHaveBeenCalled(); }); + it('treats a "missing" probe throw as backupExists:false and fresh-creates when never initialized', async () => { + // Web SDK throws (rather than returns) on a 404 from getObject. If + // that throw matches isVssBackupMissing, the probe should treat it as + // a confirmed "no backup" answer — not as "server unreachable". + const probeWallet = { + vssBackupInfo: vi.fn().mockRejectedValue(Object.assign(new Error('VSS backup not found'), { statusCode: 404 })), + dispose: vi.fn().mockResolvedValue(undefined), + } as unknown as IRgbWallet; + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn().mockResolvedValue(probeWallet), + restoreFromVss: vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { name: 'NotFoundError' })), + }; + (globalThis as any).rgbAdapter = adapter; + const storage = makeMemoryStorage(); + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init(storage); + expect(storage._data[getRgbInitializedStorageKey(NETWORK_RGB_TESTNET)]).toBe('true'); + // Candidate kept (not disposed) — caller will use it. + expect(probeWallet.dispose).not.toHaveBeenCalled(); + }); + + it('treats a "missing" probe throw as backupExists:false and throws RgbBackupLostError when device flag is set', async () => { + const probeWallet = { + vssBackupInfo: vi.fn().mockRejectedValue(Object.assign(new Error('Requested key not found.'), { statusCode: 404 })), + dispose: vi.fn().mockResolvedValue(undefined), + } as unknown as IRgbWallet; + const adapter: IRgbAdapter = { + capabilities: { lightning: false }, + createWallet: vi.fn().mockResolvedValue(probeWallet), + restoreFromVss: vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { name: 'NotFoundError' })), + }; + (globalThis as any).rgbAdapter = adapter; + const storage = makeMemoryStorage({ [getRgbInitializedStorageKey(NETWORK_RGB_TESTNET)]: 'true' }); + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await expect(w.init(storage)).rejects.toBeInstanceOf(RgbBackupLostError); + }); + it('does not invoke the probe at all when restoreFromVss succeeds', async () => { // Happy path — backup exists, restore succeeds, no probe needed. The // RGB_INITIALIZED flag is still set on the way out so a future From df21a1d6d349b8c91d2f60abd55001a63faebd1c Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Sun, 3 May 2026 23:07:57 +0100 Subject: [PATCH 23/30] fix: backup --- shared/class/wallets/rgb-wallet.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 550e5c081..878d2ef7d 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -207,12 +207,14 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa info = await candidate.vssBackupInfo(); } catch (probeErr) { // The web SDK throws on a 404 from `getObject` rather than returning - // `{ backupExists: false }`. If the throw matches our "missing" detector - // (same shape as the original restore error), treat it as a confirmed - // "no backup" answer — same outcome as the success branch with - // `info.backupExists === false`. Anything else is treated as unreachable - // — the alternative is silently overwriting a real backup with empty - // state, which is unrecoverable. + // `{ backupExists: false }`, and additionally throws "VSS backup not + // configured" when called on a candidate that hasn't yet been restored + // (no pre-restore probe API exists on web). If the throw matches our + // "missing" detector, treat it as a confirmed "no backup" answer — same + // outcome as the success branch with `info.backupExists === false`. + // Anything else is treated as unreachable — the alternative is silently + // overwriting a real backup with empty state, which is unrecoverable. + // Tracked upstream: https://github.com/UTEXO-Protocol/rgb-sdk-web/issues/6 if (isVssBackupMissing(probeErr)) { info = { backupExists: false } as Awaited>; } else { @@ -858,8 +860,9 @@ function isVssBackupMissing(e: unknown): boolean { // works without prior VSS setup, so this throw means "probe unavailable, // fall back to the restore verdict (which was 'missing')." Same outcome as // a real "missing" answer, so we merge them here. + // Tracked upstream: https://github.com/UTEXO-Protocol/rgb-sdk-web/issues/6 // - // Tracked upstream: https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/20 + // Tracked upstream (RN): https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/20 if (!e) return false; const err = e as { name?: string; message?: string; statusCode?: number; status?: number; code?: string }; if (err.code === 'VssBackupNotFound') return true; From 5c2d9b6fbfc0262a3ee4081116323f48044bfb25 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Sun, 3 May 2026 23:55:25 +0100 Subject: [PATCH 24/30] fix: ext banner --- ext/src/pages/Popup/Home.tsx | 3 + .../pages/Popup/OnboardingCreatePassword.tsx | 15 ++- .../pages/Popup/OnboardingImportWallet.tsx | 3 + .../Popup/OnboardingVerifyingRgbBackup.tsx | 124 ++++++++++++++++++ ext/src/pages/Popup/Popup.tsx | 3 + .../Popup/components/RgbBackupBanner.tsx | 84 ++++++++++++ 6 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx create mode 100644 ext/src/pages/Popup/components/RgbBackupBanner.tsx diff --git a/ext/src/pages/Popup/Home.tsx b/ext/src/pages/Popup/Home.tsx index 90a1b3419..b85ab254e 100644 --- a/ext/src/pages/Popup/Home.tsx +++ b/ext/src/pages/Popup/Home.tsx @@ -32,6 +32,7 @@ import { BackgroundCaller } from '../../modules/background-caller'; import Balance from './components/Balance'; import NftsView from './components/NftsView'; import PartnersView from './components/PartnersView'; +import RgbBackupBanner from './components/RgbBackupBanner'; import SwapInterfaceView from './components/SwapInterfaceView'; import SwapListView from './components/SwapListView'; import TokensView from './components/TokensView'; @@ -251,6 +252,8 @@ const Home: React.FC = () => { + + {showSwapInterface ? ( ) : ( diff --git a/ext/src/pages/Popup/OnboardingCreatePassword.tsx b/ext/src/pages/Popup/OnboardingCreatePassword.tsx index 76d80dd63..0b5b0f0d6 100644 --- a/ext/src/pages/Popup/OnboardingCreatePassword.tsx +++ b/ext/src/pages/Popup/OnboardingCreatePassword.tsx @@ -25,8 +25,19 @@ export default function OnboardingCreatePassword() { try { await BackgroundCaller.encryptMnemonic(pass1); - setStep(EStep.TOS); - navigate('/onboarding-tos'); + // On the import path we run a one-time VSS reachability probe before + // letting the user reach the wallet. Create-wallet skips it (no + // backup to restore from). Flag is set in OnboardingImportWallet. + // The gate route is registered in BOTH EStep.PASSWORD and EStep.TOS + // Routes blocks so this navigate matches without first transitioning + // EStep — the gate itself bumps EStep to TOS when it proceeds. + const justImported = sessionStorage.getItem('rgb.justImported') === '1'; + if (justImported) { + navigate('/onboarding-verifying-rgb-backup'); + } else { + setStep(EStep.TOS); + navigate('/onboarding-tos'); + } } catch (error) { setValidationError('An error occurred'); setIsLoading(false); diff --git a/ext/src/pages/Popup/OnboardingImportWallet.tsx b/ext/src/pages/Popup/OnboardingImportWallet.tsx index f0c034989..b3dbba1af 100644 --- a/ext/src/pages/Popup/OnboardingImportWallet.tsx +++ b/ext/src/pages/Popup/OnboardingImportWallet.tsx @@ -35,6 +35,9 @@ export default function OnboardingImport() { return; } else { await BackgroundCaller.setMasterSeed(sanitizedSeed); + // Marks this onboarding flow as restore-from-seed so the + // post-password step routes through the VSS reachability gate. + sessionStorage.setItem('rgb.justImported', '1'); setStep(EStep.PASSWORD); } }; diff --git a/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx b/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx new file mode 100644 index 000000000..8510a3024 --- /dev/null +++ b/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx @@ -0,0 +1,124 @@ +import { CloudOff, Loader2, AlertTriangle } from 'lucide-react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { RgbBackupServerUnreachableError } from '@shared/class/wallets/rgb-wallet'; +import { EStep, InitializationContext } from '@shared/hooks/InitializationContext'; +import { NETWORK_RGB_TESTNET } from '@shared/types/networks'; + +import { BackgroundCaller } from '../../modules/background-caller'; +import { ThemedText } from '../../components/ThemedText'; +import { Button, WideButton } from './DesignSystem'; + +const TARGET_NEXT = '/onboarding-tos'; +const PENDING_FLAG = 'rgb.justImported'; + +type Status = 'probing' | 'failed'; + +/** + * Restore-from-seed gate. Sits between password and TOS on the import path. + * + * The new init() flow refuses to silently create a fresh RGB wallet when VSS + * is unreachable (would overwrite the real backup with empty state). We + * surface that *during onboarding* — not on first RGB tap — so a user + * restoring on a flaky network gets a clear "VSS server unreachable, retry?" + * before they're dropped into the wallet UI. + * + * On Skip the user proceeds to TOS; subsequent RGB inits still fail with + * the same typed error, so the safety net is never bypassed. + * + * See tasks/rgb-backup-failure-handling.md. + */ +const OnboardingVerifyingRgbBackup: React.FC = () => { + const navigate = useNavigate(); + const { setStep } = useContext(InitializationContext); + const [status, setStatus] = useState('probing'); + const [errorMessage, setErrorMessage] = useState(null); + const [errorKind, setErrorKind] = useState<'unreachable' | 'other'>('unreachable'); + + const proceed = useCallback(() => { + sessionStorage.removeItem(PENDING_FLAG); + // Same race avoidance as the password screen: navigate first, then bump + // the EStep so the new Routes block is mounted with the URL already at + // /onboarding-tos. + navigate(TARGET_NEXT, { replace: true }); + setStep(EStep.TOS); + }, [navigate, setStep]); + + const probe = useCallback(async () => { + setStatus('probing'); + setErrorMessage(null); + try { + // Testnet is intentional — VSS server URL is shared with mainnet so + // reachability is correlated, and a successful init pre-warms the + // cache for the first RGB tap. + await BackgroundCaller.lazyInitWallet(NETWORK_RGB_TESTNET, 0); + proceed(); + } catch (e: any) { + if (e instanceof RgbBackupServerUnreachableError) { + setErrorKind('unreachable'); + setErrorMessage('Backup server is unreachable. We can’t verify your RGB backup right now.'); + } else { + setErrorKind('other'); + setErrorMessage(typeof e?.message === 'string' ? e.message : 'Could not verify your RGB backup.'); + } + setStatus('failed'); + } + }, [proceed]); + + useEffect(() => { + probe(); + }, [probe]); + + const accent = errorKind === 'unreachable' ? 'rgba(255, 255, 255, 0.9)' : '#ffb86b'; + + return ( +
+
+ {status === 'probing' ? ( + + ) : errorKind === 'unreachable' ? ( + + ) : ( + + )} +
+ + {status === 'probing' ? 'Verifying RGB backup…' : 'Backup not verified'} + +

+ {status === 'probing' + ? 'Checking that your RGB backup is reachable before we restore your wallet. This is a one-time step.' + : (errorMessage ?? 'Unknown error') + ' You can skip RGB for now and try again later from the home banner.'} +

+ + {status === 'failed' ? ( +
+ + Retry + +
+ +
+
+ ) : null} + + +
+ ); +}; + +export default OnboardingVerifyingRgbBackup; diff --git a/ext/src/pages/Popup/Popup.tsx b/ext/src/pages/Popup/Popup.tsx index 8a852e2ef..3ab8bec84 100644 --- a/ext/src/pages/Popup/Popup.tsx +++ b/ext/src/pages/Popup/Popup.tsx @@ -27,6 +27,7 @@ import OnboardingCreateWallet from './OnboardingCreateWallet'; import OnboardingImportWallet from './OnboardingImportWallet'; import OnboardingIntro from './OnboardingIntro'; import OnboardingTos from './OnboardingTos'; +import OnboardingVerifyingRgbBackup from './OnboardingVerifyingRgbBackup'; import './Popup.css'; import Receive from './Receive'; import ReceiveLightning from './ReceiveLightning'; @@ -73,6 +74,7 @@ const AppContent: React.FC = () => { } /> } /> + } /> } /> ); @@ -92,6 +94,7 @@ const AppContent: React.FC = () => { } /> } /> + } /> } /> ); diff --git a/ext/src/pages/Popup/components/RgbBackupBanner.tsx b/ext/src/pages/Popup/components/RgbBackupBanner.tsx new file mode 100644 index 000000000..81f66d745 --- /dev/null +++ b/ext/src/pages/Popup/components/RgbBackupBanner.tsx @@ -0,0 +1,84 @@ +import { AlertTriangle, CloudUpload, Loader2 } from 'lucide-react'; +import React, { useContext, useState } from 'react'; + +import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { useRgbBackupStatus } from '@shared/hooks/useRgbBackupStatus'; + +import { BackgroundCaller } from '../../../modules/background-caller'; + +// Persistent banner for the RGB backup ledger — see +// tasks/rgb-backup-failure-handling.md. +const RgbBackupBanner: React.FC = () => { + const { network } = useContext(NetworkContext); + const { accountNumber } = useContext(AccountNumberContext); + const { status, pendingCount, lastError, retry } = useRgbBackupStatus(network, accountNumber, BackgroundCaller); + const [isRetrying, setIsRetrying] = useState(false); + const [retryError, setRetryError] = useState(null); + + if (status === 'synced') return null; + + const isFailed = status === 'failed'; + const title = isFailed ? 'Backup failed' : 'Backup pending'; + const detail = isFailed + ? `${pendingCount} change${pendingCount === 1 ? '' : 's'} not yet saved to backup. Tap to retry — until then, recovery on a new device may be missing recent activity.` + : `${pendingCount} change${pendingCount === 1 ? '' : 's'} are syncing to backup. Usually clears within seconds.`; + + const handlePress = async () => { + if (isRetrying) return; + setIsRetrying(true); + setRetryError(null); + try { + const ok = await retry(); + if (!ok) setRetryError(lastError?.message ?? 'Unknown error. Try again in a moment, or check your network.'); + } catch (e: any) { + setRetryError(e?.message ?? 'Unknown error.'); + } finally { + setIsRetrying(false); + } + }; + + const accent = isFailed ? '#ffb86b' : 'rgba(255, 255, 255, 0.9)'; + + return ( +
+
+
+ {isRetrying ? : isFailed ? : } +
+
{title}
+
+
{detail}
+ {retryError ?
{retryError}
: null} + +
+ ); +}; + +export default RgbBackupBanner; From a0eb8686d537c2e18d6d93e22a126f218b2dcf36 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Mon, 4 May 2026 11:05:08 +0100 Subject: [PATCH 25/30] fix: tasks --- tasks/rgb-verification-notes.md | 336 ++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 tasks/rgb-verification-notes.md diff --git a/tasks/rgb-verification-notes.md b/tasks/rgb-verification-notes.md new file mode 100644 index 000000000..10ad0432e --- /dev/null +++ b/tasks/rgb-verification-notes.md @@ -0,0 +1,336 @@ +# RGB end-to-end verification — running notes + +Live notes from the verification run; updated as tests progress. Issues +that block a specific test get skipped (per user direction) and recorded +here so we can revisit. + +## Environment snapshot + +- Test seed (mobile): `setup fashion rice grant earn rabbit rude claw knife robust knife actor` +- Faucet: `bash ~/z/rgb-faucet.sh
[amount_sats]` (default 16900) +- ext popup URL: `chrome-extension://jfkjdddajnobopldmhfpgblcidgohkak/popup.html` +- iOS sim: `38489619-1224-48CD-A378-10F23D30B1F9` (iPhone 17, iOS 26.3) +- Android emu: `emulator-5554` (Galaxy S24 Ultra, API 34) + +## Skipped / blocked tests (revisit later) + +### B-EXT-1 — chrome-devtools MCP server died after `chrome.runtime.reload()` +- After clearing `chrome.storage.local` + IndexedDB + reloading runtime in + the popup, the chrome-devtools MCP server disconnected and all + `mcp__chrome-devtools__*` tools became unavailable. +- No chrome process was running on the host; MCP wasn't able to respawn. +- Fix on next run: avoid `chrome.runtime.reload()` from inside the + evaluate_script call; close the popup tab via MCP first, then reload via + a new page request. Or kill any leftover chrome-for-testing process and + let MCP spawn fresh. +- All ext-side tests (A1–A5, C1–C4, D1–D3, E*, F1) are skipped this run + until MCP is restored. + +### A-IOS-1 — iOS launched with a fresh wallet (lost test seed) +- The iOS sim was on a "Wallet created successfully" TOS celebration when + the run started — meaning storage was cleared between sessions. No DEMO + token, no funds. +- Workaround: wipe + import the test mnemonic on iOS (in progress). + +## Test pass/fail log + +### Phase A — Per-platform smoke + +- **A7 — Android RGB Testnet home renders, banner hidden when synced** ✅ + Already on `selectedNetwork-rgb_testnet`, balance 0.0000446 tBTC, + Receive/Send/Issue/UTXOs buttons present, no `RgbBackupBanner` element + in tree (synced state). Recent Sent/Received tx history shows transfers + from prior testing. + +- **A6-android — Receive RGB Asset flow** ✅ + Receive button → bottom sheet ("Receive sats" / "Receive RGB asset") → + Receive RGB Asset screen renders (Asset selector "Any asset", base + units amount input, Generate Invoice). Generate Invoice → RGB Invoice + screen with QR + invoice string + Private (blind) badge + 33m expiry. + Sample invoice (any-asset, 1 base unit) generated: + ``` + rgb:~/~/ae/sb:utxob:fbZFwePS-oOFBS4I-nSyznC4-B_u1T0Z-kSksZEW-cGPB81A-MoqJo?assignment_name=assetOwner&expiry=1777853205&endpoints=rpcs://rgb-proxy-utexo.utexo.com/json-rpc + ``` + Note: this Android wallet has **no RGB tokens** (only 0.0000446 tBTC). + No Tokens section visible on Home — confirmed no DEMO etc. + +- **A5-android — Send screen mounts on RGB Testnet** ✅ + Send button → Send tBTC address screen renders. Header is "Send tBTC" + (per-network ticker, not per-prefix as on ext). Pasting an `rgb:` + invoice and pressing Next routed to the account-based amount entry + screen (`send-amount-acc`) — `validateAddress` accepted the invoice, + the decode lookup didn't auto-fill amount because invoice was issued + with "Any asset" (no `assetId`), so it fell through to amount entry + per `send-address.tsx:75-91`. Header staying "Send tBTC" on RGB + Testnet is intentional on mobile — different from ext, where the + header flips by prefix. Not a bug. + +- **B-android-issue — RGB Issue Asset path works end-to-end** ✅ + Issue button → Issue RGB Asset screen renders (Ticker, Name, + Precision=8, Amount=1000). Filled TEST / Test Token / 8 / 1000 → + "Issue Asset" → Asset Issued screen with asset id + `rgb:WmHHJS~4-Z0PHv8s-0Prxx1N-Cg9tS_Y-JEQvsJb-S0vqqZ4`. Done → + Home now shows Tokens section: "Test Token 0.00001 TEST" (1000 + base units / 10^8 = 0.00001). + Side effect: bootstraps an RGB token on Android for cross-platform + Phase C tests when ext/iOS are restored. + +### Phase E — Backup banner state machine (passive observation) + +- **E1-android — RGB Issue triggers VSS backup, banner does not appear** ✅ + After Issue Asset succeeded, Home rendered with no `RgbBackupBanner` + visible. `useRgbBackupStatus` only mounts the banner when state is + `pending` or `failed`; the issue→backup window was below render + granularity. adb logcat showed no RGB/VSS errors. Implies VSS backup + flushed cleanly before the next state read. E2/E3 (forced failure) + not exercised — would need to interfere with VSS reachability mid-op. + +### iOS state at end of run + +- iOS sim launched with a fresh wallet (lost test seed). On the Bitcoin + network. Show Testnets is OFF in Tools, so RGB Testnet doesn't appear + in the BackdoorNetworkSwitcher list yet (only `rgb` mainnet shows). +- Toggling Show Testnets ON via UI taps was unreliable in this session + (taps at the listed (49, 113) coords didn't register a state change in + the visible button color). Easier next-time path: deep-link to + `layerzwallet://Tools` and tap the precise listed `SettingOption-showTestnets-ON` + coordinates, OR write directly to AsyncStorage (settings key + `showTestnets` = `'ON'`). Deep-linking via `xcrun simctl openurl + layerzwallet://BackdoorNetworkSwitcher` works to navigate. +- iOS Settings → Recovery Phrase tapped through to a glitched dark screen + (only the gear icon visible) — couldn't read the seed. Possibly an + animation race with the dev client. + +### Phase A — what's still pending + +- **A1, A2, A3, A4-ext, A5-ext** — all blocked on chrome-devtools-mcp + recovery. ext popup needs the MCP server back to drive the onboarding + flow + RGB screens. +- **A6-ios, A8-ios** — pending iOS reaching RGB Testnet (need showTestnets + toggle to take + test seed re-imported, OR fresh-fund and skip DEMO). + +## Continuing next run + +Pick-up checklist: +1. Restart `chrome-devtools-mcp` (parent process / claude code restart). +2. On iOS: deep-link to Tools, toggle showTestnets ON, then deep-link + to BackdoorNetworkSwitcher → select rgb_testnet. Fund via faucet. +3. Resume ext by: load popup → wipe storage via evaluate_script + (DON'T call `chrome.runtime.reload()` from inside that script — close + the popup tab from MCP first), then onboard fresh and run A1/A2. +4. Cross-platform RGB transfer using `TEST` asset on Android as the + source: generate Receive invoice on ext or iOS for the TEST asset id, + send from Android via Send screen. + +## Resumed run (chrome-devtools restored) + +### Phase A on ext + +- **A1-ext — Import path triggers VSS gate** ✅ + Onboarding-intro → Import wallet → paste test seed + (`setup fashion rice grant earn rabbit rude claw knife robust knife actor`) + → Set password (`qweqweqwe`) → URL transitioned to + `/onboarding-verifying-rgb-backup`. Gate showed "Backup not verified" + with "Could not verify your RGB backup" + Retry/Skip. Console error: + `Failed to initialize wallet for rgb_testnet account 0: Invalid backup data` + — the same cross-platform VSS interop bug from `rgb-sdk-web/issues/6` + manifesting at the gate as designed (gate caught the throw, + surfaced it, offered Skip — exactly the intended UX). + +- **A2-ext — Create path skips gate** ✅ + Wiped `chrome.storage.local` + IndexedDB + sessionStorage via + `evaluate_script` (no `chrome.runtime.reload()` this time → MCP + stayed up). Reloaded popup → Onboarding-intro → Create wallet + (fresh seed: `what mesh mention price strategy capable multiply + still defense believe name gallery`) → Set password → URL went + straight to `/onboarding-tos`, **bypassing** the gate. `rgb.justImported` + flag was never set, so OnboardingCreatePassword's branch correctly + routed past the gate. + +- **A3-ext — RGB home renders, banner hidden when synced** ✅ + Switched to `Rgb` network on home (note: ext shows mainnet RGB in + the network bar, not testnet — the gate uses `NETWORK_RGB_TESTNET` + internally for the probe). 0 BTC balance, no `RgbBackupBanner` + element in tree. + +- **A4-ext — Receive RGB Asset screen** ✅ + Receive button → `/receive-rgb-token` mounts. Asset combobox + ("Any asset"), amount spinbutton, Generate Invoice + "Receive sats + instead". Invoice generation **fails** at runtime (see B-EXT-2 below). + +- **A5-ext — Send RGB screen + prefix-based header flip** ✅ + Navigated to `/send-rgb`. Header initially "Send tBTC". Pasting + `rgb:WmHHJS~4-...` (Android-issued asset id) → header flips to + "Send RGB asset". Continue button enables. Implementation correctly + watches the input prefix. + +### Confirmed bugs (skipped tests; need upstream fixes) + +### B-EXT-2 — rgb-lib-wasm panics on fresh-wallet first call (filed as issue #7) + +Upstream tracking: https://github.com/UTEXO-Protocol/rgb-sdk-web/issues/7 + +- After Create-wallet path (no prior VSS backup), navigating to + `/receive-rgb-token` on Rgb mainnet, filling amount=1, clicking + Generate Invoice produces: + ``` + [rgb-lib WASM panic] at lib.rs:391 + Uncaught RuntimeError: unreachable + [rgb-lib WASM panic] at lib.rs:529 + [rgb-lib WASM panic] at lib.rs:213 + failed to load asset list: RuntimeError: unreachable + ``` +- These are different from the cross-platform interop bug (which + throws "Invalid backup data" without a panic). This is a **fresh + wallet** path that the web SDK can't traverse. +- The panic happens at the asset-list-load step that's invoked + before invoice generation. The 52/52 unit tests for + `acquireFreshWalletAfterProbe` pass because they mock the SDK; the + real `@utexo/rgb-lib-wasm` panics on what should be a no-op fresh + state. +- **Suggested fix in our codebase**: catch the panic upstream of the + `requestReceive` UI handler and show a clearer error than "Failed + to generate invoice"; or upstream-fix `rgb-lib-wasm` to handle the + empty-asset-list path without unreachable. +- **Next**: file as second issue in `/tmp/kkk.txt` for the user. + +### B-EXT-3 — cross-platform VSS payload undecodable on web (existing) +- Already filed as `rgb-sdk-web#6`. Confirmed again on this run: the + test seed has a mobile-encrypted VSS backup; web SDK throws + `Invalid backup data` on `vssBackupInfo()`. The gate handles this + gracefully (Skip path bypasses). + +### What still works on ext after the bugs + +- Wallet creation & import flows ✅ +- Password / TOS / unlock flows ✅ +- Onboarding gate state machine (Import → gate, Create → bypass) ✅ +- RGB Receive/Send screen mounts and prefix detection ✅ + +### What can't be verified from ext until WASM panic is fixed + +- Phase C cross-platform RGB transfer (ext leg). Both invoice + generation and asset list load fail. +- Phase D (tBTC send via SendRgb). Address validation + Continue + works but the broadcast path likely also hits the WASM panic. +- Phase E live banner mutation (need a successful issue/transfer + to capture the pending→synced transition on ext). +- Phase F1 (web-side restore via Import) blocked by both bugs. + +### Status by task + +- #58 Phase A — DONE (A1✅ A2✅ A3✅ A4✅ A5✅ A6-android✅ A7✅, + A6-ios/A8 deferred; ext A4/A5 mount-only). +- #59 Phase B funding — DONE on Android (already had 0.0000446 tBTC, + TEST token issued). +- #60 Phase C cross-platform — BLOCKED (ext fresh-wallet WASM panic + + iOS state). Skipped this run. +- #61 Phase D tBTC — BLOCKED on ext (same panic). Could still run + iOS↔Android tBTC if iOS is recovered. +- #62 Phase E banner — Android backup-after-issue passed without + banner (synced before render). E2 forced-failure not exercised. +- #63 Phase F VSS interop — CONFIRMED BROKEN as expected for + mobile→web (issue #6). Web-side fresh restore blocked by panic. + +## Files modified for new issue draft + +(See `/tmp/kkk.txt` follow-up — to draft after this verification run.) + +## Resumed run 2 — iOS via AsyncStorage patch + cross-platform RGB + +Workaround for the iOS UI tap reliability problem: terminated the +iOS app, edited `RCTAsyncLocalStorage_V1/manifest.json` directly to +set `STORAGE_KEY_SETTINGS` `showTestnets:"ON"` and +`STORAGE_SELECTED_NETWORK:"rgb_testnet"`, then relaunched. iOS came +up on Rgb_testnet with the existing seed's prior funds: +`0.00008135 tBTC`, with tBTC tx history present. + +### Phase A on iOS + +- **A6-ios — Receive RGB Asset screen renders + invoice generation works** ✅ + Receive button → "What to receive" sheet → "Receive RGB asset" → + Receive RGB Asset screen (Asset "Any asset", Amount field, Generate + Invoice). Filled amount=1, Generate → RGB Invoice screen with QR + + Private (blind) badge + 33m expiry. Invoice copied via "Tap to copy": + ``` + rgb:~/~/ae/sb:utxob:49dDNX35-_TNeftE-QvA15SZ-j_2Ptr~-PUPlUSw-zrDt~4A-OUlK3?assignment_name=assetOwner&expiry=1777889377&endpoints=rpcs://rgb-proxy-utexo.utexo.com/json-rpc + ``` + iOS tBTC receive address (`tb1pal0nx4wlj8690qa4rh4pp2qd83dj2ys9nl276a0gynw26s6vnm24qj5nvec`) + observed via Receive sats path. + +### Phase C — Android → iOS RGB transfer (broadcast leg) ✅ + +- Android: home → Send → pasted iOS invoice → tapped Test Token row + to select asset → Next. +- Send TEST screen rendered (header flipped from "Send tBTC" to + "Send TEST"), balance shown 0.00001 TEST. Tapped max → 0.00001 + filled → Next. +- Confirm screen: Total 0.00001 TEST, Network Fee 0 tBTC, Send to: + the iOS rgb invoice. Tap Confirm Send. +- **"Sent successfully!" card** — full broadcast succeeded. +- Android home post-send: Test Token balance dropped from 0.00001 TEST + (1000 base units) to **0.00000999 TEST** (999 base units), confirming + exactly 1 base unit was sent (the invoice's specified amount). +- **Sender side fully validated** end-to-end: address → token select → + amount → confirm → broadcast → balance decrement. + +### Phase C — iOS receive leg ✅ + +- Re-opened Receive RGB Asset on iOS to re-engage polling. **Asset + selector now shows "Any asset" + "TEST"** — wallet auto-discovered + the new asset. +- Backed to Home → Tokens section shows **Test Token: 0.00000001 TEST** + (exactly the 1 base unit Android broadcast). +- **Phase C-1 (Android → iOS) end-to-end SUCCESS**: invoice gen on iOS, + broadcast on Android, receive sync on iOS, balances reconcile across + both platforms. +- The "success card" on iOS Receive was missed because we navigated + away during sync; not a bug, just a UX detail that the card only + appears if the user stays on Receive while the asset arrives. + +### Status update + +- **#60 Phase C** Android↔iOS RGB transfer (1 of 5 transitions): ✅ + Other 4 transitions (ext legs blocked by lib bug #7; iOS↔Android + reverse direction blocked by bug B-IOS-1 below). +- **#62 Phase E** banner: extended observation — issue+broadcast on + Android still produced no visible banner; backups must be flushing + cleanly within the render-cycle. E2 forced-failure not exercised. + +### Phase D — tBTC vanilla send ✅ + +- **D2 — iOS → Android tBTC** ✅ + Android Receive sats → captured tBTC address + `tb1pmvr9la9geer8v9gm9dv7k3ld4qysckzx5n5c9cxyk3hrutwguqkqzhafxn`. + iOS Send → pasted address → Next → amount 0.00001 → Next → Confirm + Send → "Sent successfully!" card. Android home polled within + ~5 seconds: balance went from `0.0000446 tBTC` → + **`0.0000546 tBTC`** (+0.00001, exact match). + Validates the mobile bitcoin send path on RGB-testnet (which uses + the underlying signet-mapped Bitcoin chain) end-to-end. + +### B-IOS-1 — iOS Send rejects Android-generated invoice with assetId + +- Tried iOS → Android reverse RGB transfer. +- Generated invoice on Android for **TEST asset specifically** + (asset id embedded in URL path before `/ae/sb`): + ``` + rgb:WmHHJS~4-Z0PHv8s-0Prxx1N-Cg9tS_Y-JEQvsJb-S0vqqZ4/RWhwUfTMpuP2Zfx1~j4nswCANGeJrYOqDcKelaMV4zU/ae/sb:utxob:_0ojK8vN-MtLVgVa-6V9XsnV-bchvbdm-nY1tNOr-tT9lNFQ-T4uIq?assignment_name=assetOwner&expiry=1777889893&endpoints=rpcs://rgb-proxy-utexo.utexo.com/json-rpc + ``` +- iOS Send: pasted invoice → Send tBTC screen showed Tokens row with + "Test Token 0.00000001 TEST" → tapped Next. +- Confirm screen rendered correctly: Total `0.00000001 TEST`, + Network Fee `0 tBTC`, full Send-to invoice. Auto-decoded the asset + and amount from the invoice URL **for display**. +- Confirm Send → **Error**: `asset_id is required for send operation`, + toast `Failed to broadcast transaction: ValidationE...`. +- Suggests the mobile send-confirm path decodes the invoice's asset + segment for the UI but doesn't pass it through to the rgb-lib + `pay`/`transferToken` call. Likely fix lives in + `mobile/app/send/send-confirm.tsx` — when invoice contains + `assetId`, propagate it into the `transferToken` args instead of + letting the lib infer from the empty `asset_id` field. +- Skipped: rather than fix in this verification run, recording for + follow-up. **One direction (Android→iOS) of cross-platform RGB + is the validated success.** + From 48dee4feed29b5f5ae9e69e0e5e311e7a96d23bd Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Mon, 4 May 2026 11:59:11 +0100 Subject: [PATCH 26/30] fix: asset id bug --- shared/class/wallets/rgb-wallet.ts | 17 ++++++++++++----- shared/tests/unit-vi/rgb-wallet.test.ts | 20 +++++++++++++------- tasks/rgb-verification-notes.md | 24 +++++++++++++++++++++++- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 878d2ef7d..8e8bc2948 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -445,11 +445,19 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa * satisfy InterfaceCanHaveTokens but is ignored — the RGB send API has no memo * field. * - * RGB invoices can carry an embedded amount (and asset id). When they do, the + * `amount`: RGB invoices can carry an embedded amount. When they do, the * SDK's `sendBegin` returns an empty PSBT if we *also* pass `amount` — * downstream `signPsbt` then rejects with `psbtBase64 must be a non-empty - * string`. So we decode the invoice first and only forward the explicit - * `amount` / `assetId` for invoices that don't have them baked in. + * string`. So we decode the invoice first and only forward `amount` for + * invoices that don't have one baked in. + * + * `assetId`: always forwarded. The web SDK extracts it from the invoice + * data when omitted, but the RN binding's `sendBegin` throws + * `ValidationError: asset_id is required for send operation` if the param + * is missing — even when the invoice clearly has it. Forwarding the id + * unconditionally is a no-op on web (the invoice's id wins) and required + * on mobile. Tracking upstream: + * https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/25 */ async transferToken(tokenId: string, amount: bigint, invoice: string, _memo?: string): Promise { // The SDK's send API takes `amount: number`. High-precision assets (e.g. @@ -458,12 +466,11 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa assert(amount <= BigInt(Number.MAX_SAFE_INTEGER), `RGB send amount ${amount} exceeds Number.MAX_SAFE_INTEGER (2^53). ` + 'Reduce the amount or wait for SDK bigint support.'); const decoded = await this.sdk().decodeRGBInvoice({ invoice }); const invoiceHasAmount = typeof decoded.assignment?.amount === 'number'; - const invoiceHasAsset = typeof decoded.assetId === 'string' && decoded.assetId.length > 0; const params: Parameters[0] = { invoice, + assetId: tokenId, feeRate: await this.defaultFeeRate(), }; - if (!invoiceHasAsset) params.assetId = tokenId; if (!invoiceHasAmount) params.amount = Number(amount); const result = await this.sdk().send(params); // Critical: an asset transfer changed colorable UTXO bindings; a recovery diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index 2d0df8d89..7b6de28bd 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -262,10 +262,16 @@ describe('RgbWallet', () => { await expect(w.transferToken('nia-1', huge, 'rgb:abc')).rejects.toThrow(/MAX_SAFE_INTEGER/); }); - it('transferToken: omits amount/assetId when invoice has them baked in', async () => { - // Repro of the empty-PSBT bug: when the invoice carries assignment.amount - // and we *also* pass `amount` to sendBegin, rgb-lib returns an empty PSBT - // and signPsbt rejects with `psbtBase64 must be a non-empty string`. + it('transferToken: omits amount but always forwards assetId when invoice has them baked in', async () => { + // Two coupled SDK quirks the params here are guarding against: + // • amount: when the invoice carries assignment.amount AND we also + // pass `amount` to sendBegin, rgb-lib returns an empty PSBT and + // signPsbt rejects with `psbtBase64 must be a non-empty string`. + // • assetId: the RN binding throws "asset_id is required for send + // operation" if assetId isn't passed, even when the invoice has it. + // Web SDK is permissive (extracts from invoice when omitted), but + // forwarding unconditionally is required for mobile and a no-op on + // web. const { sdkWallet } = installAdapter({ decodeRGBInvoice: vi.fn().mockResolvedValue({ invoice: 'rgb:full-invoice', @@ -283,11 +289,11 @@ describe('RgbWallet', () => { await w.init({} as any); const txid = await w.transferToken('rgb:embedded-asset', 42n, 'rgb:full-invoice'); expect(txid).toBe('tx-omit'); - expect(sdkWallet.send).toHaveBeenCalledWith({ invoice: 'rgb:full-invoice', feeRate: 1 }); - // No `amount` and no `assetId` keys leaked through. + expect(sdkWallet.send).toHaveBeenCalledWith({ invoice: 'rgb:full-invoice', feeRate: 1, assetId: 'rgb:embedded-asset' }); + // `amount` still skipped (the empty-PSBT guard); `assetId` present. const call = (sdkWallet.send as any).mock.calls[0][0]; expect('amount' in call).toBe(false); - expect('assetId' in call).toBe(false); + expect(call.assetId).toBe('rgb:embedded-asset'); }); it('transferToken: passes amount/assetId for an "any-amount" / "any-asset" invoice', async () => { diff --git a/tasks/rgb-verification-notes.md b/tasks/rgb-verification-notes.md index 10ad0432e..b445625ec 100644 --- a/tasks/rgb-verification-notes.md +++ b/tasks/rgb-verification-notes.md @@ -309,7 +309,29 @@ up on Rgb_testnet with the existing seed's prior funds: Validates the mobile bitcoin send path on RGB-testnet (which uses the underlying signet-mapped Bitcoin chain) end-to-end. -### B-IOS-1 — iOS Send rejects Android-generated invoice with assetId +### B-IOS-1 — FIXED + filed as rgb-sdk-rn#25 + +Upstream tracking: https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/25 + +**Root cause**: `@utexo/rgb-sdk-rn`'s `sendBegin` requires `params.assetId` +unconditionally, while the web SDK extracts it from the invoice when +omitted. Our `transferToken` previously skipped passing `assetId` when +the invoice had it embedded — fine for web, broken for mobile. + +**Fix in our code**: `shared/class/wallets/rgb-wallet.ts` `transferToken` +now always forwards `assetId: tokenId`. No-op on web (the invoice's id +wins), required on RN. All 52/52 unit tests pass after updating the +"omits amount/assetId" test to assert `assetId` is always present. + +**Verified on iOS sim**: the originally reported `ValidationError: +asset_id is required for send operation` is gone. A new downstream +error surfaced (`psbtBase64 must be a non-empty string`) on the same +flow — likely caused by the iOS wallet's freshly-received TEST asset +not having a fully-settled spendable UTXO yet (only 1 base unit +received, no change room). This is a different code path than the +asset_id fix and not in scope for this fix. + +### B-IOS-1-original — iOS Send rejects Android-generated invoice with assetId - Tried iOS → Android reverse RGB transfer. - Generated invoice on Android for **TEST asset specifically** From 58a9577a2abb2ca0411b84bbd13e7d5e7325dd84 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Mon, 4 May 2026 12:56:37 +0100 Subject: [PATCH 27/30] fix: testnet --- shared/models/all-network-infos.ts | 1 + tasks/rgb-verification-notes.md | 55 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/shared/models/all-network-infos.ts b/shared/models/all-network-infos.ts index a8e3796ed..1d607c1b2 100644 --- a/shared/models/all-network-infos.ts +++ b/shared/models/all-network-infos.ts @@ -235,6 +235,7 @@ export const AllNetworkInfos: Record = { explorerUrl: 'https://layerz.mempool.space', rpcUrl: '', knowMoreUrl: 'https://rgb.tech', + isTestnet: true, isEVM: false, sortIndex: 25, }, diff --git a/tasks/rgb-verification-notes.md b/tasks/rgb-verification-notes.md index b445625ec..6fbbaaeac 100644 --- a/tasks/rgb-verification-notes.md +++ b/tasks/rgb-verification-notes.md @@ -356,3 +356,58 @@ asset_id fix and not in scope for this fix. follow-up. **One direction (Android→iOS) of cross-platform RGB is the validated success.** +--- + +## 2026-05-04 — psbtBase64-empty bug (post-#25 fix) + +After fixing rgb-sdk-rn#25 (asset_id required), iOS → Android RGB +transfers now hit a different consistent failure: +`ValidationError: psbtBase64 must be a non-empty string` raised by +`sendEnd`'s param validation. + +### Repro +1. Android (with TEST balance) generates blind invoice with amount=50 + → invoice format `/gk/sb` with embedded amount. +2. iOS (TEST balance = 100 base units, 5+ free colorable slots, + plenty of vanilla sats) pastes the invoice. +3. send-address auto-decodes `decoded.assignment.amount = 50`, + bypasses send-amount, lands on send-confirm showing + "Total 0.0000005 TEST". +4. Confirm Send → JS error + `Failed to broadcast transaction: ValidationError: psbtBase64 + must be a non-empty string [field: psbtBase64]`. + +### Root cause analysis +- `RNRgbLibBinding.sendBegin()` calls `Rgb.sendBegin` and returns + `r.psbt`. When something's wrong at the rgb-lib layer, `r.psbt` + comes back as `""` (empty) rather than throwing. +- `BaseWalletManager.send()` then calls `signPsbt("")` then + `sendEnd({signedPsbt: ""})`. `sendEnd`'s validator throws + `psbtBase64 must be a non-empty string`. +- So the surfaced error is a *symptom*, not the cause. + +### Confirmed not to be the cause +- **Allocation pressure** — iOS UTXO Manager confirms 5 free + colorable slots, settled TEST allocation present, vanilla balance + ample. +- **Invoice format** — same `/gk/sb` invoice succeeds on receive + side; iOS auto-decodes amount correctly. +- **`amount` arg conflict** — already guarded in `transferToken`: + we only pass `amount` when invoice lacks one. (Comment at + shared/class/wallets/rgb-wallet.ts:448-452.) + +### Suspected upstream issues to file +1. **rgb-sdk-rn / rgb-lib**: `sendBegin` returning empty PSBT instead + of throwing a real error when it can't build one. Need a small repro. +2. **rgb-sdk-rn**: `failTransfers` reports "No pending transfers to + fail" while UTXO Manager clearly shows a stuck `pending` allocation + with display `{"type":"type","amount":null}`. After a failed send, + `listAssets` even drops the asset from the wallet's view until app + restart. + +### Status +- Fix for #25 (asset_id) committed and tested locally — 52/52 + unit tests pass. +- psbtBase64 issue blocked on upstream investigation. Working + workaround: Android → iOS direction validated, ext direction also + blocked by web SDK panic (rgb-sdk-web#7). From 6e1b5ebbe9c9af4633f2a36bffcfa56e3a9a210e Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Mon, 4 May 2026 14:10:09 +0100 Subject: [PATCH 28/30] fix: tx history --- shared/class/wallets/rgb-wallet.ts | 32 +++- shared/tests/unit-vi/rgb-wallet.test.ts | 187 ++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 5 deletions(-) diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 8e8bc2948..7993fd0a4 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -561,17 +561,26 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa const netSats = tx.received - tx.sent; const related = transfersByTxid.get(tx.txid) ?? []; const tokenTransfers = this.annotatedTransfersToCommon(related); - const direction: CommonTransaction['direction'] = netSats === 0 && tokenTransfers.length > 0 ? (related.some((r) => r.kind === 'Send') ? 'send' : 'receive') : netSats > 0 ? 'receive' : 'send'; + const hasTokens = tokenTransfers.length > 0; + const direction: CommonTransaction['direction'] = hasTokens ? (related.some((r) => r.kind === 'Send') ? 'send' : 'receive') : netSats > 0 ? 'receive' : 'send'; + // For token transactions the on-chain BTC delta is just dust + fee — + // not what the user moved. Leave `amount` undefined so the UI renders + // the token amount as the primary figure (Transaction.tsx and + // TransactionDetails.tsx both gate their "token-as-primary" branch on + // `!transaction.amount`). `fee` is preserved separately for the + // details sheet. + const sendCounterparty = direction === 'send' ? related.find((r) => r.kind === 'Send')?.recipientId : undefined; common.push({ network: this._network, txid: tx.txid, timestamp: tx.confirmationTime?.timestamp ?? Math.floor(Date.now() / 1000), direction, - amount: Math.abs(netSats), + amount: hasTokens ? undefined : Math.abs(netSats), fee: tx.fee, status: tx.confirmationTime ? 'confirmed' : 'pending', blockHeight: tx.confirmationTime?.height, - tokenTransfers: tokenTransfers.length > 0 ? tokenTransfers : undefined, + tokenTransfers: hasTokens ? tokenTransfers : undefined, + counterparty: sendCounterparty, explorerUrl: explorerBase ? `${explorerBase}/tx/${tx.txid}` : undefined, }); } @@ -585,13 +594,15 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa if (seenTransferIds.has(key)) continue; seenTransferIds.add(key); const tokenTransfers = this.annotatedTransfersToCommon([t]); + const direction: CommonTransaction['direction'] = t.kind === 'Send' ? 'send' : 'receive'; common.push({ network: this._network, txid: t.txid ?? key, timestamp: Math.floor((t.updatedAt || t.createdAt) / 1000), - direction: t.kind === 'Send' ? 'send' : 'receive', + direction, status: transferStatusToCommon(t.status), tokenTransfers: tokenTransfers.length > 0 ? tokenTransfers : undefined, + counterparty: direction === 'send' ? t.recipientId : undefined, explorerUrl: explorerBase && t.txid ? `${explorerBase}/tx/${t.txid}` : undefined, }); } @@ -620,7 +631,18 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa const seen = new Set(); for (const t of list) { const metadata = t.assetId ? this._tokens.find((m) => m.id === t.assetId) : undefined; - for (const a of t.assignments ?? []) { + // For `Send` transfers, `assignments[]` reflects the sender's *change* + // UTXO (the leftover that stayed in the wallet) — not the amount that + // was transferred. The actual transferred amount lives on + // `requestedAssignment` (set from the invoice). For Receive/Issuance, + // `assignments[]` is the inbound delta and is correct. + const sourceAssignments = t.kind === 'Send' && t.requestedAssignment ? [t.requestedAssignment] : (t.assignments ?? []); + for (const a of sourceAssignments) { + // The TS interface declares `Assignment.type: 'Fungible' | 'NonFungible' | …`, + // but the iOS Swift binding emits `{Fungible: 100}` (raw enum case) + // instead of `{type: "Fungible", amount: 100}` like Android does, so + // `a.type` is undefined on iOS and this filter drops everything. + // Tracking: https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/28 if (a.type !== 'Fungible' && a.type !== 'NonFungible') continue; const key = `${t.assetId ?? ''}|${a.amount ?? ''}|${t.recipientId ?? ''}|${t.kind}`; if (seen.has(key)) continue; diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index 7b6de28bd..912ab245d 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -524,6 +524,193 @@ describe('RgbWallet', () => { expect(txs).toHaveLength(1); expect(txs[0].tokenTransfers).toHaveLength(1); // duplicate collapsed }); + + it('Send transfer: tokenTransfers.amount comes from requestedAssignment, not assignments[]', async () => { + // Verified on Android: a Send transfer's `assignments[]` reflects the + // sender's *change* UTXO (sender had 999, sent 100, kept 899). The + // amount the user actually transferred lives on `requestedAssignment` + // (mirrored from the invoice). Without this, the tx history would + // display the change amount instead of the transferred amount. + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [{ assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 8, balance: { settled: 899, future: 899, spendable: 899 } }], + cfa: [], + ifa: [], + uda: [], + }); + (sdkWallet.listTransactions as any) = vi + .fn() + .mockResolvedValue([{ transactionType: 'RgbSend', txid: 'tx-send', received: 0, sent: 0, fee: 155, confirmationTime: { height: 1, timestamp: 1700000000 } }]); + (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([ + { + idx: 1, + batchTransferIdx: 1, + createdAt: 1700000000000, + updatedAt: 1700000000000, + status: 'Settled', + kind: 'Send', + requestedAssignment: { type: 'Fungible', amount: 100 }, + assignments: [{ type: 'Fungible', amount: 899 }], // change UTXO + transportEndpoints: [], + txid: 'tx-send', + recipientId: 'utxob:recipient-1', + }, + ]); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs).toHaveLength(1); + expect(txs[0].tokenTransfers?.[0].amount).toBe(100); // not 899 + }); + + it('Send transfer: counterparty wired from recipientId; amount is undefined when token transfers present', async () => { + // Two coupled invariants for the UI: TransactionDetails.tsx renders the + // To field from `counterparty`, and Transaction.tsx / + // TransactionDetails.tsx both gate "show token as primary amount" on + // `!transaction.amount`. So for a token-only RGB tx we want + // `counterparty` set and `amount` left undefined (the on-chain dust/ + // fee isn't what the user moved). + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [{ assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 8, balance: { settled: 0, future: 0, spendable: 0 } }], + cfa: [], + ifa: [], + uda: [], + }); + (sdkWallet.listTransactions as any) = vi + .fn() + .mockResolvedValue([{ transactionType: 'RgbSend', txid: 'tx-send', received: 0, sent: 200, fee: 155, confirmationTime: { height: 1, timestamp: 1700000000 } }]); + (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([ + { + idx: 1, + batchTransferIdx: 1, + createdAt: 1700000000000, + updatedAt: 1700000000000, + status: 'Settled', + kind: 'Send', + requestedAssignment: { type: 'Fungible', amount: 100 }, + assignments: [{ type: 'Fungible', amount: 899 }], + transportEndpoints: [], + txid: 'tx-send', + recipientId: 'utxob:bcB8PI8V-FfeJp6L', + }, + ]); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs).toHaveLength(1); + expect(txs[0].direction).toBe('send'); + expect(txs[0].counterparty).toBe('utxob:bcB8PI8V-FfeJp6L'); + expect(txs[0].amount).toBeUndefined(); + expect(txs[0].fee).toBe(155); + }); + + it('tBTC-only tx (no token transfers): amount stays as netSats, counterparty stays undefined', async () => { + // Regression guard: dropping the netSats amount should only happen + // when there's a token transfer attached. Vanilla tBTC sends from the + // wallet — which surface in `listTransactions` but have no + // corresponding `listTransfers` entry — must keep showing the BTC + // amount as the primary figure. + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ nia: [], cfa: [], ifa: [], uda: [] }); + (sdkWallet.listTransactions as any) = vi + .fn() + .mockResolvedValue([{ transactionType: 'User', txid: 'tx-btc', received: 0, sent: 5000, fee: 200, confirmationTime: { height: 1, timestamp: 1700000000 } }]); + (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([]); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs).toHaveLength(1); + expect(txs[0].direction).toBe('send'); + expect(txs[0].amount).toBe(5000); + expect(txs[0].counterparty).toBeUndefined(); + expect(txs[0].tokenTransfers).toBeUndefined(); + }); + + it('Receive transfer (Blind/Witness): amount uses assignments[] (no requestedAssignment), counterparty stays undefined', async () => { + // Regression guard: Receive transfers don't have the "change vs + // transferred" ambiguity — `assignments[]` IS the inbound delta. And + // RGB blind/witness receives don't reveal sender, so counterparty + // should remain unset (the To/From field will render as "—"). + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [{ assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 8, balance: { settled: 100, future: 100, spendable: 100 } }], + cfa: [], + ifa: [], + uda: [], + }); + (sdkWallet.listTransactions as any) = vi + .fn() + .mockResolvedValue([{ transactionType: 'RgbSend', txid: 'tx-recv', received: 1000, sent: 0, fee: 0, confirmationTime: { height: 1, timestamp: 1700000000 } }]); + (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([ + { + idx: 1, + batchTransferIdx: 1, + createdAt: 1700000000000, + updatedAt: 1700000000000, + status: 'Settled', + kind: 'ReceiveBlind', + assignments: [{ type: 'Fungible', amount: 100 }], + transportEndpoints: [], + txid: 'tx-recv', + }, + ]); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs).toHaveLength(1); + expect(txs[0].direction).toBe('receive'); + expect(txs[0].tokenTransfers?.[0].amount).toBe(100); + expect(txs[0].counterparty).toBeUndefined(); + expect(txs[0].amount).toBeUndefined(); + }); + + it('Send transfer falls back to assignments[] when requestedAssignment is missing (defensive)', async () => { + // Older wallet states or batch sends might surface a Send transfer + // without `requestedAssignment`. Falling back to `assignments[]` + // keeps the UI showing *something* rather than rendering an empty + // token row. + const { sdkWallet } = installAdapter(); + (sdkWallet.listAssets as any) = vi.fn().mockResolvedValue({ + nia: [{ assetId: 'nia-A', name: 'Token A', ticker: 'A', precision: 0, balance: { settled: 0, future: 0, spendable: 0 } }], + cfa: [], + ifa: [], + uda: [], + }); + (sdkWallet.listTransactions as any) = vi + .fn() + .mockResolvedValue([{ transactionType: 'RgbSend', txid: 'tx-fallback', received: 0, sent: 0, fee: 50, confirmationTime: { height: 1, timestamp: 1700000000 } }]); + (sdkWallet.listTransfers as any) = vi.fn().mockResolvedValue([ + { + idx: 1, + batchTransferIdx: 1, + createdAt: 1700000000000, + updatedAt: 1700000000000, + status: 'Settled', + kind: 'Send', + // requestedAssignment intentionally absent + assignments: [{ type: 'Fungible', amount: 42 }], + transportEndpoints: [], + txid: 'tx-fallback', + recipientId: 'rcp-fallback', + }, + ]); + + const w = new RgbWallet(NETWORK_RGB_TESTNET); + w.setSecret(MNEMONIC); + await w.init({} as any); + const txs = await w.getCommonTransactions(); + expect(txs[0].tokenTransfers?.[0].amount).toBe(42); + expect(txs[0].counterparty).toBe('rcp-fallback'); + }); }); describe('fetchTokenBalances', () => { From 7516091237b87af0eceba4611ff45f406e9333af Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Mon, 4 May 2026 14:52:16 +0100 Subject: [PATCH 29/30] fix: wip --- .../Popup/OnboardingVerifyingRgbBackup.tsx | 2 +- .../Popup/components/RgbBackupBanner.tsx | 2 +- .../app/onboarding/verifying-rgb-backup.tsx | 2 +- mobile/components/RgbBackupBanner.tsx | 2 +- shared/class/wallets/rgb-wallet.ts | 20 +- shared/hooks/useRgbBackupStatus.ts | 2 +- shared/tests/unit-vi/rgb-wallet.test.ts | 4 +- shared/types/IStorage.ts | 4 +- tasks/rgb-backup-failure-handling.md | 190 -------- tasks/rgb-verification-notes.md | 413 ------------------ tasks/ship-rgb.md | 118 +++++ 11 files changed, 137 insertions(+), 622 deletions(-) delete mode 100644 tasks/rgb-backup-failure-handling.md delete mode 100644 tasks/rgb-verification-notes.md create mode 100644 tasks/ship-rgb.md diff --git a/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx b/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx index 8510a3024..1e327366f 100644 --- a/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx +++ b/ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx @@ -27,7 +27,7 @@ type Status = 'probing' | 'failed'; * On Skip the user proceeds to TOS; subsequent RGB inits still fail with * the same typed error, so the safety net is never bypassed. * - * See tasks/rgb-backup-failure-handling.md. + * See tasks/ship-rgb.md. */ const OnboardingVerifyingRgbBackup: React.FC = () => { const navigate = useNavigate(); diff --git a/ext/src/pages/Popup/components/RgbBackupBanner.tsx b/ext/src/pages/Popup/components/RgbBackupBanner.tsx index 81f66d745..6c96a99f6 100644 --- a/ext/src/pages/Popup/components/RgbBackupBanner.tsx +++ b/ext/src/pages/Popup/components/RgbBackupBanner.tsx @@ -8,7 +8,7 @@ import { useRgbBackupStatus } from '@shared/hooks/useRgbBackupStatus'; import { BackgroundCaller } from '../../../modules/background-caller'; // Persistent banner for the RGB backup ledger — see -// tasks/rgb-backup-failure-handling.md. +// tasks/ship-rgb.md. const RgbBackupBanner: React.FC = () => { const { network } = useContext(NetworkContext); const { accountNumber } = useContext(AccountNumberContext); diff --git a/mobile/app/onboarding/verifying-rgb-backup.tsx b/mobile/app/onboarding/verifying-rgb-backup.tsx index fbff5d60d..ab21f8d26 100644 --- a/mobile/app/onboarding/verifying-rgb-backup.tsx +++ b/mobile/app/onboarding/verifying-rgb-backup.tsx @@ -10,7 +10,7 @@ * On Skip the user proceeds to TOS; subsequent RGB inits still fail with * the same typed error, so the safety net is never bypassed. * - * See tasks/rgb-backup-failure-handling.md. + * See tasks/ship-rgb.md. */ import { Ionicons } from '@expo/vector-icons'; import { Stack, useRouter } from 'expo-router'; diff --git a/mobile/components/RgbBackupBanner.tsx b/mobile/components/RgbBackupBanner.tsx index 9543a5cad..dccc97699 100644 --- a/mobile/components/RgbBackupBanner.tsx +++ b/mobile/components/RgbBackupBanner.tsx @@ -11,7 +11,7 @@ import { NetworkContext } from '@shared/hooks/NetworkContext'; import { useRgbBackupStatus } from '@shared/hooks/useRgbBackupStatus'; // Keeps the banner persistent until the user retries or a fresh mutation -// succeeds — see tasks/rgb-backup-failure-handling.md. +// succeeds — see tasks/ship-rgb.md. const RgbBackupBanner: React.FC = () => { const { network } = React.useContext(NetworkContext); diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index 7993fd0a4..d1a535eb7 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -35,7 +35,7 @@ export interface DecodedInvoice { * Error classes for VSS backup edge cases. Callers (lazyInitWallet, the * restore-from-seed gate) match on `instanceof` to decide whether to retry, * surface a "server unreachable" UI, or refuse to proceed because a backup we - * previously had has gone missing. See tasks/rgb-backup-failure-handling.md. + * previously had has gone missing. See tasks/ship-rgb.md. */ export class RgbBackupServerUnreachableError extends Error { constructor(message = 'RGB backup server is unreachable') { @@ -98,7 +98,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa private static readonly UTXO_PREPARE_FEE_GATE_SAT_VB = 3; // mainnet only private _preparingWallet = false; /** - * Backup ledger. See tasks/rgb-backup-failure-handling.md. + * Backup ledger. See tasks/ship-rgb.md. * `_pendingMutationsSinceBackup` is bumped before every `tryBackup` and * decremented on success; the persisted shape mirrors what the UI hook * surfaces. `_storage` is captured during `init()` so `tryBackup` can persist @@ -135,7 +135,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa * * The decision tree below exists to defend against silently overwriting a * real VSS backup with empty local state — the worst possible failure mode - * for this wallet. See tasks/rgb-backup-failure-handling.md for the full + * for this wallet. See tasks/ship-rgb.md for the full * model. Short version: * * 1. Try `restoreFromVss`. Happy path: backup exists, we restore, done. @@ -197,7 +197,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa * * Returns the kept wallet on success; throws `RgbBackupServerUnreachableError`, * `RgbBackupLostError`, or the original restore error otherwise. See - * tasks/rgb-backup-failure-handling.md. + * tasks/ship-rgb.md. */ private async acquireFreshWalletAfterProbe(originalError: unknown): Promise { const candidate = await this.adapter.createWallet({ mnemonic: this.secret!, network: this._sdkNetwork }); @@ -421,7 +421,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa async issueAssetNia(params: { ticker: string; name: string; precision: number; amounts: number[] }): Promise<{ assetId: string; ticker: string; name: string; precision: number }> { const asset = await this.sdk().issueAssetNia(params); // Critical: a freshly-issued asset that isn't backed up is invisible after - // a reinstall. See tasks/rgb-backup-failure-handling.md. + // a reinstall. See tasks/ship-rgb.md. await this.tryBackup({ critical: true }); return { assetId: asset.assetId, ticker: asset.ticker, name: asset.name, precision: asset.precision }; } @@ -434,7 +434,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa }); // Critical: a sent-on-chain payment changed the wallet's UTXO set; a // recovery without this backup would think those UTXOs are still - // spendable. See tasks/rgb-backup-failure-handling.md. + // spendable. See tasks/ship-rgb.md. await this.tryBackup({ critical: true }); return txid; } @@ -476,7 +476,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa // Critical: an asset transfer changed colorable UTXO bindings; a recovery // without this backup would have the wrong allocation map and could even // double-spend the now-consumed slot. See - // tasks/rgb-backup-failure-handling.md. + // tasks/ship-rgb.md. await this.tryBackup({ critical: true }); return result.txid; } @@ -666,7 +666,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } /** - * Backup ledger semantics. See tasks/rgb-backup-failure-handling.md. + * Backup ledger semantics. See tasks/ship-rgb.md. * * Every state-changing op increments `_pendingMutationsSinceBackup`, calls * `vssBackup`, and on success decrements back to zero (or whatever the @@ -734,7 +734,7 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } // ── Persistence helpers for the backup ledger + the "initialized" flag. ── - // See tasks/rgb-backup-failure-handling.md. + // See tasks/ship-rgb.md. /** * The `storage?. ?? noop` shape below tolerates the test fixtures' @@ -832,7 +832,7 @@ function isInsufficientAllocationSlots(e: unknown): boolean { * Classify a backup failure for the UI ledger. The split exists so the * warning banner can say "we couldn't reach the server" vs "the server * rejected our credentials" — both are actionable, but in different ways. - * See tasks/rgb-backup-failure-handling.md. + * See tasks/ship-rgb.md. */ function classifyBackupError(e: unknown): RgbBackupErrorKind { if (!e) return 'unknown'; diff --git a/shared/hooks/useRgbBackupStatus.ts b/shared/hooks/useRgbBackupStatus.ts index 5eb3bb773..9f4cb921e 100644 --- a/shared/hooks/useRgbBackupStatus.ts +++ b/shared/hooks/useRgbBackupStatus.ts @@ -52,7 +52,7 @@ async function fetcher(arg: FetcherArg): Promise * - `failed`: the last backup attempt threw — until the user retries (or a * new mutation succeeds), VSS is stale relative to local state * - * See tasks/rgb-backup-failure-handling.md for why this exists. + * See tasks/ship-rgb.md for why this exists. */ export function useRgbBackupStatus(network: Networks, accountNumber: number, backgroundCaller: IBackgroundCaller): UseRgbBackupStatusResult { const isRgb = network === NETWORK_RGB || network === NETWORK_RGB_TESTNET; diff --git a/shared/tests/unit-vi/rgb-wallet.test.ts b/shared/tests/unit-vi/rgb-wallet.test.ts index 912ab245d..587f33176 100644 --- a/shared/tests/unit-vi/rgb-wallet.test.ts +++ b/shared/tests/unit-vi/rgb-wallet.test.ts @@ -57,7 +57,7 @@ function installAdapter(overrides: Partial = {}) { // Default to "VSS reachable, no backup yet, no backup required" so the // probe-then-fresh-create path used by RgbWallet.init() works without // each test having to spell it out. See - // tasks/rgb-backup-failure-handling.md. + // tasks/ship-rgb.md. vssBackupInfo: vi.fn().mockResolvedValue({ backupExists: false, backupRequired: false, serverVersion: null }), configureVssBackup: vi.fn(), disableVssAutoBackup: vi.fn(), @@ -916,7 +916,7 @@ describe('RgbWallet', () => { /** * Backup-failure handling. The model these tests cover is documented in - * tasks/rgb-backup-failure-handling.md. + * tasks/ship-rgb.md. * * The init() probe path: when restoreFromVss reports a "missing" error, we * don't trust that classification on its own — we create a candidate wallet diff --git a/shared/types/IStorage.ts b/shared/types/IStorage.ts index 3f56b720b..d77b0a783 100644 --- a/shared/types/IStorage.ts +++ b/shared/types/IStorage.ts @@ -15,12 +15,12 @@ export const STORAGE_KEY_FLASHNET_TRANSFERS = 'STORAGE_KEY_FLASHNET_TRANSFERS'; export const STORAGE_KEY_SPARK_REFUNDED_DEPOSITS = 'STORAGE_KEY_SPARK_REFUNDED_DEPOSITS'; /** Set after the first successful RGB wallet init per network. Used to detect * the dangerous "had a backup, now VSS says missing" case during a later - * unlock — see tasks/rgb-backup-failure-handling.md. */ + * unlock — see tasks/ship-rgb.md. */ export const STORAGE_KEY_RGB_INITIALIZED = 'STORAGE_KEY_RGB_INITIALIZED'; /** Persistent ledger of RGB backup state per network/account: pending mutation * count + last failure classification. Survives force-quit so the warning * banner can't be hidden by killing the app. See - * tasks/rgb-backup-failure-handling.md. */ + * tasks/ship-rgb.md. */ export const STORAGE_KEY_RGB_BACKUP_STATE = 'STORAGE_KEY_RGB_BACKUP_STATE'; export interface IStorage { diff --git a/tasks/rgb-backup-failure-handling.md b/tasks/rgb-backup-failure-handling.md deleted file mode 100644 index 03c50b919..000000000 --- a/tasks/rgb-backup-failure-handling.md +++ /dev/null @@ -1,190 +0,0 @@ -# RGB VSS backup-failure handling - -## Why this exists - -VSS is the only durable record of an RGB wallet's state. The local sled DB -holds the truth on this device, but a reinstall, a wipe, or a new device -gets state strictly from VSS. Any window where local mutations have happened -and VSS hasn't been updated is a window where tokens can disappear on -recovery. Today that window is silent, unbounded, and indistinguishable -(from the user's POV) from a healthy wallet. - -This doc captures the failure modes, the design that addresses them, and -the implementation steps. Comments in the code touching `init()` / -`tryBackup` / the new probe should reference this file by path so the -reasoning isn't lost. - -## The four failure modes - -**A. Backup fails *after* a state-changing op.** -`tryBackup()` swallows the error today. The mutation (transfer / issue / -createUtxos) succeeded locally — the local sled DB has the truth — but VSS -is stale. If the user reinstalls or moves devices before the next mutation, -the recovered state is missing whatever happened since the last successful -backup. Tokens look gone. - -**B. VSS unreachable during *restore*.** -`restoreFromVss` throws something `isVssBackupMissing()` correctly does -*not* classify as missing, so init re-throws. The user sees a generic -error. Good news: we don't currently silently overwrite — but the UX is -opaque, and the only thing standing between us and disaster is one regex -in `isVssBackupMissing`. - -**C. Fresh install + valid mnemonic + transient VSS outage = silent overwrite risk.** -The fragile path. Today this *probably* works because we rethrow on -non-"missing". But if `isVssBackupMissing` is ever broadened (e.g., adding -a network-error case "to be helpful"), a user restoring during an outage -gets a fresh empty wallet — and the next mutation calls `vssBackup` which -**overwrites the real backup** with empty state. Loss is permanent. - -**D. No user-visible "synced" state.** -After a transfer the user has no signal whether their state is durably -backed up. Today's "swallow + log" behavior is invisible — only the -developer sees `handleError`. - -## Design (three ideas) - -### 1. A "wallet has been used before on this device" flag - -`STORAGE_KEY_RGB_INITIALIZED_` set after the first successful -`init()` per network. This flag distinguishes: - -- **First-ever creation on this device** — backup-missing is expected; - create fresh wallet. -- **Restore from existing seed on a new device or fresh install** — flag - absent. Backup-missing here is a yellow flag: could be genuine first - creation, OR could be a server outage classified as missing. We add a - probe (see #2). -- **Same device, after any successful init** — flag present. - Backup-missing here is a *red* flag — we previously had backup, now - it's gone? Refuse to silently recreate (`RgbBackupLostError`). - -### 2. A VSS health probe before "missing" decisions - -Use `sdk().vssBackupInfo()` before deciding to fall back to fresh wallet. -Three outcomes: - -- `backupExists: true` → restore should have worked; the original error - was real, rethrow. -- `backupExists: false` and probe succeeded → genuinely no backup; safe - to create fresh. -- Probe itself throws → server is unreachable. **Do not create fresh - wallet.** Throw a typed `RgbBackupServerUnreachableError`. - -This eliminates the silent-overwrite risk in mode C. - -**Critical assumption:** `vssBackupInfo()` must be cheap and side-effect -free (HTTP HEAD / version probe), not a full backup fetch. Verify before -implementing the probe-before-restore step. If it's actually heavy, -swap to a TCP-level reachability check. - -### 3. Per-wallet backup ledger + UI surface - -Track on `RgbWallet`: - -- `_lastBackupAt: number | null` -- `_pendingMutationsSinceBackup: number` — incremented before any - `tryBackup`, decremented on success. -- `_lastBackupError: { kind: 'network' | 'auth' | 'unknown', at: number } | null` - -Persist a small subset (`{ pendingMutations, lastBackupError }`) to -storage so a force-quit can't hide the warning. - -Expose via a hook `useRgbBackupStatus(network)` returning -`{ status: 'synced' | 'pending' | 'failed', pendingCount, lastBackupAt, lastError }`. - -UI surface: - -- **Persistent banner** on RGB home when `status !== 'synced'`. Tap → - "Retry backup" button + plain-language explanation ("your last N - changes haven't been saved to backup; we'll keep retrying, or tap to - retry now"). -- **Send-confirm screen**: if a transfer would push the wallet further - out-of-sync (i.e., we already have pending unbacked changes and the - network looks bad), show a soft-warning before submission, *not* a - hard block. -- **Receive screen**: same treatment — receiving is also a state change. - -## Implementation steps - -1. **`shared/class/wallets/rgb-wallet.ts`** - - Add the three tracking fields. Persist - `{ pendingMutations, lastBackupError }` via a new storage key per - network/account. - - Replace `tryBackup` with `tryBackup({ critical?: boolean })`: - - Pre-increments `_pendingMutationsSinceBackup` and persists. - - Calls `vssBackup`, on success decrements counter, sets - `_lastBackupAt`, persists. - - On failure classifies error (network vs auth vs schema), persists - `_lastBackupError`, returns `false`. - - Optional `critical: true` for issue/transfer flows: if backup - fails, throw a typed `RgbBackupFailedError` so the caller can show - a confirm-anyway dialog. Default stays non-throwing for - `createUtxos` / `prepareWallet` background work. - - In `init()`: when `restoreFromVss` throws, *before* falling through - to `createWallet`, call `sdk().vssBackupInfo()`. Only create fresh - wallet if `backupExists === false` AND the probe succeeded. On probe - throw, throw `RgbBackupServerUnreachableError`. If - `STORAGE_KEY_RGB_INITIALIZED_` is set and probe says - `backupExists === false`, throw `RgbBackupLostError`. - - On every successful `init()`, set - `STORAGE_KEY_RGB_INITIALIZED_` = `'true'`. - -2. **`shared/types/rgb-adapter.ts`** — `vssBackupInfo` is already in the - Pick allow-list. No change. - -3. **`shared/hooks/useRgbBackupStatus.ts`** (new) — SWR-driven; reads from - the wallet via `BackgroundExecutor.getRgbBackupStatus(network)`. Polls - every 30s; revalidates on app foreground. - -4. **`mobile/src/modules/background-executor.ts`** (and `ext/` mirror) — - add `getRgbBackupStatus(network)` / `retryRgbBackup(network)` RPCs. - -5. **UI** - - `mobile/components/RgbBackupBanner.tsx` (new) — sticky banner on RGB - home + send/receive flows. - - `mobile/app/send/send-confirm.tsx` — inline warning when - `status !== 'synced'`. - - Settings screen "Backup status" entry — last backup time + "Force - backup now". - -6. **Restore-from-seed gate** — on TOS/unlock flow, if user is restoring - (not creating new), surface a "verifying backup..." step that calls - `vssBackupInfo`. If unreachable, present "Skip RGB for now" / "Retry" - buttons. Only proceed to `lazyInitWallet(NETWORK_RGB_*)` when the probe - succeeds. Non-RGB networks proceed regardless. - -7. **Tests** (`shared/tests/unit-vi/rgb-wallet.test.ts`) - - `init` with restore failure + probe success(missing) → creates fresh - wallet. - - `init` with restore failure + probe failure → throws - `RgbBackupServerUnreachableError`. - - `init` with restore failure + probe success(exists) → rethrows - original error (don't silently fresh-create). - - `init` with `RGB_INITIALIZED` flag set + probe success(missing) → - throws `RgbBackupLostError`. - - `tryBackup` failure path increments pending counter, persists error. - - `tryBackup({ critical: true })` failure path throws. - -## What is *not* in scope - -- **Hard-blocking the user on every backup failure.** Bad UX for - transient blips; persistent banner + retry covers the long tail. -- **Encrypting the local sled DB to tolerate backup loss.** Doesn't - address the cross-device case. -- **Building our own backup retry queue.** rgb-lib already has a - `backupRequired` flag — we just surface and retry it. -- **Auto-failing transfers when out-of-sync.** A user with a stale backup - can still successfully transfer; the data risk is on the recovery path, - not the immediate operation. - -## Files to add a comment pointing here - -When implementing, the following sites should carry a short comment -ending in `See tasks/rgb-backup-failure-handling.md`: - -- `RgbWallet.init()` — the probe + flag logic. -- `RgbWallet.tryBackup()` — the ledger semantics. -- `useRgbBackupStatus()` — what the three states mean. -- The restore-from-seed gate in the unlock flow. -- The home banner — why it's persistent until cleared. diff --git a/tasks/rgb-verification-notes.md b/tasks/rgb-verification-notes.md deleted file mode 100644 index 6fbbaaeac..000000000 --- a/tasks/rgb-verification-notes.md +++ /dev/null @@ -1,413 +0,0 @@ -# RGB end-to-end verification — running notes - -Live notes from the verification run; updated as tests progress. Issues -that block a specific test get skipped (per user direction) and recorded -here so we can revisit. - -## Environment snapshot - -- Test seed (mobile): `setup fashion rice grant earn rabbit rude claw knife robust knife actor` -- Faucet: `bash ~/z/rgb-faucet.sh
[amount_sats]` (default 16900) -- ext popup URL: `chrome-extension://jfkjdddajnobopldmhfpgblcidgohkak/popup.html` -- iOS sim: `38489619-1224-48CD-A378-10F23D30B1F9` (iPhone 17, iOS 26.3) -- Android emu: `emulator-5554` (Galaxy S24 Ultra, API 34) - -## Skipped / blocked tests (revisit later) - -### B-EXT-1 — chrome-devtools MCP server died after `chrome.runtime.reload()` -- After clearing `chrome.storage.local` + IndexedDB + reloading runtime in - the popup, the chrome-devtools MCP server disconnected and all - `mcp__chrome-devtools__*` tools became unavailable. -- No chrome process was running on the host; MCP wasn't able to respawn. -- Fix on next run: avoid `chrome.runtime.reload()` from inside the - evaluate_script call; close the popup tab via MCP first, then reload via - a new page request. Or kill any leftover chrome-for-testing process and - let MCP spawn fresh. -- All ext-side tests (A1–A5, C1–C4, D1–D3, E*, F1) are skipped this run - until MCP is restored. - -### A-IOS-1 — iOS launched with a fresh wallet (lost test seed) -- The iOS sim was on a "Wallet created successfully" TOS celebration when - the run started — meaning storage was cleared between sessions. No DEMO - token, no funds. -- Workaround: wipe + import the test mnemonic on iOS (in progress). - -## Test pass/fail log - -### Phase A — Per-platform smoke - -- **A7 — Android RGB Testnet home renders, banner hidden when synced** ✅ - Already on `selectedNetwork-rgb_testnet`, balance 0.0000446 tBTC, - Receive/Send/Issue/UTXOs buttons present, no `RgbBackupBanner` element - in tree (synced state). Recent Sent/Received tx history shows transfers - from prior testing. - -- **A6-android — Receive RGB Asset flow** ✅ - Receive button → bottom sheet ("Receive sats" / "Receive RGB asset") → - Receive RGB Asset screen renders (Asset selector "Any asset", base - units amount input, Generate Invoice). Generate Invoice → RGB Invoice - screen with QR + invoice string + Private (blind) badge + 33m expiry. - Sample invoice (any-asset, 1 base unit) generated: - ``` - rgb:~/~/ae/sb:utxob:fbZFwePS-oOFBS4I-nSyznC4-B_u1T0Z-kSksZEW-cGPB81A-MoqJo?assignment_name=assetOwner&expiry=1777853205&endpoints=rpcs://rgb-proxy-utexo.utexo.com/json-rpc - ``` - Note: this Android wallet has **no RGB tokens** (only 0.0000446 tBTC). - No Tokens section visible on Home — confirmed no DEMO etc. - -- **A5-android — Send screen mounts on RGB Testnet** ✅ - Send button → Send tBTC address screen renders. Header is "Send tBTC" - (per-network ticker, not per-prefix as on ext). Pasting an `rgb:` - invoice and pressing Next routed to the account-based amount entry - screen (`send-amount-acc`) — `validateAddress` accepted the invoice, - the decode lookup didn't auto-fill amount because invoice was issued - with "Any asset" (no `assetId`), so it fell through to amount entry - per `send-address.tsx:75-91`. Header staying "Send tBTC" on RGB - Testnet is intentional on mobile — different from ext, where the - header flips by prefix. Not a bug. - -- **B-android-issue — RGB Issue Asset path works end-to-end** ✅ - Issue button → Issue RGB Asset screen renders (Ticker, Name, - Precision=8, Amount=1000). Filled TEST / Test Token / 8 / 1000 → - "Issue Asset" → Asset Issued screen with asset id - `rgb:WmHHJS~4-Z0PHv8s-0Prxx1N-Cg9tS_Y-JEQvsJb-S0vqqZ4`. Done → - Home now shows Tokens section: "Test Token 0.00001 TEST" (1000 - base units / 10^8 = 0.00001). - Side effect: bootstraps an RGB token on Android for cross-platform - Phase C tests when ext/iOS are restored. - -### Phase E — Backup banner state machine (passive observation) - -- **E1-android — RGB Issue triggers VSS backup, banner does not appear** ✅ - After Issue Asset succeeded, Home rendered with no `RgbBackupBanner` - visible. `useRgbBackupStatus` only mounts the banner when state is - `pending` or `failed`; the issue→backup window was below render - granularity. adb logcat showed no RGB/VSS errors. Implies VSS backup - flushed cleanly before the next state read. E2/E3 (forced failure) - not exercised — would need to interfere with VSS reachability mid-op. - -### iOS state at end of run - -- iOS sim launched with a fresh wallet (lost test seed). On the Bitcoin - network. Show Testnets is OFF in Tools, so RGB Testnet doesn't appear - in the BackdoorNetworkSwitcher list yet (only `rgb` mainnet shows). -- Toggling Show Testnets ON via UI taps was unreliable in this session - (taps at the listed (49, 113) coords didn't register a state change in - the visible button color). Easier next-time path: deep-link to - `layerzwallet://Tools` and tap the precise listed `SettingOption-showTestnets-ON` - coordinates, OR write directly to AsyncStorage (settings key - `showTestnets` = `'ON'`). Deep-linking via `xcrun simctl openurl - layerzwallet://BackdoorNetworkSwitcher` works to navigate. -- iOS Settings → Recovery Phrase tapped through to a glitched dark screen - (only the gear icon visible) — couldn't read the seed. Possibly an - animation race with the dev client. - -### Phase A — what's still pending - -- **A1, A2, A3, A4-ext, A5-ext** — all blocked on chrome-devtools-mcp - recovery. ext popup needs the MCP server back to drive the onboarding - flow + RGB screens. -- **A6-ios, A8-ios** — pending iOS reaching RGB Testnet (need showTestnets - toggle to take + test seed re-imported, OR fresh-fund and skip DEMO). - -## Continuing next run - -Pick-up checklist: -1. Restart `chrome-devtools-mcp` (parent process / claude code restart). -2. On iOS: deep-link to Tools, toggle showTestnets ON, then deep-link - to BackdoorNetworkSwitcher → select rgb_testnet. Fund via faucet. -3. Resume ext by: load popup → wipe storage via evaluate_script - (DON'T call `chrome.runtime.reload()` from inside that script — close - the popup tab from MCP first), then onboard fresh and run A1/A2. -4. Cross-platform RGB transfer using `TEST` asset on Android as the - source: generate Receive invoice on ext or iOS for the TEST asset id, - send from Android via Send screen. - -## Resumed run (chrome-devtools restored) - -### Phase A on ext - -- **A1-ext — Import path triggers VSS gate** ✅ - Onboarding-intro → Import wallet → paste test seed - (`setup fashion rice grant earn rabbit rude claw knife robust knife actor`) - → Set password (`qweqweqwe`) → URL transitioned to - `/onboarding-verifying-rgb-backup`. Gate showed "Backup not verified" - with "Could not verify your RGB backup" + Retry/Skip. Console error: - `Failed to initialize wallet for rgb_testnet account 0: Invalid backup data` - — the same cross-platform VSS interop bug from `rgb-sdk-web/issues/6` - manifesting at the gate as designed (gate caught the throw, - surfaced it, offered Skip — exactly the intended UX). - -- **A2-ext — Create path skips gate** ✅ - Wiped `chrome.storage.local` + IndexedDB + sessionStorage via - `evaluate_script` (no `chrome.runtime.reload()` this time → MCP - stayed up). Reloaded popup → Onboarding-intro → Create wallet - (fresh seed: `what mesh mention price strategy capable multiply - still defense believe name gallery`) → Set password → URL went - straight to `/onboarding-tos`, **bypassing** the gate. `rgb.justImported` - flag was never set, so OnboardingCreatePassword's branch correctly - routed past the gate. - -- **A3-ext — RGB home renders, banner hidden when synced** ✅ - Switched to `Rgb` network on home (note: ext shows mainnet RGB in - the network bar, not testnet — the gate uses `NETWORK_RGB_TESTNET` - internally for the probe). 0 BTC balance, no `RgbBackupBanner` - element in tree. - -- **A4-ext — Receive RGB Asset screen** ✅ - Receive button → `/receive-rgb-token` mounts. Asset combobox - ("Any asset"), amount spinbutton, Generate Invoice + "Receive sats - instead". Invoice generation **fails** at runtime (see B-EXT-2 below). - -- **A5-ext — Send RGB screen + prefix-based header flip** ✅ - Navigated to `/send-rgb`. Header initially "Send tBTC". Pasting - `rgb:WmHHJS~4-...` (Android-issued asset id) → header flips to - "Send RGB asset". Continue button enables. Implementation correctly - watches the input prefix. - -### Confirmed bugs (skipped tests; need upstream fixes) - -### B-EXT-2 — rgb-lib-wasm panics on fresh-wallet first call (filed as issue #7) - -Upstream tracking: https://github.com/UTEXO-Protocol/rgb-sdk-web/issues/7 - -- After Create-wallet path (no prior VSS backup), navigating to - `/receive-rgb-token` on Rgb mainnet, filling amount=1, clicking - Generate Invoice produces: - ``` - [rgb-lib WASM panic] at lib.rs:391 - Uncaught RuntimeError: unreachable - [rgb-lib WASM panic] at lib.rs:529 - [rgb-lib WASM panic] at lib.rs:213 - failed to load asset list: RuntimeError: unreachable - ``` -- These are different from the cross-platform interop bug (which - throws "Invalid backup data" without a panic). This is a **fresh - wallet** path that the web SDK can't traverse. -- The panic happens at the asset-list-load step that's invoked - before invoice generation. The 52/52 unit tests for - `acquireFreshWalletAfterProbe` pass because they mock the SDK; the - real `@utexo/rgb-lib-wasm` panics on what should be a no-op fresh - state. -- **Suggested fix in our codebase**: catch the panic upstream of the - `requestReceive` UI handler and show a clearer error than "Failed - to generate invoice"; or upstream-fix `rgb-lib-wasm` to handle the - empty-asset-list path without unreachable. -- **Next**: file as second issue in `/tmp/kkk.txt` for the user. - -### B-EXT-3 — cross-platform VSS payload undecodable on web (existing) -- Already filed as `rgb-sdk-web#6`. Confirmed again on this run: the - test seed has a mobile-encrypted VSS backup; web SDK throws - `Invalid backup data` on `vssBackupInfo()`. The gate handles this - gracefully (Skip path bypasses). - -### What still works on ext after the bugs - -- Wallet creation & import flows ✅ -- Password / TOS / unlock flows ✅ -- Onboarding gate state machine (Import → gate, Create → bypass) ✅ -- RGB Receive/Send screen mounts and prefix detection ✅ - -### What can't be verified from ext until WASM panic is fixed - -- Phase C cross-platform RGB transfer (ext leg). Both invoice - generation and asset list load fail. -- Phase D (tBTC send via SendRgb). Address validation + Continue - works but the broadcast path likely also hits the WASM panic. -- Phase E live banner mutation (need a successful issue/transfer - to capture the pending→synced transition on ext). -- Phase F1 (web-side restore via Import) blocked by both bugs. - -### Status by task - -- #58 Phase A — DONE (A1✅ A2✅ A3✅ A4✅ A5✅ A6-android✅ A7✅, - A6-ios/A8 deferred; ext A4/A5 mount-only). -- #59 Phase B funding — DONE on Android (already had 0.0000446 tBTC, - TEST token issued). -- #60 Phase C cross-platform — BLOCKED (ext fresh-wallet WASM panic - + iOS state). Skipped this run. -- #61 Phase D tBTC — BLOCKED on ext (same panic). Could still run - iOS↔Android tBTC if iOS is recovered. -- #62 Phase E banner — Android backup-after-issue passed without - banner (synced before render). E2 forced-failure not exercised. -- #63 Phase F VSS interop — CONFIRMED BROKEN as expected for - mobile→web (issue #6). Web-side fresh restore blocked by panic. - -## Files modified for new issue draft - -(See `/tmp/kkk.txt` follow-up — to draft after this verification run.) - -## Resumed run 2 — iOS via AsyncStorage patch + cross-platform RGB - -Workaround for the iOS UI tap reliability problem: terminated the -iOS app, edited `RCTAsyncLocalStorage_V1/manifest.json` directly to -set `STORAGE_KEY_SETTINGS` `showTestnets:"ON"` and -`STORAGE_SELECTED_NETWORK:"rgb_testnet"`, then relaunched. iOS came -up on Rgb_testnet with the existing seed's prior funds: -`0.00008135 tBTC`, with tBTC tx history present. - -### Phase A on iOS - -- **A6-ios — Receive RGB Asset screen renders + invoice generation works** ✅ - Receive button → "What to receive" sheet → "Receive RGB asset" → - Receive RGB Asset screen (Asset "Any asset", Amount field, Generate - Invoice). Filled amount=1, Generate → RGB Invoice screen with QR + - Private (blind) badge + 33m expiry. Invoice copied via "Tap to copy": - ``` - rgb:~/~/ae/sb:utxob:49dDNX35-_TNeftE-QvA15SZ-j_2Ptr~-PUPlUSw-zrDt~4A-OUlK3?assignment_name=assetOwner&expiry=1777889377&endpoints=rpcs://rgb-proxy-utexo.utexo.com/json-rpc - ``` - iOS tBTC receive address (`tb1pal0nx4wlj8690qa4rh4pp2qd83dj2ys9nl276a0gynw26s6vnm24qj5nvec`) - observed via Receive sats path. - -### Phase C — Android → iOS RGB transfer (broadcast leg) ✅ - -- Android: home → Send → pasted iOS invoice → tapped Test Token row - to select asset → Next. -- Send TEST screen rendered (header flipped from "Send tBTC" to - "Send TEST"), balance shown 0.00001 TEST. Tapped max → 0.00001 - filled → Next. -- Confirm screen: Total 0.00001 TEST, Network Fee 0 tBTC, Send to: - the iOS rgb invoice. Tap Confirm Send. -- **"Sent successfully!" card** — full broadcast succeeded. -- Android home post-send: Test Token balance dropped from 0.00001 TEST - (1000 base units) to **0.00000999 TEST** (999 base units), confirming - exactly 1 base unit was sent (the invoice's specified amount). -- **Sender side fully validated** end-to-end: address → token select → - amount → confirm → broadcast → balance decrement. - -### Phase C — iOS receive leg ✅ - -- Re-opened Receive RGB Asset on iOS to re-engage polling. **Asset - selector now shows "Any asset" + "TEST"** — wallet auto-discovered - the new asset. -- Backed to Home → Tokens section shows **Test Token: 0.00000001 TEST** - (exactly the 1 base unit Android broadcast). -- **Phase C-1 (Android → iOS) end-to-end SUCCESS**: invoice gen on iOS, - broadcast on Android, receive sync on iOS, balances reconcile across - both platforms. -- The "success card" on iOS Receive was missed because we navigated - away during sync; not a bug, just a UX detail that the card only - appears if the user stays on Receive while the asset arrives. - -### Status update - -- **#60 Phase C** Android↔iOS RGB transfer (1 of 5 transitions): ✅ - Other 4 transitions (ext legs blocked by lib bug #7; iOS↔Android - reverse direction blocked by bug B-IOS-1 below). -- **#62 Phase E** banner: extended observation — issue+broadcast on - Android still produced no visible banner; backups must be flushing - cleanly within the render-cycle. E2 forced-failure not exercised. - -### Phase D — tBTC vanilla send ✅ - -- **D2 — iOS → Android tBTC** ✅ - Android Receive sats → captured tBTC address - `tb1pmvr9la9geer8v9gm9dv7k3ld4qysckzx5n5c9cxyk3hrutwguqkqzhafxn`. - iOS Send → pasted address → Next → amount 0.00001 → Next → Confirm - Send → "Sent successfully!" card. Android home polled within - ~5 seconds: balance went from `0.0000446 tBTC` → - **`0.0000546 tBTC`** (+0.00001, exact match). - Validates the mobile bitcoin send path on RGB-testnet (which uses - the underlying signet-mapped Bitcoin chain) end-to-end. - -### B-IOS-1 — FIXED + filed as rgb-sdk-rn#25 - -Upstream tracking: https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/25 - -**Root cause**: `@utexo/rgb-sdk-rn`'s `sendBegin` requires `params.assetId` -unconditionally, while the web SDK extracts it from the invoice when -omitted. Our `transferToken` previously skipped passing `assetId` when -the invoice had it embedded — fine for web, broken for mobile. - -**Fix in our code**: `shared/class/wallets/rgb-wallet.ts` `transferToken` -now always forwards `assetId: tokenId`. No-op on web (the invoice's id -wins), required on RN. All 52/52 unit tests pass after updating the -"omits amount/assetId" test to assert `assetId` is always present. - -**Verified on iOS sim**: the originally reported `ValidationError: -asset_id is required for send operation` is gone. A new downstream -error surfaced (`psbtBase64 must be a non-empty string`) on the same -flow — likely caused by the iOS wallet's freshly-received TEST asset -not having a fully-settled spendable UTXO yet (only 1 base unit -received, no change room). This is a different code path than the -asset_id fix and not in scope for this fix. - -### B-IOS-1-original — iOS Send rejects Android-generated invoice with assetId - -- Tried iOS → Android reverse RGB transfer. -- Generated invoice on Android for **TEST asset specifically** - (asset id embedded in URL path before `/ae/sb`): - ``` - rgb:WmHHJS~4-Z0PHv8s-0Prxx1N-Cg9tS_Y-JEQvsJb-S0vqqZ4/RWhwUfTMpuP2Zfx1~j4nswCANGeJrYOqDcKelaMV4zU/ae/sb:utxob:_0ojK8vN-MtLVgVa-6V9XsnV-bchvbdm-nY1tNOr-tT9lNFQ-T4uIq?assignment_name=assetOwner&expiry=1777889893&endpoints=rpcs://rgb-proxy-utexo.utexo.com/json-rpc - ``` -- iOS Send: pasted invoice → Send tBTC screen showed Tokens row with - "Test Token 0.00000001 TEST" → tapped Next. -- Confirm screen rendered correctly: Total `0.00000001 TEST`, - Network Fee `0 tBTC`, full Send-to invoice. Auto-decoded the asset - and amount from the invoice URL **for display**. -- Confirm Send → **Error**: `asset_id is required for send operation`, - toast `Failed to broadcast transaction: ValidationE...`. -- Suggests the mobile send-confirm path decodes the invoice's asset - segment for the UI but doesn't pass it through to the rgb-lib - `pay`/`transferToken` call. Likely fix lives in - `mobile/app/send/send-confirm.tsx` — when invoice contains - `assetId`, propagate it into the `transferToken` args instead of - letting the lib infer from the empty `asset_id` field. -- Skipped: rather than fix in this verification run, recording for - follow-up. **One direction (Android→iOS) of cross-platform RGB - is the validated success.** - ---- - -## 2026-05-04 — psbtBase64-empty bug (post-#25 fix) - -After fixing rgb-sdk-rn#25 (asset_id required), iOS → Android RGB -transfers now hit a different consistent failure: -`ValidationError: psbtBase64 must be a non-empty string` raised by -`sendEnd`'s param validation. - -### Repro -1. Android (with TEST balance) generates blind invoice with amount=50 - → invoice format `/gk/sb` with embedded amount. -2. iOS (TEST balance = 100 base units, 5+ free colorable slots, - plenty of vanilla sats) pastes the invoice. -3. send-address auto-decodes `decoded.assignment.amount = 50`, - bypasses send-amount, lands on send-confirm showing - "Total 0.0000005 TEST". -4. Confirm Send → JS error - `Failed to broadcast transaction: ValidationError: psbtBase64 - must be a non-empty string [field: psbtBase64]`. - -### Root cause analysis -- `RNRgbLibBinding.sendBegin()` calls `Rgb.sendBegin` and returns - `r.psbt`. When something's wrong at the rgb-lib layer, `r.psbt` - comes back as `""` (empty) rather than throwing. -- `BaseWalletManager.send()` then calls `signPsbt("")` then - `sendEnd({signedPsbt: ""})`. `sendEnd`'s validator throws - `psbtBase64 must be a non-empty string`. -- So the surfaced error is a *symptom*, not the cause. - -### Confirmed not to be the cause -- **Allocation pressure** — iOS UTXO Manager confirms 5 free - colorable slots, settled TEST allocation present, vanilla balance - ample. -- **Invoice format** — same `/gk/sb` invoice succeeds on receive - side; iOS auto-decodes amount correctly. -- **`amount` arg conflict** — already guarded in `transferToken`: - we only pass `amount` when invoice lacks one. (Comment at - shared/class/wallets/rgb-wallet.ts:448-452.) - -### Suspected upstream issues to file -1. **rgb-sdk-rn / rgb-lib**: `sendBegin` returning empty PSBT instead - of throwing a real error when it can't build one. Need a small repro. -2. **rgb-sdk-rn**: `failTransfers` reports "No pending transfers to - fail" while UTXO Manager clearly shows a stuck `pending` allocation - with display `{"type":"type","amount":null}`. After a failed send, - `listAssets` even drops the asset from the wallet's view until app - restart. - -### Status -- Fix for #25 (asset_id) committed and tested locally — 52/52 - unit tests pass. -- psbtBase64 issue blocked on upstream investigation. Working - workaround: Android → iOS direction validated, ext direction also - blocked by web SDK panic (rgb-sdk-web#7). diff --git a/tasks/ship-rgb.md b/tasks/ship-rgb.md new file mode 100644 index 000000000..8f32efb4e --- /dev/null +++ b/tasks/ship-rgb.md @@ -0,0 +1,118 @@ +# Ship RGB + +Branch: `rgb-two`. Both RGB networks are flagged `isTestnet: true` so they +hide behind the "Show Testnets" toggle until upstream blockers clear. +This doc is for picking the work back up later. + +## What works + +- **Android, RGB Testnet**: issue NIA asset, generate blind invoices, + receive tokens, send tokens, send/receive tBTC, UTXO Manager (Issue / + Fail Pending / Refresh), backup banner state machine, restore-from-seed + VSS gate. +- **iOS sim, RGB Testnet**: same surface as Android *except* outbound + RGB token sends fail (see `psbtBase64` blocker below). tBTC send/receive + works. RGB receive works. +- **Cross-platform RGB token transfer**: only `Android → iOS` validated + end-to-end (Phase C-1 in the run log). +- **Onboarding gate** (`mobile/app/onboarding/verifying-rgb-backup.tsx`, + `ext/src/pages/Popup/OnboardingVerifyingRgbBackup.tsx`): import path + probes VSS, surfaces typed errors, offers Skip; create path bypasses. +- **Backup banner** (`mobile/components/RgbBackupBanner.tsx` + ext + mirror): persistent on `pending` / `failed`, retry on tap. Hidden on + `synced`. Hook: `shared/hooks/useRgbBackupStatus.ts`. + +## What's broken (upstream) + +| ID | Surface | Issue | +|----|---------|-------| +| [rgb-sdk-web#6](https://github.com/UTEXO-Protocol/rgb-sdk-web/issues/6) | ext | Mobile-encrypted VSS payload undecodable on web — cross-platform restore broken. | +| [rgb-sdk-web#7](https://github.com/UTEXO-Protocol/rgb-sdk-web/issues/7) | ext | `rgb-lib-wasm` panics on fresh wallet's first `listAssets`. Blocks all ext RGB ops. | +| psbtBase64-empty | iOS sender | rgb-lib's `sendBegin` returns `""` instead of throwing → `sendEnd` fails with `psbtBase64 must be a non-empty string`. Repro + analysis in run log below. **Not yet filed.** | +| `failTransfers` ghost-pending | iOS / Android | UTXO Manager shows `pending` allocation, `failTransfers` reports "no pending transfers to fail". After a failed send, `listAssets` drops the asset until app restart. **Not yet filed.** | +| bdk-rn x86_64 | EAS Maestro | `bdk-rn` (transitive of `@utexo/rgb-sdk-rn`) only ships `arm64-v8a`; eager dlopen on app boot kills app on x86_64 emulators. Issue draft in `/tmp/kkk.txt`. **Not yet filed.** | + +## Upstream issues already filed + +- [rgb-sdk-rn#20](https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/20) — iOS ignores `dataDir`, hardcodes `Documents//` (referenced in `mobile/src/modules/rgb-adapter.ts:nativeWalletDirs`). +- [rgb-sdk-rn#22](https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/22) — `listUnspents` assignment display bug `{type:"type",amount:null}` (referenced in `mobile/app/utxo-manager.tsx:235`). +- [rgb-sdk-rn#24](https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/24) — Android TMPDIR (patched in `mobile/patches/@utexo+rgb-sdk-rn+1.0.0-beta.9.patch`). +- [rgb-sdk-rn#25](https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/25) — `sendBegin` requires `assetId` even when invoice carries it. Worked around in `transferToken` (`shared/class/wallets/rgb-wallet.ts:462-475`). +- [rgb-sdk-rn#28](https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/28) — `Assignment` shape mismatch iOS vs Android (`{Fungible: 100}` vs `{type, amount}`); only `listUnspents` is normalized in JS shim, `listTransfers` isn't. Comment + worked around at `shared/class/wallets/rgb-wallet.ts:annotatedTransfersToCommon`. +- [rgb-sdk-rn#29](https://github.com/UTEXO-Protocol/rgb-sdk-rn/issues/29) — `Transfer.expiration` (TS) vs `expirationTimestamp` (native) field rename. Not consumed in our code yet. + +## Critical files + +- `shared/class/wallets/rgb-wallet.ts` — `init()` probe, `acquireFreshWalletAfterProbe`, `tryBackup`, `transferToken`, `getCommonTransactions`, `RgbBackupServerUnreachableError` / `RgbBackupLostError`. +- `shared/types/IStorage.ts` — `getRgbBackupStateStorageKey`, `getRgbInitializedStorageKey`. +- `shared/types/rgb-adapter.ts` — `IRgbAdapter` interface. +- `shared/hooks/useRgbBackupStatus.ts` — banner data source. +- `mobile/src/modules/rgb-adapter.ts` — RN-side adapter, per-mnemonic data dir, corrupt-store recovery. +- `mobile/app/send/{send-address,send-confirm}.tsx` — RGB invoice auto-decode, amount routing. +- `mobile/app/utxo-manager.tsx`, `mobile/app/issue-asset.tsx`, `mobile/app/receive-rgb-token.tsx` — debug screens. +- `ext/src/pages/Popup/{SendRgb,ReceiveRgbToken,OnboardingVerifyingRgbBackup}.tsx`, `ext/src/pages/Popup/components/RgbBackupBanner.tsx`. + +## Tests + +- `shared/tests/unit-vi/rgb-wallet.test.ts` — 57/57 passing. Covers all four + init() probe outcomes, both `tryBackup` modes, `transferToken` invoice + variants, `getCommonTransactions` token-row attribution incl. + `requestedAssignment` preference for Send. +- `shared/tests/integration-vi/rgb-wallet.test.ts` — integration suite. +- `mobile/.maestro/{home,restore}.yml` — fail on x86_64 emulators (EAS test + farm) due to bdk-rn dlopen crash. Pass locally on arm64-v8a emulators. + +## Backup design (one-page summary) + +VSS is the only durable record. The hard failure mode is silent-overwrite: +fresh install + valid mnemonic + transient VSS outage → SDK creates fresh +wallet → next mutation `vssBackup`s empty state → real backup gone. + +Three pieces guard against this: + +1. **`STORAGE_KEY_RGB_INITIALIZED_`** flag, set after first + successful init. Distinguishes "first ever creation on this device" + (fresh OK) vs "wallet was here before and now backup says missing" + (red flag → throw `RgbBackupLostError`). +2. **VSS health probe** (`vssBackupInfo`) before falling back to fresh + creation. Throws `RgbBackupServerUnreachableError` if probe itself + fails. +3. **Backup ledger** (`{pendingMutations, lastBackupAt, lastBackupError}`), + persisted, surfaced via `useRgbBackupStatus` → banner. + +Onboarding gate calls `lazyInitWallet(NETWORK_RGB_TESTNET)` to surface +the typed errors during restore (not on first RGB tap). Skip is allowed; +subsequent inits still fail with the same typed error so the safety net +isn't bypassed. + +## Verification quick-resume + +Test seed (mobile only — has TEST asset issued): +`setup fashion rice grant earn rabbit rude claw knife robust knife actor` + +- iOS sim: `38489619-1224-48CD-A378-10F23D30B1F9` (iPhone 17, iOS 26.3). + Show Testnets toggle is unreliable via UI taps — easier to edit + AsyncStorage `manifest.json` directly: set + `STORAGE_KEY_SETTINGS.showTestnets="ON"` and + `STORAGE_SELECTED_NETWORK="rgb_testnet"`, then relaunch. +- Android emu: `emulator-5554` (Galaxy S24 Ultra, API 34, arm64). +- Faucet: `bash ~/z/rgb-faucet.sh
[amount_sats]` (default 16900). + +To resume: +1. Reload mobile app, switch to RGB Testnet, confirm balance/tokens render. +2. Generate Receive invoice on Android, send 100 base units of TEST from + iOS. Expected today: fails with `psbtBase64 must be a non-empty string`. + File the upstream issue + repro. +3. ext: blocked on rgb-sdk-web #6, #7. No additional verification possible + until those land. + +## Quick "is RGB still broken?" smoke + +```bash +# unit + integration +cd mobile && npx vitest run shared/tests/unit-vi/rgb-wallet.test.ts +cd mobile && npx vitest run shared/tests/integration-vi/rgb-wallet.test.ts + +# Maestro (arm64-v8a emulator only — see bdk-rn x86_64 row above) +cd mobile && maestro test .maestro/home.yml +``` From 5993e291c43f0e6da60125627718679c5b1cac71 Mon Sep 17 00:00:00 2001 From: Jude Nullon Date: Mon, 4 May 2026 20:14:54 +0100 Subject: [PATCH 30/30] fix: wip --- shared/class/wallets/rgb-wallet.ts | 40 +++++++++++++++++++++++++----- shared/modules/wallet-utils.ts | 12 +++++++++ tasks/ship-rgb.md | 15 +++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/shared/class/wallets/rgb-wallet.ts b/shared/class/wallets/rgb-wallet.ts index d1a535eb7..89e059c9d 100644 --- a/shared/class/wallets/rgb-wallet.ts +++ b/shared/class/wallets/rgb-wallet.ts @@ -89,6 +89,10 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa private static readonly SYNC_COOLDOWN_MS = 10_000; private _lastSync: number = 0; private _syncInFlight: Promise | undefined; + /** The most recent sync promise (in-flight or completed). Returned from the + * cooldown branch so a `sync()` short-circuit still chains correctly with + * callers that `await` it before reading state. */ + private _lastSyncPromise: Promise = Promise.resolve(); /** Auto-UTXO prep tunables. Top up colorable slots so the user doesn't have * to wait through a chain confirmation the moment they hit Issue / Receive. */ private static readonly UTXO_PREPARE_DELAY_MS = 1_000; @@ -97,6 +101,11 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa private static readonly UTXO_PREPARE_SIZE_SATS = 1_000; private static readonly UTXO_PREPARE_FEE_GATE_SAT_VB = 3; // mainnet only private _preparingWallet = false; + /** Timer id for the deferred `prepareWallet` kick-off. Cleared in + * `dispose()` so a logout / network switch within the prep delay can't + * fire SDK calls against an evicted instance. */ + private _prepareTimer: ReturnType | undefined; + private _disposed = false; /** * Backup ledger. See tasks/ship-rgb.md. * `_pendingMutationsSinceBackup` is bumped before every `tryBackup` and @@ -182,12 +191,29 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa // Pre-warm colorable UTXOs in the background so Issue / blind-Receive // don't have to detour through a UTXO-creation tx the moment the user - // taps them. Deferred so it never blocks first paint. - setTimeout(() => { + // taps them. Deferred so it never blocks first paint. Cancelled on + // dispose so a logout / network switch within UTXO_PREPARE_DELAY_MS + // can't run SDK calls against an evicted wallet. + this._prepareTimer = setTimeout(() => { + this._prepareTimer = undefined; + if (this._disposed) return; this.prepareWallet().catch((e) => globalThis.handleError?.(e, 'rgb-wallet.ts:prepareWallet:scheduled')); }, RgbWallet.UTXO_PREPARE_DELAY_MS); } + /** + * Releases scheduled work tied to this instance. Idempotent. Called by + * `clearWalletCache()` on logout / wipe so the deferred `prepareWallet` + * timer can't fire against a wallet whose underlying storage is gone. + */ + dispose(): void { + this._disposed = true; + if (this._prepareTimer !== undefined) { + clearTimeout(this._prepareTimer); + this._prepareTimer = undefined; + } + } + /** * Probe-then-keep flow. Called only when `restoreFromVss` failed in a way * `isVssBackupMissing` recognized — we still don't trust that on its own, @@ -258,8 +284,8 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa */ private async sync(): Promise { if (this._syncInFlight) return this._syncInFlight; - if (Date.now() - this._lastSync < RgbWallet.SYNC_COOLDOWN_MS) return; - this._syncInFlight = (async () => { + if (Date.now() - this._lastSync < RgbWallet.SYNC_COOLDOWN_MS) return this._lastSyncPromise; + const p = (async () => { try { await this.sdk().syncWallet(); } catch (e) { @@ -272,9 +298,11 @@ export class RgbWallet extends AbstractWallet implements InterfaceAccountBasedWa } this._lastSync = Date.now(); })().finally(() => { - this._syncInFlight = undefined; + if (this._syncInFlight === p) this._syncInFlight = undefined; }); - return this._syncInFlight; + this._syncInFlight = p; + this._lastSyncPromise = p; + return p; } async getOffchainReceiveAddress(): Promise { diff --git a/shared/modules/wallet-utils.ts b/shared/modules/wallet-utils.ts index 262a24b85..c66e47eb1 100644 --- a/shared/modules/wallet-utils.ts +++ b/shared/modules/wallet-utils.ts @@ -283,6 +283,18 @@ export const sanitizeAndValidateMnemonic = (mnemonic: string): string => { export const clearWalletCache = () => { (Object.keys(cachedWallets) as TSupportedLazyInitWalletNetworks[]).forEach((network) => { + for (const w of Object.values(cachedWallets[network])) { + // Best-effort dispose. Currently only RgbWallet has a dispose() (clears + // the deferred prepareWallet timer); others can opt in later. + const d = (w as unknown as { dispose?: () => void }).dispose; + if (typeof d === 'function') { + try { + d.call(w); + } catch { + /* swallow — dispose is best-effort */ + } + } + } cachedWallets[network] = {}; }); }; diff --git a/tasks/ship-rgb.md b/tasks/ship-rgb.md index 8f32efb4e..6eb3f7c76 100644 --- a/tasks/ship-rgb.md +++ b/tasks/ship-rgb.md @@ -85,6 +85,21 @@ the typed errors during restore (not on first RGB tap). Skip is allowed; subsequent inits still fail with the same typed error so the safety net isn't bypassed. +## Future TODO: ext context for RGB + +Today RGB runs in the **popup window context** (`ext/src/modules/rgb-adapter.ts` +imported only from `Popup.tsx`; `background-message-controller.ts:90-91` +explicitly throws if RGB is hit from the SW). When the user closes the popup, +the JS context dies — any long-running RGB op (transfer broadcast, large sync, +VSS roundtrip) is aborted mid-flight. + +Won't fix `rgb-sdk-web#7` (the wasm panic is in rgb-lib's Rust, host-independent; +same bytecode panics in popup / SW / offscreen). But once #7 lands, we should +move RGB to an **offscreen document** (Chrome MV3 offscreen API) — survives +popup close, full DOM/IDB access, no MV3 service worker idle-kill. Service +worker by itself isn't the right target (30s idle eviction in MV3 makes it +worse than popup for state retention). + ## Verification quick-resume Test seed (mobile only — has TEST asset issued):