diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..e824c97 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,22 @@ +# Workflows + +## publish.yml — Publish to npm + +Builds and publishes `@utexo/rgb-sdk` to npm. + +- **Trigger**: manual +- **What it does**: builds the package with `tsup` and runs `npm publish --access public` + +## regtest-e2e.yml — Regtest E2E Tests + +Runs the full offline-receiver test suite (18 tests) against a local regtest environment. + +- **Trigger**: manual (`workflow_dispatch`) +- **What it does**: + 1. Clones `dcorral/test-rgb-proxy-playground` (Docker stack: bitcoind + electrs + rgb-proxy) + 2. Starts the stack on `localhost` (proxy :3000, electrs :50001) + 3. Runs `npm run test:regtest` — Jest tests from `tests/regtest/` + 4. On failure, uploads `artifacts/` (JSON smoke reports) for debugging + 5. Tears down the Docker stack +- **No secrets needed** — all credentials are hardcoded defaults from the playground +- **Test coverage**: blind/witness receive, relay-only mode, ACK guards, parallel sends, proxy restarts, expiry, donation paths diff --git a/.github/workflows/regtest-e2e.yml b/.github/workflows/regtest-e2e.yml new file mode 100644 index 0000000..f61ff97 --- /dev/null +++ b/.github/workflows/regtest-e2e.yml @@ -0,0 +1,88 @@ +name: Regtest E2E Tests + +on: + workflow_dispatch: + +jobs: + regtest: + name: Regtest E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout rgb-sdk + uses: actions/checkout@v4 + + - name: Checkout test playground + uses: actions/checkout@v4 + with: + repository: dcorral/test-rgb-proxy-playground + path: playground + submodules: recursive + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Start regtest stack + working-directory: playground + run: | + ./regtest.sh start + echo "Waiting for services to be ready..." + sleep 15 + + - name: Verify stack is healthy + run: | + curl -sf http://localhost:3000/json-rpc -X POST \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server.info","params":{}}' \ + && echo "Proxy is up" || echo "Proxy not responding yet" + nc -z localhost 50001 && echo "Electrs is up" || echo "Electrs not responding yet" + + - name: Run regtest E2E tests + env: + REGTEST_PROXY_HTTP_URL: http://localhost:3000/json-rpc + REGTEST_PROXY_RPC_URL: rpc://localhost:3000/json-rpc + REGTEST_BITCOIND_CONTAINER: bitcoind + REGTEST_BITCOIND_USER: user + REGTEST_BITCOIND_PASS: password + REGTEST_INDEXER_URL: tcp://localhost:50001 + REGTEST_DATA_DIR: /tmp/rgb-e2e + REGTEST_PLAYGROUND_COMPOSE_FILE: ${{ github.workspace }}/playground/docker-compose.yaml + run: npm run test:regtest 2>&1 | tee /tmp/test-output.txt + + - name: Generate summary + if: always() + run: | + echo "## Regtest E2E Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if grep -q "Tests:.*passed" /tmp/test-output.txt 2>/dev/null; then + SUITES=$(grep "Test Suites:" /tmp/test-output.txt | tail -1 | sed 's/Test Suites: //') + TESTS=$(grep "Tests:" /tmp/test-output.txt | tail -1 | sed 's/Tests: *//') + TIME=$(grep "Time:" /tmp/test-output.txt | tail -1 | sed 's/Time: *//') + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **Test Suites** | $SUITES |" >> $GITHUB_STEP_SUMMARY + echo "| **Tests** | $TESTS |" >> $GITHUB_STEP_SUMMARY + echo "| **Time** | $TIME |" >> $GITHUB_STEP_SUMMARY + else + echo "> Could not parse test results. Check logs." >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload smoke reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: regtest-smoke-reports + path: artifacts/ + retention-days: 7 + if-no-files-found: ignore + + - name: Stop regtest stack + if: always() + working-directory: playground + run: ./regtest.sh stop diff --git a/.gitignore b/.gitignore index 866cd82..d6b3ee0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ dist/ scripts/ package.docs.json cli/data/* -.rgb-wallet/ \ No newline at end of file +!cli/data/*.example.json +.rgb-wallet/ +artifacts/ diff --git a/Readme.md b/Readme.md index d681257..f48b9db 100644 --- a/Readme.md +++ b/Readme.md @@ -4,14 +4,7 @@ This is the underlying SDK used by RGB client applications. It provides a complete set of TypeScript/Node.js bindings for managing RGB-based transfers using **rgb-protocol libraries** -⚠️ **Security Notice** -If you're migrating from the legacy `rgb-sdk` (which relied on a remote RGB Node server), be aware that wallet metadata such as xpubs may have been exposed and this cannot be undone. - -If you're upgrading from `rgb-sdk` to `@utexo/rgb-sdk`, see the **[Migration Guide](./MIGRATION.md)** for step-by-step instructions on moving your wallet state to local storage. - -For full details on security implications and recommended actions, please read **[SECURITY.md](./SECURITY.md)**. - -> **RGB Protocol**: This SDK uses the [`rgb-lib`](https://github.com/RGB-Tools/rgb-lib) binding library to interact with the RGB protocol. All operations are performed locally, providing full control over wallet data and operations. +**RGB Protocol**: This SDK uses the [`rgb-lib`](https://github.com/RGB-Tools/rgb-lib) binding library to interact with the RGB protocol. All operations are performed locally, providing full control over wallet data and operations. --- @@ -126,18 +119,20 @@ The SDK uses default endpoints for RGB transport and Bitcoin indexing. These are **Transport Endpoints** (RGB protocol communication): +- **UTEXO**: `rpcs://rgb-proxy-utexo.utexo.com/json-rpc` - **Mainnet**: `rpcs://rgb-proxy-mainnet.utexo.com/json-rpc` - **Testnet**: `rpcs://rgb-proxy-testnet3.utexo.com/json-rpc` - **Testnet4**: `rpcs://proxy.iriswallet.com/0.2/json-rpc` -- **Signet**: `rpcs://rgb-proxy-utexo.utexo.com/json-rpc` +- **Signet**: `rpcs://proxy.iriswallet.com/0.2/json-rpc` - **Regtest**: `rpcs://proxy.iriswallet.com/0.2/json-rpc` **Indexer URLs** (Bitcoin blockchain data): +- **UTEXO**: `https://esplora-api.utexo.com` - **Mainnet**: `ssl://electrum.iriswallet.com:50003` - **Testnet**: `ssl://electrum.iriswallet.com:50013` - **Testnet4**: `ssl://electrum.iriswallet.com:50053` -- **Signet**: `https://esplora-api.utexo.com` +- **Signet**: `ssl://electrum.iriswallet.com:50033` - **Regtest**: `tcp://regtest.thunderstack.org:50001` UTEXOWallet uses network (`testnet` / `mainnet`) that define indexer and transport endpoints internally. @@ -174,8 +169,6 @@ console.log('BTC balance:', balance); await wallet.dispose(); ``` ---- - ## Core Workflows ### Wallet Initialization diff --git a/cli/README.md b/cli/README.md index 784dcb9..a59ca76 100644 --- a/cli/README.md +++ b/cli/README.md @@ -35,7 +35,7 @@ utexo generate_keys mywallet testnet **Parameters:** - `wallet_name` (required) - Name for the wallet configuration file - `network` (optional) - Bitcoin network, defaults to `regtest` if not provided - - Options: `mainnet`, `testnet`, `testnet4`, `regtest`, `signet` + - Options: `mainnet`, `testnet`, `testnet4`, `regtest`, `utexo` **Output:** - Creates a JSON file in `cli/data/.json` containing: @@ -345,4 +345,4 @@ Scripts read and write wallet configs under `data/` automatically. - All scripts use ES modules (`.mjs` extension) - Scripts require the project to be built (`npm run build`) before use - Wallet files are stored in `cli/data/` as `.json` -- Wallet `network` from config (e.g. `regtest`, `signet`, `testnet`) is mapped to UTEXOWallet presets `mainnet` or `testnet` automatically +- Wallet `network` from config (e.g. `regtest`, `utexo`, `testnet`) is mapped to UTEXOWallet presets `mainnet` or `testnet` automatically diff --git a/cli/data/stage2-receiver.example.json b/cli/data/stage2-receiver.example.json new file mode 100644 index 0000000..bb07857 --- /dev/null +++ b/cli/data/stage2-receiver.example.json @@ -0,0 +1,11 @@ +{ + "walletName": "stage2-receiver", + "network": "signet", + "mnemonic": "REPLACE_WITH_12_OR_24_WORD_MNEMONIC", + "xpub": "REPLACE_WITH_XPUB", + "accountXpubVanilla": "REPLACE_WITH_ACCOUNT_XPUB_VANILLA", + "accountXpubColored": "REPLACE_WITH_ACCOUNT_XPUB_COLORED", + "masterFingerprint": "REPLACE_WITH_FINGERPRINT", + "xpriv": "REPLACE_WITH_XPRIV", + "createdAt": "REPLACE_WITH_ISO_TIMESTAMP" +} diff --git a/cli/data/stage2-sender.example.json b/cli/data/stage2-sender.example.json new file mode 100644 index 0000000..9f9d832 --- /dev/null +++ b/cli/data/stage2-sender.example.json @@ -0,0 +1,11 @@ +{ + "walletName": "stage2-sender", + "network": "signet", + "mnemonic": "REPLACE_WITH_12_OR_24_WORD_MNEMONIC", + "xpub": "REPLACE_WITH_XPUB", + "accountXpubVanilla": "REPLACE_WITH_ACCOUNT_XPUB_VANILLA", + "accountXpubColored": "REPLACE_WITH_ACCOUNT_XPUB_COLORED", + "masterFingerprint": "REPLACE_WITH_FINGERPRINT", + "xpriv": "REPLACE_WITH_XPRIV", + "createdAt": "REPLACE_WITH_ISO_TIMESTAMP" +} diff --git a/examples/create-utxos-asset.mjs b/examples/create-utxos-asset.mjs index f41e100..aa5ce6b 100644 --- a/examples/create-utxos-asset.mjs +++ b/examples/create-utxos-asset.mjs @@ -12,7 +12,7 @@ import { UTEXOWallet } from '../dist/index.mjs'; const NETWORK = 'testnet'; -const MNEMONIC = "drastic vacuum age family between general melody elbow ball very require pulp"; +const MNEMONIC = "tobacco dinner advice together repeat digital need cancel lift near blind cute"; // drastic vacuum age family between general melody elbow ball very require pulp // const MNEMONIC = process.env.MNEMONIC || 'apple deposit job second wear metal zebra target filter chunk pill dynamic'; // const MNEMONIC = process.env.MNEMONIC || 'famous hurt miss favorite pitch rich rude cricket fault hammer split guilt'; diff --git a/examples/transfer.mjs b/examples/transfer.mjs index 47853a8..490d38a 100644 --- a/examples/transfer.mjs +++ b/examples/transfer.mjs @@ -12,9 +12,9 @@ import { UTEXOWallet } from '../dist/index.mjs'; const NETWORK = 'testnet'; -const MNEMONIC_A = process.env.MNEMONIC_A || 'top reject between sugar rug pulse radar coffee kiss faculty pool vocal'; -const MNEMONIC_B = process.env.MNEMONIC_B || 'famous hurt miss favorite pitch rich rude cricket fault hammer split guilt'; -const ASSET_ID = process.env.ASSET_ID||'rgb:GE5hMbGS-TdK60Sf-V4TNAUM-zb0228l-4yi_qEh-PiMHLOg'; +const MNEMONIC_A = process.env.MNEMONIC_A || 'paddle smooth humble inherit reason basic brave clerk absorb later text that'; +const MNEMONIC_B = process.env.MNEMONIC_B || 'tobacco dinner advice together repeat digital need cancel lift near blind cute'; +const ASSET_ID = process.env.ASSET_ID||'rgb:4PhQDg98-kFPjSKO-HdbJOXo-IWt6P~a-HeVZA8L-A~tvBNU'; if (!ASSET_ID) { console.error('ASSET_ID is required (e.g. from create-utxos-asset.mjs output)'); process.exit(1); @@ -31,6 +31,10 @@ async function main() { try { await walletA.initialize(); await walletB.initialize(); + + await walletB.refreshWallet(); + await walletA.refreshWallet(); + console.log('Wallet A address:', await walletA.getAddress()); console.log('Wallet B address:', await walletB.getAddress()); @@ -63,10 +67,10 @@ async function main() { await walletB.refreshWallet(); await walletA.refreshWallet(); - const transfersA = await walletA.listTransfers(ASSET_ID); // sent stansfer should be settled - const transfersB = await walletB.listTransfers(ASSET_ID); // received transfer should be settled - console.log('Wallet A listTransfers:', transfersA.length, transfersA); - console.log('Wallet B listTransfers:', transfersB.length, transfersB); + // const transfersA = await walletA.listTransfers(ASSET_ID); // sent stansfer should be settled + // const transfersB = await walletB.listTransfers(ASSET_ID); // received transfer should be settled + // console.log('Wallet A listTransfers:', transfersA.length, transfersA); + // console.log('allet B listTransfers:', transfersB.length, transfersB); } finally { await walletA.dispose(); await walletB.dispose(); @@ -74,7 +78,6 @@ async function main() { console.log('Done.'); } - main() .then(() => process.exit(0)) .catch((err) => { diff --git a/examples/utexo-vss-backup-restore.mjs b/examples/utexo-vss-backup-restore.mjs index d118df1..3365913 100644 --- a/examples/utexo-vss-backup-restore.mjs +++ b/examples/utexo-vss-backup-restore.mjs @@ -10,7 +10,7 @@ import path from 'path'; import { UTEXOWallet, restoreUtxoWalletFromVss } from '../dist/index.mjs'; -const MNEMONIC = 'top reject between sugar rug pulse radar coffee kiss faculty pool vocal'; +const MNEMONIC = 'paddle smooth humble inherit reason basic brave clerk absorb later text that'; const TARGET_DIR = path.join(process.cwd(), 'restored-utexo-vss'); async function runVssBackup() { @@ -41,16 +41,16 @@ async function runVssRestore() { }); console.log('Restored directory:', restoredDir); - // const wallet = new UTEXOWallet(MNEMONIC, { dataDir: restoredDir, network: 'testnet' }); - // try { - // await wallet.initialize(); - // const address = await wallet.getAddress(); - // console.log('Restored wallet address:', address); - // const balance = await wallet.getBtcBalance(); - // console.log('BTC balance:', balance); - // } finally { - // await wallet.dispose(); - // } + const wallet = new UTEXOWallet(MNEMONIC, { dataDir: restoredDir, network: 'testnet' }); + try { + await wallet.initialize(); + const address = await wallet.getAddress(); + console.log('Restored wallet address:', address); + const balance = await wallet.getBtcBalance(); + console.log('BTC balance:', balance); + } finally { + await wallet.dispose(); + } } const runRestore = true; // true = run VSS restore instead of backup diff --git a/jest.config.js b/jest.config.js index 033b696..1f11a5b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,17 +21,17 @@ module.exports = { diagnostics: { ignoreCodes: [1343, 2351, 6059, 7016], // Ignore missing declaration file errors }, - isolatedModules: true, useESM: true, }, ], }, // Don't transform ESM modules - let Node.js handle them with experimental flags transformIgnorePatterns: [ - 'node_modules/(?!(@metamask|bitcoindevkit|@types)/)', + 'node_modules/(?!(@metamask|bitcoindevkit|@types|@noble)/)', ], moduleNameMapper: { '^@/(.*)$': '/src/$1', + '^@noble/hashes/sha2$': '/node_modules/@noble/hashes/sha2.js', }, collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts'], testTimeout: 30000, // Increased timeout for crypto operations diff --git a/jest.regtest.config.js b/jest.regtest.config.js new file mode 100644 index 0000000..6bbdbe0 --- /dev/null +++ b/jest.regtest.config.js @@ -0,0 +1,35 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/regtest/**/*.test.ts'], + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'node', + esModuleInterop: true, + allowSyntheticDefaultImports: true, + skipLibCheck: true, + }, + diagnostics: { + ignoreCodes: [1343, 2351, 6059, 7016], + }, + useESM: true, + }, + ], + }, + transformIgnorePatterns: [ + 'node_modules/(?!(@metamask|bitcoindevkit|@types)/)', + ], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + testTimeout: 120000, + setupFilesAfterEnv: ['/tests/setup.ts'], +}; diff --git a/jest.signet.config.js b/jest.signet.config.js new file mode 100644 index 0000000..2cda230 --- /dev/null +++ b/jest.signet.config.js @@ -0,0 +1,35 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/signet/**/*.test.ts'], + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + target: 'ES2020', + module: 'ESNext', + moduleResolution: 'node', + esModuleInterop: true, + allowSyntheticDefaultImports: true, + skipLibCheck: true, + }, + diagnostics: { + ignoreCodes: [1343, 2351, 6059, 7016], + }, + useESM: true, + }, + ], + }, + transformIgnorePatterns: [ + 'node_modules/(?!(@metamask|bitcoindevkit|@types)/)', + ], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + testTimeout: 600000, + setupFilesAfterEnv: ['/tests/setup.ts'], +}; diff --git a/package.json b/package.json index a68893e..72f02a6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,16 @@ "dev": "tsc -w", "build": "tsup", "test": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest", + "test:signet": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.signet.config.js --runInBand --verbose", + "test:signet:smoke": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.signet.config.js --runInBand --verbose --runTestsByPath tests/signet/offline-receiver-smoke.test.ts", + "test:regtest": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose", + "test:regtest:relay-only": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/relay-only-mode.test.ts", + "test:regtest:upload-guard": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/upload-guard.test.ts", + "test:regtest:smoke": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/offline-receiver.test.ts", + "test:regtest:offline-delayed-refresh": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/offline-receiver-delayed-refresh.test.ts", + "test:regtest:ack-guard": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/ack-guard.test.ts", + "test:regtest:witness": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/witness-receiver.test.ts", + "test:regtest:nack": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/invalid-consignment.test.ts", "test:watch": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --watch", "test:coverage": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --coverage", "version:patch": "npm version patch && git push && git push --tags && npm publish --access public", @@ -41,7 +51,24 @@ "example:vss": "node examples/utexo-vss-backup-restore.mjs", "example:file-backup": "node examples/utexo-file-backup-restore.mjs", "utexo": "node cli/run.mjs", - "generate_keys": "node cli/generate_keys.mjs" + "generate_keys": "node cli/generate_keys.mjs", + "test:signet:witness": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.signet.config.js --runInBand --verbose --runTestsByPath tests/signet/witness-receiver-smoke.test.ts", + "test:signet:convergence": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.signet.config.js --runInBand --verbose --runTestsByPath tests/signet/offline-receiver-refresh-convergence.test.ts", + "test:signet:two-refresh": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.signet.config.js --runInBand --verbose --runTestsByPath tests/signet/offline-receiver-two-refresh-convergence.test.ts", + "test:signet:sequential": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.signet.config.js --runInBand --verbose --runTestsByPath tests/signet/sequential-receives-smoke.test.ts", + "test:regtest:preconfirm": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/pre-confirmation-gating.test.ts", + "test:regtest:relay-witness": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/relay-only-witness-mode.test.ts", + "test:regtest:roundtrip": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/real-consignment-roundtrip.test.ts", + "test:regtest:expired": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/expired-invoice.test.ts", + "test:regtest:donation-false": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/donation-false.test.ts", + "test:regtest:proxy-down": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/proxy-down-during-send.test.ts", + "test:regtest:restart-mid": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/restart-mid-flow.test.ts", + "test:regtest:send-batch": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/send-batch-same-receiver.test.ts", + "test:regtest:sequential-sends": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/sequential-sends-same-receiver.test.ts", + "test:regtest:restart-after-ack": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/restart-after-ack-before-settled.test.ts", + "test:regtest:expiry-race": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/expiry-race-near-boundary.test.ts", + "test:regtest:witness-donation-false": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/witness-donation-false.test.ts", + "test:regtest:sequential-receives": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --config jest.regtest.config.js --runInBand --verbose --runTestsByPath tests/regtest/sequential-receives-same-wallet.test.ts" }, "files": [ "dist", @@ -60,22 +87,20 @@ "types": "./dist/index.d.ts", "dependencies": { "@bitcoindevkit/bdk-wallet-node": "^0.2.0", - "@bitcoindevkit/bdk-wallet-web": "^0.2.0", - "@bitcoinerlab/secp256k1": "^1.2.0", - "@noble/hashes": "^2.0.1", +"@noble/hashes": "^2.0.1", + "@scure/btc-signer": "^2.0.1", + "@utexo/rgb-lib": "0.3.0-beta.13", + "@utexo/rgb-sdk-core": "1.0.0-beta.2", "axios": "^1.8.4", "bare-node-runtime": "^1.1.4", - "bip32": "^5.0.0-rc.0", - "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.7", "form-data": "^4.0.0", - "@utexo/rgb-lib": "0.3.0-beta.13", "ripemd160": "^2.0.3" }, "optionalDependencies": { - "@utexo/rgb-lib-linux-x64": "0.3.0-beta.13", + "@utexo/rgb-lib-darwin-arm64": "0.3.0-beta.13", "@utexo/rgb-lib-linux-arm64": "0.3.0-beta.13", - "@utexo/rgb-lib-darwin-arm64": "0.3.0-beta.13" + "@utexo/rgb-lib-linux-x64": "0.3.0-beta.13" }, "keywords": [ "rgb", @@ -96,4 +121,4 @@ "trailingComma": "es5", "useTabs": false } -} \ No newline at end of file +} diff --git a/src/client/rgb-lib-client.ts b/src/binding/NodeRgbLibBinding.ts similarity index 77% rename from src/client/rgb-lib-client.ts rename to src/binding/NodeRgbLibBinding.ts index c9e99de..bdd337f 100644 --- a/src/client/rgb-lib-client.ts +++ b/src/binding/NodeRgbLibBinding.ts @@ -1,8 +1,9 @@ /** - * RGB Lib Client - Local client using rgb-lib directly instead of HTTP server + * NodeRgbLibBinding — Node SDK implementation of IRgbLibBinding. * - * This client provides the same interface as RGBClient but uses rgb-lib locally - * without requiring an RGB Node server. + * Wraps the @utexo/rgb-lib napi binding, translates raw rgb-lib types to + * canonical wallet-model.ts types, and satisfies IRgbLibBinding so it can + * be injected directly into BaseWalletManager. */ import * as path from 'path'; @@ -10,13 +11,21 @@ import * as fs from 'fs'; import { DEFAULT_TRANSPORT_ENDPOINTS, DEFAULT_INDEXER_URLS, -} from './network-config'; -import { Unspent, DecodeRgbInvoiceResponse } from '../types/rgb-model'; -import { ValidationError, WalletError } from '../errors'; -import { normalizeNetwork } from '../utils/validation'; +} from '@utexo/rgb-sdk-core'; +import { + Unspent as RawUnspent, + DecodeRgbInvoiceResponse, +} from '../types/rgb-model'; +import { + ValidationError, + WalletError, + normalizeNetwork, + logger, +} from '@utexo/rgb-sdk-core'; import type { Network } from '../crypto/types'; // Use default import for CommonJS compatibility in ESM import rgblib from '@utexo/rgb-lib'; +import type { IRgbLibBinding } from '@utexo/rgb-sdk-core'; import { Transfer, Transaction, @@ -25,6 +34,8 @@ import { AssetIfa, AssetNIA, BtcBalance, + Unspent, + InvoiceData, CreateUtxosEndRequestModel, SendAssetBeginRequestModel, CreateUtxosBeginRequestModel, @@ -46,7 +57,11 @@ import { RecipientMap, VssBackupConfig, VssBackupInfo, -} from '../types/wallet-model'; + AssignmentType, + Assignment, + AssetSchema, + BitcoinNetwork, +} from '@utexo/rgb-sdk-core'; /** * Map network from client format to rgb-lib format */ @@ -56,6 +71,7 @@ function mapNetworkToRgbLib(network: string): string { testnet: 'Testnet', testnet4: 'Testnet4', signet: 'Signet', + utexo: 'Signet', regtest: 'Regtest', }; const networkStr = String(network).toLowerCase(); @@ -138,7 +154,7 @@ export const restoreFromVss = (params: { /** * RGB Lib Client class - Local implementation using rgb-lib */ -export class RGBLibClient { +export class NodeRgbLibBinding implements IRgbLibBinding { private wallet: any; private online: any | null = null; private readonly xpubVan: string; @@ -201,7 +217,6 @@ export class RGBLibClient { try { this.wallet = new rgblib.Wallet(new rgblib.WalletData(walletData)); } catch (error) { - console.log('error', error); throw new WalletError( 'Failed to initialize rgb-lib wallet', undefined, @@ -247,21 +262,50 @@ export class RGBLibClient { }; } - getBtcBalance(): BtcBalance { + async getBtcBalance(): Promise { const online = this.getOnline(); return this.wallet.getBtcBalance(online, false); } - getAddress(): string { + async getAddress(): Promise { return this.wallet.getAddress(); } - listUnspents(): Unspent[] { + listUnspents(): Promise { const online = this.getOnline(); - return this.wallet.listUnspents(online, false, false); + const raw: RawUnspent[] = this.wallet.listUnspents(online, false, false); + return Promise.resolve( + raw.map((unspent) => ({ + utxo: { + ...unspent.utxo, + exists: (unspent.utxo as any).exists ?? true, + }, + rgbAllocations: unspent.rgbAllocations.map((allocation) => { + const assignmentKeys = Object.keys(allocation.assignment); + const assignmentType = assignmentKeys[0] as + | AssignmentType + | undefined; + const assignment: Assignment = { + type: assignmentType ?? 'Any', + amount: + assignmentType && allocation.assignment[assignmentType] + ? Number(allocation.assignment[assignmentType]) + : undefined, + }; + return { + assetId: allocation.assetId, + assignment, + settled: allocation.settled, + }; + }), + pendingBlinded: (unspent as any).pendingBlinded ?? 0, + })) + ); } - createUtxosBegin(params: CreateUtxosBeginRequestModel): string { + async createUtxosBegin( + params: CreateUtxosBeginRequestModel + ): Promise { const online = this.getOnline(); const upTo = params.upTo ?? false; const num = params.num !== undefined ? String(params.num) : null; @@ -279,7 +323,7 @@ export class RGBLibClient { ); } - createUtxosEnd(params: CreateUtxosEndRequestModel): number { + async createUtxosEnd(params: CreateUtxosEndRequestModel): Promise { const online = this.getOnline(); const signedPsbt = params.signedPsbt; const skipSync = params.skipSync ?? false; @@ -287,7 +331,7 @@ export class RGBLibClient { return this.wallet.createUtxosEnd(online, signedPsbt, skipSync); } - sendBegin(params: SendAssetBeginRequestModel): string { + async sendBegin(params: SendAssetBeginRequestModel): Promise { const online = this.getOnline(); const feeRate = String(params.feeRate ?? 1); const minConfirmations = String(params.minConfirmations ?? 1); @@ -308,8 +352,7 @@ export class RGBLibClient { }; } if (params.invoice) { - const invoiceStr = params.invoice; - const invoiceData = this.decodeRGBInvoice({ invoice: invoiceStr }); + const invoiceData = this.decodeRGBInvoiceRaw({ invoice: params.invoice }); recipientId = invoiceData.recipientId; transportEndpoints = invoiceData.transportEndpoints; } @@ -364,12 +407,12 @@ export class RGBLibClient { /** * Batch send: accepts an already-built recipientMap and calls sendBegin. */ - sendBeginBatch(params: { + async sendBeginBatch(params: { recipientMap: RecipientMap; feeRate?: number; minConfirmations?: number; donation?: boolean; - }): string { + }): Promise { const online = this.getOnline(); const feeRate = String(params.feeRate ?? 1); const minConfirmations = String(params.minConfirmations ?? 1); @@ -410,7 +453,7 @@ export class RGBLibClient { return psbt; } - sendEnd(params: SendAssetEndRequestModel): SendResult { + async sendEnd(params: SendAssetEndRequestModel): Promise { const online = this.getOnline(); const signedPsbt = params.signedPsbt; const skipSync = params.skipSync ?? false; @@ -418,7 +461,7 @@ export class RGBLibClient { return this.wallet.sendEnd(online, signedPsbt, skipSync); } - sendBtcBegin(params: SendBtcBeginRequestModel): string { + async sendBtcBegin(params: SendBtcBeginRequestModel): Promise { const online = this.getOnline(); const address = params.address; const amount = String(params.amount); @@ -428,7 +471,7 @@ export class RGBLibClient { return this.wallet.sendBtcBegin(online, address, amount, feeRate, skipSync); } - sendBtcEnd(params: SendBtcEndRequestModel): string { + async sendBtcEnd(params: SendBtcEndRequestModel): Promise { const online = this.getOnline(); const signedPsbt = params.signedPsbt; const skipSync = params.skipSync ?? false; @@ -436,9 +479,9 @@ export class RGBLibClient { return this.wallet.sendBtcEnd(online, signedPsbt, skipSync); } - getFeeEstimation( + async getFeeEstimation( params: GetFeeEstimationRequestModel - ): GetFeeEstimationResponse { + ): Promise { const online = this.getOnline(); const blocks = String(params.blocks); try { @@ -452,55 +495,74 @@ export class RGBLibClient { } return result; } catch (_error) { - console.warn( + logger.warn( 'rgb-lib estimation fee are not available, using default fee rate 2' ); - return 2 as GetFeeEstimationResponse; // return default fee rate 4 when lib estimation fee error + return 2 as GetFeeEstimationResponse; } } - blindReceive(params: InvoiceRequest): InvoiceReceiveData { + async blindReceive(params: InvoiceRequest): Promise { const assetId = params.assetId || null; const assignment = `{"Fungible":${params.amount}}`; const durationSeconds = String(params.durationSeconds ?? 2000); const transportEndpoints: string[] = [this.transportEndpoint]; const minConfirmations = String(params.minConfirmations ?? 3); - return this.wallet.blindReceive( + const raw = this.wallet.blindReceive( assetId, assignment, durationSeconds, transportEndpoints, minConfirmations ); + return { + invoice: raw.invoice, + recipientId: raw.recipientId, + expirationTimestamp: raw.expirationTimestamp ?? null, + batchTransferIdx: raw.batchTransferIdx, + }; } - witnessReceive(params: InvoiceRequest): InvoiceReceiveData { + async witnessReceive(params: InvoiceRequest): Promise { const assetId = params.assetId || null; const assignment = `{"Fungible":${params.amount}}`; const durationSeconds = String(params.durationSeconds ?? 2000); const transportEndpoints: string[] = [this.transportEndpoint]; const minConfirmations = String(params.minConfirmations ?? 3); - return this.wallet.witnessReceive( + const raw = this.wallet.witnessReceive( assetId, assignment, durationSeconds, transportEndpoints, minConfirmations ); + return { + invoice: raw.invoice, + recipientId: raw.recipientId, + expirationTimestamp: raw.expirationTimestamp ?? null, + batchTransferIdx: raw.batchTransferIdx, + }; } - getAssetBalance(asset_id: string): AssetBalance { - return this.wallet.getAssetBalance(asset_id); + async getAssetBalance(asset_id: string): Promise { + const balance = this.wallet.getAssetBalance(asset_id); + return { + settled: balance.settled ?? 0, + future: balance.future ?? 0, + spendable: balance.spendable ?? 0, + offchainOutbound: balance.offchainOutbound ?? 0, + offchainInbound: balance.offchainInbound ?? 0, + }; } - issueAssetNia(params: { + async issueAssetNia(params: { ticker: string; name: string; amounts: number[]; precision: number; - }): AssetNIA { + }): Promise { const ticker = params.ticker; const name = params.name; const precision = String(params.precision); @@ -508,41 +570,34 @@ export class RGBLibClient { return this.wallet.issueAssetNIA(ticker, name, precision, amounts); } - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unused-vars - issueAssetIfa(params: IssueAssetIfaRequestModel): AssetIfa { + async issueAssetIfa(_params: IssueAssetIfaRequestModel): Promise { throw new ValidationError( 'issueAssetIfa is not fully supported in rgb-lib. Use RGB Node server for IFA assets.', 'asset' ); } - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unused-vars - inflateBegin(params: InflateAssetIfaRequestModel): string { + + async inflateBegin(_params: InflateAssetIfaRequestModel): Promise { throw new ValidationError( 'inflateBegin is not fully supported in rgb-lib. Use RGB Node server for inflation operations.', 'asset' ); } - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unused-vars - inflateEnd(params: InflateEndRequestModel): OperationResult { + + async inflateEnd(_params: InflateEndRequestModel): Promise { throw new ValidationError( 'inflateEnd is not fully supported in rgb-lib. Use RGB Node server for inflation operations.', 'asset' ); } - listAssets(): ListAssets { + async listAssets(): Promise { const filterAssetSchemas: string[] = []; return this.wallet.listAssets(filterAssetSchemas); } - decodeRGBInvoice(params: { invoice: string }): DecodeRgbInvoiceResponse { - const invoiceString = params.invoice; - - const invoice = new rgblib.Invoice(invoiceString); - + decodeRGBInvoiceRaw(params: { invoice: string }): DecodeRgbInvoiceResponse { + const invoice = new rgblib.Invoice(params.invoice); try { return invoice.invoiceData(); } finally { @@ -550,14 +605,37 @@ export class RGBLibClient { } } + async decodeRGBInvoice(params: { invoice: string }): Promise { + const raw = this.decodeRGBInvoiceRaw(params); + const assignmentKeys = Object.keys(raw.assignment); + const assignmentType = assignmentKeys[0] as AssignmentType | undefined; + const assignment: Assignment = { + type: assignmentType ?? 'Any', + amount: + assignmentType && raw.assignment[assignmentType] + ? Number(raw.assignment[assignmentType]) + : undefined, + }; + return { + invoice: params.invoice, + recipientId: raw.recipientId, + assetSchema: raw.assetSchema as AssetSchema | undefined, + assetId: raw.assetId, + network: raw.network as BitcoinNetwork, + assignment, + assignmentName: raw.assignmentName, + expirationTimestamp: raw.expirationTimestamp ?? null, + transportEndpoints: raw.transportEndpoints, + }; + } + refreshWallet(): void { const online = this.getOnline(); const assetId = null; const filter: string[] = []; const skipSync = false; - const result = this.wallet.refresh(online, assetId, filter, skipSync); - console.log('refresh state:', JSON.stringify(result, null, 2)); + return this.wallet.refresh(online, assetId, filter, skipSync); } dropWallet(): void { @@ -571,17 +649,17 @@ export class RGBLibClient { } } - listTransactions(): Transaction[] { + async listTransactions(): Promise { const online = this.getOnline(); const skipSync = false; return this.wallet.listTransactions(online, skipSync); } - listTransfers(asset_id?: string): Transfer[] { + async listTransfers(asset_id?: string): Promise { return this.wallet.listTransfers(asset_id ? asset_id : null); } - failTransfers(params: FailTransfersRequest): boolean { + async failTransfers(params: FailTransfersRequest): Promise { const online = this.getOnline(); const batchTransferIdx = params.batchTransferIdx !== undefined ? params.batchTransferIdx : null; @@ -612,10 +690,10 @@ export class RGBLibClient { this.wallet.sync(online); } - createBackup(params: { + async createBackup(params: { backupPath: string; password: string; - }): WalletBackupResponse { + }): Promise { if (!params.backupPath) { throw new ValidationError('backupPath is required', 'backupPath'); } @@ -708,7 +786,7 @@ export class RGBLibClient { * Trigger a VSS backup immediately using a one-off client created from config. * Returns the server version of the stored backup. */ - vssBackup(config: VssBackupConfig): number { + async vssBackup(config: VssBackupConfig): Promise { const { walletAny, anyLib } = this.getVssBindingsOrThrow('vssBackup'); const client = new anyLib.VssBackupClient({ @@ -730,7 +808,7 @@ export class RGBLibClient { /** * Get VSS backup status information for this wallet using a one-off client. */ - vssBackupInfo(config: VssBackupConfig): VssBackupInfo { + async vssBackupInfo(config: VssBackupConfig): Promise { const { walletAny, anyLib } = this.getVssBindingsOrThrow('vssBackupInfo'); const client = new anyLib.VssBackupClient({ diff --git a/src/client/index.ts b/src/client/index.ts deleted file mode 100644 index b704114..0000000 --- a/src/client/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Client module exports - * - * This module contains RGB Lib client - */ - -export { - RGBLibClient, - generateKeys, - restoreWallet, - restoreFromVss, - type RgbLibGeneratedKeys, -} from './rgb-lib-client'; diff --git a/src/client/network-config.ts b/src/client/network-config.ts deleted file mode 100644 index 5d933a9..0000000 --- a/src/client/network-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Network } from '../crypto/types'; - -export const DEFAULT_TRANSPORT_ENDPOINTS: Record = { - mainnet: 'rpcs://rgb-proxy-mainnet.utexo.com/json-rpc', - testnet: 'rpcs://rgb-proxy-testnet3.utexo.com/json-rpc', - testnet4: 'rpcs://proxy.iriswallet.com/0.2/json-rpc', - signet: 'rpcs://rgb-proxy-utexo.utexo.com/json-rpc', - regtest: 'rpcs://proxy.iriswallet.com/0.2/json-rpc', -}; - -export const DEFAULT_INDEXER_URLS: Record = { - mainnet: 'ssl://electrum.iriswallet.com:50003', - testnet: 'ssl://electrum.iriswallet.com:50013', - testnet4: 'ssl://electrum.iriswallet.com:50053', - signet: 'https://esplora-api.utexo.com', - regtest: 'tcp://regtest.thunderstack.org:50001', -}; diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index a4912d1..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Shared constants for the RGB SDK - * - * This file re-exports constants from the organized constants directory - * for backward compatibility and convenience. - * - * @deprecated Import from './constants' directly or use domain-specific exports - * @see ./constants/derivation - * @see ./constants/network - * @see ./constants/defaults - */ - -// Re-export all constants from organized structure -export * from './constants/index'; diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts deleted file mode 100644 index d3e5f32..0000000 --- a/src/constants/defaults.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Default configuration values - */ - -/** - * Default network to use - */ -export const DEFAULT_NETWORK = 'regtest' as const; - -/** - * Default API request timeout in milliseconds - */ -export const DEFAULT_API_TIMEOUT = 120000; // 30 seconds - -/** - * Default maximum number of retries for failed requests - */ -export const DEFAULT_MAX_RETRIES = 3; - -/** - * Default log level - */ -export const DEFAULT_LOG_LEVEL = 3; // ERROR level diff --git a/src/constants/derivation.ts b/src/constants/derivation.ts deleted file mode 100644 index b71cda3..0000000 --- a/src/constants/derivation.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * BIP32 derivation path constants - */ - -/** - * BIP86 (Taproot) purpose value - */ -export const DERIVATION_PURPOSE = 86; - -/** - * Account index (account 0') - */ -export const DERIVATION_ACCOUNT = 0; - -/** - * RGB keychain index - */ -export const KEYCHAIN_RGB = 0; - -/** - * Bitcoin keychain index - */ -export const KEYCHAIN_BTC = 0; diff --git a/src/constants/index.ts b/src/constants/index.ts deleted file mode 100644 index da70a59..0000000 --- a/src/constants/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Centralized constants for the RGB SDK - * - * All constants are organized by domain for better maintainability - */ - -// Derivation constants -export * from './derivation'; - -// Network constants -export * from './network'; - -// Default values -export * from './defaults'; diff --git a/src/constants/network.ts b/src/constants/network.ts deleted file mode 100644 index 7c2917a..0000000 --- a/src/constants/network.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Network-related constants - */ - -import type { Network } from '../crypto/types'; - -// Re-export UTEXO network mapping from utexo/utils (canonical location) -export { - utexoNetworkMap, - utexoNetworkIdMap, - getDestinationAsset, - getUtxoNetworkConfig, - type NetworkAsset, - type UtxoNetworkId, - type UtxoNetworkPreset, - type UtxoNetworkMap, - type UtxoNetworkIdMap, - type UtxoNetworkPresetConfig, -} from '../utexo/utils/network'; - -/** - * Coin type constants - */ -export const COIN_RGB_MAINNET = 827166; -export const COIN_RGB_TESTNET = 827167; -export const COIN_BITCOIN_MAINNET = 0; -export const COIN_BITCOIN_TESTNET = 1; - -/** - * Network string/number to Network type mapping - */ -export const NETWORK_MAP = { - '0': 'mainnet' as const, - '1': 'testnet' as const, - '2': 'testnet' as const, // Alternative testnet number (also maps to testnet) - '3': 'regtest' as const, - 'signet': 'signet' as const, - 'mainnet': 'mainnet' as const, - 'testnet': 'testnet' as const, - 'testnet4': 'testnet4' as const, - 'regtest': 'regtest' as const, -} as const; - -/** - * BIP32 network version constants - * Note: testnet4 uses the same versions as testnet - */ -export const BIP32_VERSIONS = { - mainnet: { - public: 0x0488b21e, - private: 0x0488ade4, - }, - testnet: { - public: 0x043587cf, - private: 0x04358394, - }, - testnet4: { - public: 0x043587cf, - private: 0x04358394, - }, - signet: { - public: 0x043587cf, - private: 0x04358394, - }, - regtest: { - public: 0x043587cf, - private: 0x04358394, - }, -} as const satisfies Record; diff --git a/src/crypto/dependencies.ts b/src/crypto/dependencies.ts deleted file mode 100644 index 75024e8..0000000 --- a/src/crypto/dependencies.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { isNode } from '../utils/environment'; -import type { - BIP39Module, - ECCModule, - BIP32Factory, - BitcoinJsPayments, - BitcoinJsNetworks, - BIP341Module, - BDKModule, - BDKInit, -} from './types'; - -type BaseDependencies = { - bip39: BIP39Module; - ecc: ECCModule; - factory: BIP32Factory; -}; - -export type SignerDependencies = BaseDependencies & { - Psbt: typeof import('bitcoinjs-lib').Psbt; - payments: BitcoinJsPayments; - networks: BitcoinJsNetworks; - toXOnly: (pubkey: Buffer) => Buffer; - bdk: BDKModule; - init: BDKInit; -}; - -let baseDeps: BaseDependencies | null = null; -let basePromise: Promise | null = null; - -async function loadBaseDependencies(): Promise { - if (isNode()) { - const nodeModule = 'node:' + 'module'; - const { createRequire } = await import(nodeModule); - // @ts-ignore - import.meta.url not available in CJS build context - const requireFromModule = createRequire(import.meta.url); - - const bip39 = requireFromModule('bip39') as unknown as BIP39Module; - const eccModule = requireFromModule( - '@bitcoinerlab/secp256k1' - ) as unknown as { default?: unknown }; - const ecc = - eccModule && typeof eccModule === 'object' && 'default' in eccModule - ? (eccModule.default as ECCModule) - : (eccModule as unknown as ECCModule); - const bip32 = requireFromModule('bip32') as unknown as { - BIP32Factory: BIP32Factory; - }; - - return { - bip39, - ecc, - factory: bip32.BIP32Factory, - }; - } - - const bip39Module = await import('bip39'); - const bip39 = - (bip39Module.default as BIP39Module) || - (bip39Module as unknown as BIP39Module); - - const eccModule = await import('@bitcoinerlab/secp256k1'); - const ecc = - (eccModule.default as ECCModule) || (eccModule as unknown as ECCModule); - - const bip32 = (await import('bip32')) as unknown as { - BIP32Factory: BIP32Factory; - }; - - return { - bip39, - ecc, - factory: bip32.BIP32Factory, - }; -} - -export async function ensureBaseDependencies(): Promise { - if (baseDeps) { - return baseDeps; - } - if (!basePromise) { - basePromise = loadBaseDependencies() - .then((deps) => { - baseDeps = deps; - basePromise = null; - return deps; - }) - .catch((error) => { - basePromise = null; - throw error; - }); - } - return basePromise; -} - -let signerDeps: SignerDependencies | null = null; -let signerPromise: Promise | null = null; - -async function loadSignerDependencies(): Promise { - const base = await ensureBaseDependencies(); - - if (isNode()) { - const bdkNode = await import('@bitcoindevkit/bdk-wallet-node'); - const init = - ((bdkNode as { default?: unknown }).default as BDKInit) || - ((bdkNode as { init?: unknown }).init as BDKInit) || - (bdkNode as unknown as BDKInit); - const bdk = bdkNode as unknown as BDKModule; - - const nodeModule = 'node:' + 'module'; - const { createRequire } = await import(nodeModule); - // @ts-ignore - import.meta.url not available in CJS build context - const requireFromModule = createRequire(import.meta.url); - - const bitcoinjs = requireFromModule('bitcoinjs-lib') as unknown as { - Psbt: typeof import('bitcoinjs-lib').Psbt; - payments: BitcoinJsPayments; - networks: BitcoinJsNetworks; - }; - const Psbt = bitcoinjs.Psbt; - const payments = bitcoinjs.payments; - const networks = bitcoinjs.networks; - - const bip341 = requireFromModule( - 'bitcoinjs-lib/src/payments/bip341.js' - ) as unknown as BIP341Module; - const toXOnly = - bip341.toXOnly || ((pubkey: Buffer) => Buffer.from(pubkey.slice(1))); - - return { - ...base, - Psbt, - payments, - networks, - toXOnly, - bdk, - init, - }; - } - - const bdkWeb = await import('@bitcoindevkit/bdk-wallet-web'); - const init = - ((bdkWeb as { default?: unknown }).default as BDKInit) || - ((bdkWeb as { init?: unknown }).init as BDKInit) || - (bdkWeb as unknown as BDKInit); - const bdk = bdkWeb as unknown as BDKModule; - - const bitcoinModule = (await import('bitcoinjs-lib')) as unknown as { - Psbt: typeof import('bitcoinjs-lib').Psbt; - payments: BitcoinJsPayments; - networks: BitcoinJsNetworks; - }; - const Psbt = bitcoinModule.Psbt; - const payments = bitcoinModule.payments; - const networks = bitcoinModule.networks; - - const bip341 = - (await import('bitcoinjs-lib/src/payments/bip341.js')) as unknown as BIP341Module; - const toXOnly = - bip341.toXOnly || ((pubkey: Buffer) => Buffer.from(pubkey.slice(1))); - - return { - ...base, - Psbt, - payments, - networks, - toXOnly, - bdk, - init, - }; -} - -export async function ensureSignerDependencies(): Promise { - if (signerDeps) { - return signerDeps; - } - if (!signerPromise) { - signerPromise = loadSignerDependencies() - .then((deps) => { - signerDeps = deps; - signerPromise = null; - return deps; - }) - .catch((error) => { - signerPromise = null; - throw error; - }); - } - return signerPromise; -} diff --git a/src/crypto/index.ts b/src/crypto/index.ts deleted file mode 100644 index ebdcf9c..0000000 --- a/src/crypto/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Crypto module exports - * - * This module contains RGB-specific cryptographic operations including: - * - PSBT signing for RGB transfers (create_utxo_begin and send_begin PSBTs) - * - RGB key generation and derivation (vanilla and colored keychains) - * - Network-related cryptographic operations for RGB protocol - * - BIP86 Taproot key derivation for RGB wallets - */ - -// Export signer functions -export { - signPsbt, - signPsbtSync, - signPsbtFromSeed, - signMessage, - verifyMessage, - estimatePsbt, -} from './signer'; -export type { - SignPsbtOptions, - SignMessageParams, - SignMessageResult, - VerifyMessageParams, - EstimateFeeResult, -} from './signer'; - -// Export key functions -export { - generateKeys, - deriveKeysFromMnemonic, - deriveKeysFromSeed, - deriveKeysFromMnemonicOrSeed, - restoreKeys, - accountXpubsFromMnemonic, - getXprivFromMnemonic, - getXpubFromXpriv, - deriveKeysFromXpriv, -} from './keys'; -export type { GeneratedKeys, AccountXpubs } from './keys'; - -export { deriveVssSigningKeyFromMnemonic } from './vss-keys'; - -// Export types -export type { Network, PsbtType, NetworkVersions, Descriptors } from './types'; diff --git a/src/crypto/keys.ts b/src/crypto/keys.ts deleted file mode 100644 index d4fe498..0000000 --- a/src/crypto/keys.ts +++ /dev/null @@ -1,636 +0,0 @@ -// Copyright 2024 Tether Operations Limited -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * RGB Key Generation and Derivation - * - * This module provides RGB-specific cryptographic key operations including: - * - RGB wallet key generation (vanilla and colored keychains) - * - BIP86 Taproot key derivation for RGB protocol - * - Account-level key derivation with RGB coin types - * - Master fingerprint calculation for RGB wallets - */ - -import type { BIP32Interface } from 'bip32'; -import type { Network } from './types'; -import { ValidationError, CryptoError } from '../errors'; -import { validateMnemonic, normalizeNetwork } from '../utils/validation'; -import { - DERIVATION_PURPOSE, - DERIVATION_ACCOUNT, - COIN_RGB_MAINNET, - COIN_RGB_TESTNET, -} from '../constants'; -import { calculateMasterFingerprint } from '../utils/fingerprint'; -import { - normalizeSeedBuffer, - toNetworkName, - getNetworkVersions, -} from '../utils/bip32-helpers'; -import { ensureBaseDependencies } from './dependencies'; - -export type SeedInput = string | Uint8Array; - -export function normalizeSeedInput( - seed: SeedInput, - field: string = 'seed' -): Uint8Array { - if (typeof seed === 'string') { - const trimmed = seed.trim(); - if (!trimmed) { - throw new ValidationError( - `${field} must be a non-empty hex string`, - field - ); - } - const hex = trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed; - if (hex.length % 2 !== 0) { - throw new ValidationError( - `${field} hex string must have even length`, - field - ); - } - if (hex.length !== 128) { - throw new ValidationError( - `${field} must be 64 bytes (128 hex characters)`, - field - ); - } - if (!/^[0-9a-fA-F]+$/.test(hex)) { - throw new ValidationError(`${field} must be a valid hex string`, field); - } - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - const byte = hex.slice(i * 2, i * 2 + 2); - bytes[i] = parseInt(byte, 16); - } - return bytes; - } - - if (seed instanceof Uint8Array) { - if (seed.length === 0) { - throw new ValidationError(`${field} must not be empty`, field); - } - return new Uint8Array(seed); - } - - throw new ValidationError( - `${field} must be a 64-byte hex string or Uint8Array`, - field - ); -} - -export interface GeneratedKeys { - mnemonic: string; - xpub: string; - accountXpubVanilla: string; - accountXpubColored: string; - masterFingerprint: string; - xpriv: string; -} - -export interface AccountXpubs { - account_xpub_vanilla: string; - account_xpub_colored: string; -} - -/** - * Get coin type for derivation path - */ -function getCoinType(bitcoinNetwork: string | number, rgb: boolean): number { - const net = toNetworkName(bitcoinNetwork); - if (rgb) return net === 'mainnet' ? COIN_RGB_MAINNET : COIN_RGB_TESTNET; - return net === 'mainnet' ? 0 : 1; -} - -/** - * Generate account derivation path: m / 86' / coinType' / 0' - */ -export function accountDerivationPath( - bitcoinNetwork: string | number, - rgb: boolean -): string { - const coinType = getCoinType(bitcoinNetwork, rgb); - return `m/${DERIVATION_PURPOSE}'/${coinType}'/${DERIVATION_ACCOUNT}'`; -} - -/** - * Calculate master fingerprint from BIP32 node - * Alias for shared fingerprint calculation utility - */ -async function masterFingerprintFromNode( - node: BIP32Interface -): Promise { - return calculateMasterFingerprint(node); -} - -/** - * Convert mnemonic to root BIP32 node - */ -async function mnemonicToRoot( - mnemonic: string, - bitcoinNetwork: string | number -): Promise { - const { bip39, ecc, factory } = await ensureBaseDependencies(); - - if (!bip39 || typeof bip39.mnemonicToSeedSync !== 'function') { - throw new CryptoError('bip39 module not loaded correctly'); - } - - const seedBuffer = normalizeSeedBuffer(bip39.mnemonicToSeedSync(mnemonic)); - const versions = getNetworkVersions(bitcoinNetwork); - const bip32 = factory(ecc); - - try { - return bip32.fromSeed(seedBuffer, versions); - } catch (error) { - throw new CryptoError( - `Failed to create BIP32 root node from seed: ${error instanceof Error ? error.message : String(error)}`, - error as Error - ); - } -} - -/** - * Get account extended public key from mnemonic - */ -async function getAccountXpub( - mnemonic: string, - bitcoinNetwork: string | number, - rgb: boolean -): Promise { - const root = await mnemonicToRoot(mnemonic, bitcoinNetwork); - const path = accountDerivationPath(bitcoinNetwork, rgb); - const acct = root.derivePath(path); - return acct.neutered().toBase58(); -} - -/** - * Get master extended private key (xpriv) from mnemonic - */ -async function getMasterXpriv( - mnemonic: string, - bitcoinNetwork: string | number -): Promise { - const root = await mnemonicToRoot(mnemonic, bitcoinNetwork); - return root.toBase58(); -} - -function deriveAccountXpubsFromRoot(root: BIP32Interface, network: Network) { - const vanillaPath = accountDerivationPath(network, false); - const coloredPath = accountDerivationPath(network, true); - - return { - account_xpub_vanilla: root.derivePath(vanillaPath).neutered().toBase58(), - account_xpub_colored: root.derivePath(coloredPath).neutered().toBase58(), - }; -} - -async function buildGeneratedKeysFromRoot( - root: BIP32Interface, - network: Network, - mnemonic: string -): Promise { - const xpub = root.neutered().toBase58(); - const xpriv = root.toBase58(); - const master_fingerprint = await masterFingerprintFromNode(root); - const { account_xpub_vanilla, account_xpub_colored } = - deriveAccountXpubsFromRoot(root, network); - - return { - mnemonic, - xpub, - accountXpubVanilla: account_xpub_vanilla, - accountXpubColored: account_xpub_colored, - masterFingerprint: master_fingerprint, - xpriv, - }; -} - -/** - * Get extended public key (xpub) from extended private key (xpriv) - * Internal helper function - */ -async function getXpubFromXprivInternal( - xpriv: string, - bitcoinNetwork?: string | number -): Promise { - const { ecc, factory } = await ensureBaseDependencies(); - - try { - // BIP32Factory is a factory function that returns BIP32 interface - // Use it to create a BIP32 instance from the xpriv - const bip32 = factory(ecc); - - // fromBase58 requires network versions for validation - // If network is not provided, try to infer from xpriv prefix (xprv/tprv for mainnet/testnet) - let node; - if (bitcoinNetwork) { - const versions = getNetworkVersions(bitcoinNetwork); - node = bip32.fromBase58(xpriv, versions); - } else { - // Try to infer network from xpriv prefix - // xprv = mainnet, tprv = testnet/regtest - const inferredNetwork = xpriv.startsWith('xprv') ? 'mainnet' : 'testnet'; - const versions = getNetworkVersions(inferredNetwork); - node = bip32.fromBase58(xpriv, versions); - } - - return node.neutered().toBase58(); - } catch (error) { - throw new CryptoError('Failed to derive xpub from xpriv', error as Error); - } -} - -/** - * Build complete keys output object from mnemonic - */ -async function buildKeysOutput( - mnemonic: string, - bitcoinNetwork: string | number -): Promise { - const normalizedNetwork = normalizeNetwork(bitcoinNetwork); - const root = await mnemonicToRoot(mnemonic, normalizedNetwork); - return buildGeneratedKeysFromRoot(root, normalizedNetwork, mnemonic); -} - -async function buildKeysOutputFromSeed( - seed: Uint8Array | Buffer, - bitcoinNetwork: string | number -): Promise { - const { ecc, factory } = await ensureBaseDependencies(); - const normalizedNetwork = normalizeNetwork(bitcoinNetwork); - const seedBuffer = normalizeSeedBuffer(seed); - const versions = getNetworkVersions(bitcoinNetwork); - const bip32 = factory(ecc); - - let root: BIP32Interface; - try { - root = bip32.fromSeed(seedBuffer, versions); - } catch (error) { - throw new CryptoError( - 'Failed to create BIP32 root node from seed', - error as Error - ); - } - - return buildGeneratedKeysFromRoot(root, normalizedNetwork, ''); -} - -/** - * Build complete keys output object from xpriv - */ -async function buildKeysOutputFromXpriv( - xpriv: string, - bitcoinNetwork: string | number -): Promise { - const { ecc, factory } = await ensureBaseDependencies(); - try { - // BIP32Factory is a factory function that returns BIP32 interface - const bip32 = factory(ecc); - - // Get network versions for validation - const normalizedNetwork = normalizeNetwork(bitcoinNetwork); - const versions = getNetworkVersions(bitcoinNetwork); - const root = bip32.fromBase58(xpriv, versions); - - return buildGeneratedKeysFromRoot(root, normalizedNetwork, ''); - } catch (error) { - throw new CryptoError('Failed to derive keys from xpriv', error as Error); - } -} - -/** - * Generate new wallet keys with a random mnemonic - * Mirrors rgb_lib::generate_keys (creates new 12-word mnemonic) - * - * @param bitcoinNetwork - Network string or number (default: 'regtest') - * @returns Promise resolving to generated keys including mnemonic, xpubs, and master fingerprint - * @throws {CryptoError} If key generation fails - * - * @example - * ```typescript - * const keys = await generateKeys('testnet'); - * console.log('Mnemonic:', keys.mnemonic); - * console.log('Master Fingerprint:', keys.master_fingerprint); - * ``` - */ -export async function generateKeys( - bitcoinNetwork: string | number = 'regtest' -): Promise { - try { - const { bip39 } = await ensureBaseDependencies(); - if (!bip39 || typeof (bip39 as any).generateMnemonic !== 'function') { - throw new Error( - 'bip39 not loaded. Dependencies may not have initialized correctly.' - ); - } - const mnemonic = (bip39 as any).generateMnemonic(128); - return await buildKeysOutput(mnemonic, bitcoinNetwork); - } catch (error) { - if (error instanceof Error && error.message.includes('bip39 not loaded')) { - throw new CryptoError('Failed to load dependencies', error); - } - // Log the actual error for debugging - const errorMessage = error instanceof Error ? error.message : String(error); - throw new CryptoError( - `Failed to generate mnemonic: ${errorMessage}`, - error as Error - ); - } -} - -/** - * Derive wallet keys from existing mnemonic - * Takes a mnemonic phrase and derives all keys (xpubs, master fingerprint) - * - * This function is the counterpart to `generateKeys()` - instead of generating - * a new mnemonic, it derives keys from an existing one. - * - * @param bitcoinNetwork - Network string or number (default: 'regtest') - * @param mnemonic - BIP39 mnemonic phrase - * @returns Promise resolving to derived keys including mnemonic, xpubs, and master fingerprint - * @throws {ValidationError} If mnemonic is invalid - * @throws {CryptoError} If key derivation fails - * - * @example - * ```typescript - * const keys = await deriveKeysFromMnemonic('testnet', 'abandon abandon abandon...'); - * console.log('Account XPub:', keys.account_xpub_vanilla); - * ``` - */ -export async function deriveKeysFromMnemonic( - bitcoinNetwork: string | number = 'regtest', - mnemonic: string -): Promise { - validateMnemonic(mnemonic, 'mnemonic'); - - const normalizedNetwork = normalizeNetwork(bitcoinNetwork); - - try { - const { bip39 } = await ensureBaseDependencies(); - const trimmedMnemonic = mnemonic.trim(); - if (!bip39 || !bip39.validateMnemonic(trimmedMnemonic)) { - throw new ValidationError( - 'Invalid mnemonic format - failed BIP39 validation', - 'mnemonic' - ); - } - - return await buildKeysOutput(trimmedMnemonic, normalizedNetwork); - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } - throw new CryptoError( - 'Failed to derive keys from mnemonic', - error as Error - ); - } -} - -/** - * Derive wallet keys directly from a BIP39 seed (hex string or Uint8Array) - */ -export async function deriveKeysFromSeed( - bitcoinNetwork: string | number = 'regtest', - seed: string | Uint8Array -): Promise { - const normalizedNetwork = normalizeNetwork(bitcoinNetwork); - - try { - const normalizedSeed = normalizeSeedInput(seed, 'seed'); - return await buildKeysOutputFromSeed(normalizedSeed, normalizedNetwork); - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } - throw new CryptoError('Failed to derive keys from seed', error as Error); - } -} - -/** - * Derive wallet keys from either a mnemonic phrase or seed - * Automatically detects the input type and uses the appropriate derivation method - * - * @param bitcoinNetwork - Network string or number (default: 'regtest') - * @param mnemonicOrSeed - Either a BIP39 mnemonic phrase (string) or seed (Uint8Array | string) - * @returns Promise resolving to derived keys including mnemonic, xpubs, and master fingerprint - * @throws {ValidationError} If mnemonic is invalid - * @throws {CryptoError} If key derivation fails - * - * @example - * ```typescript - * // With mnemonic - * const keys1 = await deriveKeysFromMnemonicOrSeed('testnet', 'abandon abandon abandon...'); - * - * // With seed (Uint8Array) - * const seed = new Uint8Array([...]); - * const keys2 = await deriveKeysFromMnemonicOrSeed('testnet', seed); - * ``` - */ -export async function deriveKeysFromMnemonicOrSeed( - bitcoinNetwork: string | number = 'regtest', - mnemonicOrSeed: string | Uint8Array -): Promise { - if (typeof mnemonicOrSeed === 'string') { - const trimmed = mnemonicOrSeed.trim(); - const words = trimmed.split(/\s+/); - const isLikelyMnemonic = - trimmed.includes(' ') && words.length >= 12 && words.length <= 24; - - if (isLikelyMnemonic) { - try { - return await deriveKeysFromMnemonic(bitcoinNetwork, trimmed); - } catch (error) { - if (error instanceof ValidationError) { - return await deriveKeysFromSeed(bitcoinNetwork, trimmed); - } - throw error; - } - } else { - return await deriveKeysFromSeed(bitcoinNetwork, trimmed); - } - } else { - return await deriveKeysFromSeed(bitcoinNetwork, mnemonicOrSeed); - } -} - -/** - * Restore wallet keys from existing mnemonic (backward compatibility alias) - * @deprecated Use `deriveKeysFromMnemonic()` instead. This alias will be removed in a future version. - * @see deriveKeysFromMnemonic - */ -export async function restoreKeys( - bitcoinNetwork: string | number = 'regtest', - mnemonic: string -): Promise { - return deriveKeysFromMnemonic(bitcoinNetwork, mnemonic); -} - -/** - * Get account xpubs from mnemonic (convenience function) - * - * @param bitcoinNetwork - Network string or number (default: 'regtest') - * @param mnemonic - BIP39 mnemonic phrase - * @returns Promise resolving to account xpubs for vanilla and colored keychains - * @throws {ValidationError} If mnemonic is invalid - * @throws {CryptoError} If key derivation fails - * - * @example - * ```typescript - * const xpubs = await accountXpubsFromMnemonic('testnet', 'abandon abandon abandon...'); - * console.log('Vanilla XPub:', xpubs.account_xpub_vanilla); - * console.log('Colored XPub:', xpubs.account_xpub_colored); - * ``` - */ -/** - * Get master extended private key (xpriv) from mnemonic - * - * @param bitcoinNetwork - Network string or number (default: 'regtest') - * @param mnemonic - BIP39 mnemonic phrase (12 or 24 words) - * @returns Promise resolving to master xpriv (extended private key) - * @throws {ValidationError} If mnemonic is invalid - * @throws {CryptoError} If key derivation fails - * - * @example - * ```typescript - * const xpriv = await getXprivFromMnemonic('testnet', 'your mnemonic phrase here'); - * console.log('Master xpriv:', xpriv); - * ``` - */ -export async function getXprivFromMnemonic( - bitcoinNetwork: string | number = 'regtest', - mnemonic: string -): Promise { - validateMnemonic(mnemonic, 'mnemonic'); - const normalizedNetwork = normalizeNetwork(bitcoinNetwork); - - try { - return await getMasterXpriv(mnemonic, normalizedNetwork); - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } - throw new CryptoError( - 'Failed to derive xpriv from mnemonic', - error as Error - ); - } -} - -/** - * Get extended public key (xpub) from extended private key (xpriv) - * - * @param xpriv - Extended private key (base58 encoded) - * @returns Promise resolving to xpub (extended public key) - * @throws {CryptoError} If xpriv is invalid or derivation fails - * - * @example - * ```typescript - * const xpub = await getXpubFromXpriv('xprv...'); - * console.log('xpub:', xpub); - * ``` - */ -export async function getXpubFromXpriv( - xpriv: string, - bitcoinNetwork?: string | number -): Promise { - if (!xpriv || typeof xpriv !== 'string') { - throw new ValidationError('xpriv must be a non-empty string', 'xpriv'); - } - - try { - return await getXpubFromXprivInternal(xpriv, bitcoinNetwork); - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } - throw new CryptoError('Failed to derive xpub from xpriv', error as Error); - } -} - -/** - * Derive wallet keys from extended private key (xpriv) - * Similar to deriveKeysFromMnemonic but starts from xpriv instead of mnemonic - * - * @param bitcoinNetwork - Network string or number (default: 'regtest') - * @param xpriv - Extended private key (base58 encoded) - * @returns Promise resolving to generated keys (without mnemonic) - * @throws {ValidationError} If xpriv is invalid - * @throws {CryptoError} If key derivation fails - * - * @example - * ```typescript - * const keys = await deriveKeysFromXpriv('testnet', 'xprv...'); - * console.log('Master Fingerprint:', keys.master_fingerprint); - * console.log('Account xpub vanilla:', keys.account_xpub_vanilla); - * ``` - */ -export async function deriveKeysFromXpriv( - bitcoinNetwork: string | number = 'regtest', - xpriv: string -): Promise { - if (!xpriv || typeof xpriv !== 'string') { - throw new ValidationError('xpriv must be a non-empty string', 'xpriv'); - } - - const normalizedNetwork = normalizeNetwork(bitcoinNetwork); - - try { - return await buildKeysOutputFromXpriv(xpriv, normalizedNetwork); - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } - throw new CryptoError('Failed to derive keys from xpriv', error as Error); - } -} - -export async function accountXpubsFromMnemonic( - bitcoinNetwork: string | number = 'regtest', - mnemonic: string -): Promise { - validateMnemonic(mnemonic, 'mnemonic'); - - try { - const { bip39 } = await ensureBaseDependencies(); - if (!bip39 || !bip39.validateMnemonic(mnemonic)) { - throw new ValidationError( - 'Invalid mnemonic format - failed BIP39 validation', - 'mnemonic' - ); - } - return { - account_xpub_vanilla: await getAccountXpub( - mnemonic, - bitcoinNetwork, - false - ), - account_xpub_colored: await getAccountXpub( - mnemonic, - bitcoinNetwork, - true - ), - }; - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } - throw new CryptoError( - 'Failed to derive account xpubs from mnemonic', - error as Error - ); - } -} diff --git a/src/crypto/signer.ts b/src/crypto/signer.ts index ae5b1f9..1be3010 100644 --- a/src/crypto/signer.ts +++ b/src/crypto/signer.ts @@ -1,316 +1,54 @@ -// RGB PSBT Signer Library using bdk-wasm -// Signs both create_utxo_begin and send_begin PSBTs from rgb-lib -// -// This module provides RGB-specific PSBT signing functionality for: -// - create_utxo_begin PSBTs: Creating UTXOs for RGB wallet operations -// - send_begin PSBTs: Signing RGB asset transfer transactions -// -// Usage: -// import { signPsbt, signPsbtSync } from './signer'; -// const signedPsbt = signPsbt(mnemonic, psbtBase64, 'testnet'); +// RGB PSBT Signer — BDK-based signing for rgb-lib PSBTs. +// Handles both create_utxo_begin and send_begin PSBT types. -import type { BIP32Interface } from 'bip32'; -import type { - Psbt as BitcoinJsPsbt, - Network as BitcoinJsNetwork, -} from 'bitcoinjs-lib'; -import { ValidationError, CryptoError } from '../errors'; +import { Psbt } from 'bitcoinjs-lib'; +import * as bdkNode from '@bitcoindevkit/bdk-wallet-node'; import { + ValidationError, + CryptoError, validateMnemonic, validatePsbt, normalizeNetwork, -} from '../utils/validation'; -import { - DERIVATION_PURPOSE, - DERIVATION_ACCOUNT, - KEYCHAIN_RGB, - KEYCHAIN_BTC, - COIN_RGB_TESTNET, - COIN_RGB_MAINNET, - COIN_BITCOIN_MAINNET, - COIN_BITCOIN_TESTNET, -} from '../constants'; -import type { + calculateMasterFingerprint, + getNetworkVersions, + normalizeSeedBuffer, + normalizeSeedInput, + bip39, + bip32Factory, + detectPsbtType, + deriveDescriptors, +} from '@utexo/rgb-sdk-core'; +import type { Network, PsbtType, BIP32Interface } from '@utexo/rgb-sdk-core'; +import type { BDKWallet, BDKPsbt, BDKNetwork, BDKSignOptions } from './types'; +import type { EstimateFeeResult } from '@utexo/rgb-sdk-core'; + +export type { Network, PsbtType, NetworkVersions, Descriptors, - BDKWallet, - BDKPsbt, - BDKNetwork, - BDKSignOptions, -} from './types'; -import { calculateMasterFingerprint } from '../utils/fingerprint'; -import { - getNetworkVersions as getBIP32NetworkVersions, - normalizeSeedBuffer, -} from '../utils/bip32-helpers'; -import { accountDerivationPath, normalizeSeedInput } from './keys'; -import { sha256 } from '../utils/crypto-browser'; -import { - ensureBaseDependencies, - ensureSignerDependencies, - type SignerDependencies, -} from './dependencies'; +} from '@utexo/rgb-sdk-core'; -// Re-export types from types module -export type { Network, PsbtType, NetworkVersions, Descriptors } from './types'; +const bdk = bdkNode as unknown as import('./types').BDKModule; export interface SignPsbtOptions { signOptions?: BDKSignOptions; - preprocess?: boolean; -} - -type DerivationPath = string | number[]; - -/** - * Normalize derivation path string - */ -function normalizePath(path: DerivationPath): DerivationPath { - if (typeof path === 'string') { - // Remove duplicate m/ prefixes - if (path.startsWith('m/m/')) { - return path.replace(/^m\/m\//, 'm/'); - } - return path; - } else if (Array.isArray(path)) { - // Remove leading 0 if it represents duplicate m/ prefix - if (path.length > 0 && path[0] === 0 && path.length > 1) { - const second = path[1]; - if (typeof second === 'number' && second >= 0x80000000) { - return path.slice(1); - } - } - return path; - } - return path; -} - -/** - * Convert derivation path to string format - */ -function pathToString(path: DerivationPath): string { - if (typeof path === 'string') { - return path; - } else if (Array.isArray(path)) { - return path - .map((p) => { - if (typeof p === 'number') { - return p >= 0x80000000 ? `${p & 0x7fffffff}'` : `${p}`; - } - return String(p); - }) - .join('/'); - } - return ''; -} - -/** - * Preprocessing for send_begin PSBTs: Update RGB PSBT metadata to BDK can match inputs. - */ -function preprocessPsbtForBDK( - psbtBase64: string, - rootNode: BIP32Interface, - fp: string, - network: Network, - deps: SignerDependencies -): string { - const { Psbt, networks, payments, toXOnly } = deps; - if (!Psbt || !networks || !payments || !toXOnly) { - throw new CryptoError('BitcoinJS modules not loaded'); - } - const psbt = Psbt.fromBase64(psbtBase64.trim()) as BitcoinJsPsbt; - const bjsNet: BitcoinJsNetwork = - network === 'mainnet' ? networks.bitcoin : networks.testnet; - - for (let i = 0; i < psbt.inputCount; i++) { - const input = psbt.data.inputs[i]; - - if (input.tapBip32Derivation && input.tapBip32Derivation.length > 0) { - input.tapBip32Derivation.forEach((deriv) => { - const normalizedPath = normalizePath(deriv.path as DerivationPath); - deriv.path = pathToString(normalizedPath); - let pathStr = pathToString(normalizedPath); - - if (!pathStr.startsWith('m/')) { - pathStr = 'm/' + pathStr; - } - - try { - const derivedNode = rootNode.derivePath(pathStr); - const pubkey = derivedNode.publicKey; - if (!pubkey) { - return; - } - const pubkeyBuffer = - pubkey instanceof Buffer ? pubkey : Buffer.from(pubkey); - const xOnly = toXOnly(pubkeyBuffer); - const p2tr = payments.p2tr({ - internalPubkey: xOnly, - network: bjsNet, - }); - const expectedScript = p2tr.output; - - if (!expectedScript) { - return; - } - - // Update witness_utxo.script if it doesn't match - if (input.witnessUtxo && expectedScript) { - const currentScript = input.witnessUtxo.script; - if (!currentScript.equals(expectedScript)) { - input.witnessUtxo.script = expectedScript; - } - } - - // Update tapInternalKey to match derivation - if (xOnly) { - if (!input.tapInternalKey || !input.tapInternalKey.equals(xOnly)) { - input.tapInternalKey = xOnly; - } - } - - // Update master fingerprint - const fingerprintBuf = Buffer.from(fp, 'hex'); - if (!deriv.masterFingerprint) { - deriv.masterFingerprint = fingerprintBuf; - } else { - const currentFp = Buffer.from(deriv.masterFingerprint); - if (!currentFp.equals(fingerprintBuf)) { - deriv.masterFingerprint = fingerprintBuf; - } - } - - // Update pubkey in derivation - if (!deriv.pubkey || !deriv.pubkey.equals(xOnly)) { - deriv.pubkey = xOnly; - } - } catch (_e) { - // Skip this derivation if it can't be derived from path - } - }); - } - - // Update legacy bip32Derivation if needed - if (input.bip32Derivation && input.bip32Derivation.length > 0) { - input.bip32Derivation.forEach((deriv) => { - const normalizedPath = normalizePath(deriv.path as DerivationPath); - deriv.path = pathToString(normalizedPath); - }); - } - } - - return psbt.toBase64(); -} - -/** - * Detect PSBT type by examining derivation paths - * @returns {'create_utxo'|'send'} PSBT type - */ -function detectPsbtType( - psbtBase64: string, - deps: SignerDependencies -): PsbtType { - const { Psbt } = deps; - if (!Psbt) { - throw new CryptoError('BitcoinJS Psbt module not loaded'); - } - try { - const psbt = Psbt.fromBase64(psbtBase64.trim()) as BitcoinJsPsbt; - for (let i = 0; i < psbt.inputCount; i++) { - const input = psbt.data.inputs[i]; - if (input.tapBip32Derivation && input.tapBip32Derivation.length > 0) { - for (const deriv of input.tapBip32Derivation) { - const pathStr = pathToString(deriv.path as DerivationPath); - // Check if path contains RGB coin type - indicates send_begin PSBT - if (pathStr.includes("827167'") || pathStr.includes("827166'")) { - return 'send'; - } - } - } - } - return 'create_utxo'; - } catch (_e) { - return 'create_utxo'; // Default to simpler structure - } -} - -/** - * Derive descriptors based on PSBT type - */ -function deriveDescriptors( - rootNode: BIP32Interface, - fp: string, - network: Network, - psbtType: PsbtType -): Descriptors { - const isMainnet = network === 'mainnet'; - const coinTypeBtc = isMainnet ? COIN_BITCOIN_MAINNET : COIN_BITCOIN_TESTNET; - const coinTypeRgb = isMainnet ? COIN_RGB_MAINNET : COIN_RGB_TESTNET; - - if (psbtType === 'create_utxo') { - // For create_utxo_begin: Use account-level xprv structure - const accountPath = `m/${DERIVATION_PURPOSE}'/${coinTypeBtc}'/${DERIVATION_ACCOUNT}'`; - const accountNode = rootNode.derivePath(accountPath); - const accountXprv = accountNode.toBase58(); - const origin = `[${fp}/${DERIVATION_PURPOSE}'/${coinTypeBtc}'/${DERIVATION_ACCOUNT}']`; - return { - external: `tr(${origin}${accountXprv}/0/*)`, - internal: `tr(${origin}${accountXprv}/1/*)`, - }; - } else { - // For send_begin: Use RGB descriptor structure - const rgbAccountPath = `m/${DERIVATION_PURPOSE}'/${coinTypeRgb}'/${DERIVATION_ACCOUNT}'`; - const rgbAccountNode = rootNode.derivePath(rgbAccountPath); - const rgbKeychainNode = rgbAccountNode.derive(KEYCHAIN_RGB); - const rgbKeychainXprv = rgbKeychainNode.toBase58(); - const rgbOrigin = `[${fp}/${DERIVATION_PURPOSE}'/${coinTypeRgb}'/${DERIVATION_ACCOUNT}'/${KEYCHAIN_RGB}]`; - - const btcAccountPath = `m/${DERIVATION_PURPOSE}'/${coinTypeBtc}'/${DERIVATION_ACCOUNT}'`; - const btcAccountNode = rootNode.derivePath(btcAccountPath); - const btcKeychainNode = btcAccountNode.derive(KEYCHAIN_BTC); - const btcKeychainXprv = btcKeychainNode.toBase58(); - const btcOrigin = `[${fp}/${DERIVATION_PURPOSE}'/${coinTypeBtc}'/${DERIVATION_ACCOUNT}'/${KEYCHAIN_BTC}]`; - - return { - external: `tr(${rgbOrigin}${rgbKeychainXprv}/*)`, - internal: `tr(${btcOrigin}${btcKeychainXprv}/*)`, - }; - } -} - -/** - * Get network versions for BIP32 - * Alias for shared network versions utility - */ -function getNetworkVersions(network: Network): NetworkVersions { - return getBIP32NetworkVersions(network); -} - -/** - * Calculate master fingerprint from root node - * Alias for shared fingerprint calculation utility - */ -async function getMasterFingerprint(rootNode: BIP32Interface): Promise { - return calculateMasterFingerprint(rootNode); } async function signPsbtFromSeedInternal( seed: Buffer | Uint8Array, psbtBase64: string, network: Network, - options: SignPsbtOptions = {}, - deps: SignerDependencies + options: SignPsbtOptions ): Promise { validatePsbt(psbtBase64, 'psbtBase64'); - const { ecc, factory, bdk } = deps; - const bip32 = factory(ecc); - const seedBuffer = normalizeSeedBuffer(seed); - const versions = getNetworkVersions(network); let rootNode: BIP32Interface; try { - rootNode = bip32.fromSeed(seedBuffer, versions); + rootNode = bip32Factory().fromSeed( + normalizeSeedBuffer(seed), + getNetworkVersions(network) + ); } catch (error) { throw new CryptoError( 'Failed to derive root node from seed', @@ -318,79 +56,50 @@ async function signPsbtFromSeedInternal( ); } - const fp = await getMasterFingerprint(rootNode); - const psbtType = detectPsbtType(psbtBase64, deps); - const needsPreprocessing = psbtType === 'send'; - const { external, internal } = deriveDescriptors( - rootNode, - fp, - network, - psbtType - ); - - let wallet: BDKWallet; - try { - wallet = bdk.Wallet.create(network as BDKNetwork, external, internal); - } catch (error) { - throw new CryptoError('Failed to create BDK wallet', error as Error); - } + const fp = await calculateMasterFingerprint(rootNode); + const psbtType = detectPsbtType(psbtBase64); + // utexo is a UTEXO-specific network that shares BDK's signet parameters + const bdkNetwork: Network = network === 'utexo' ? 'signet' : network; - let processedPsbt = psbtBase64.trim(); - if (needsPreprocessing || options.preprocess) { + // Try signing with the detected descriptor type; fall back to the other if it fails. + // No PSBT preprocessing needed — rgb-lib PSBTs already carry correct metadata. + const trySign = (type: PsbtType): BDKPsbt => { + const { external, internal } = deriveDescriptors( + rootNode, + fp, + bdkNetwork, + type + ); + let wallet: BDKWallet; try { - processedPsbt = preprocessPsbtForBDK( - psbtBase64, - rootNode, - fp, - network, - deps - ); + wallet = bdk.Wallet.create(bdkNetwork as BDKNetwork, external, internal); } catch (error) { - throw new CryptoError('Failed to preprocess PSBT', error as Error); + throw new CryptoError('Failed to create BDK wallet', error as Error); } - } + const pstb = bdk.Psbt.from_string(psbtBase64.trim()); + wallet.sign(pstb, options.signOptions || new bdk.SignOptions()); + return pstb; + }; let pstb: BDKPsbt; try { - pstb = bdk.Psbt.from_string(processedPsbt); - } catch (error) { - throw new CryptoError('Failed to parse PSBT', error as Error); - } - - const signOptions = options.signOptions || new bdk.SignOptions(); - try { - wallet.sign(pstb, signOptions); - } catch (error) { - throw new CryptoError('Failed to sign PSBT', error as Error); + pstb = trySign(psbtType); + } catch { + const fallback: PsbtType = + psbtType === 'create_utxo' ? 'send' : 'create_utxo'; + try { + pstb = trySign(fallback); + } catch (error) { + throw new CryptoError( + 'Failed to sign PSBT — both descriptor types failed', + error as Error + ); + } } return pstb.toString().trim(); } -/** - * Sign a PSBT using BDK - * - * Note: This function is async due to dependency loading and crypto operations. - * - * @param mnemonic - BIP39 mnemonic phrase (12 or 24 words) - * @param psbtBase64 - Base64 encoded PSBT string - * @param network - Bitcoin network ('mainnet' | 'testnet' | 'signet' | 'regtest') - * @param options - Optional signing options - * @param options.signOptions - BDK sign options (defaults used if not provided) - * @param options.preprocess - Force preprocessing (auto-detected by default) - * @returns Base64 encoded signed PSBT - * @throws {ValidationError} If mnemonic or PSBT format is invalid - * @throws {CryptoError} If signing fails - * - * @example - * ```typescript - * const signedPsbt = signPsbt( - * 'abandon abandon abandon...', - * 'cHNidP8BAIkBAAAAA...', - * 'testnet' - * ); - * ``` - */ export async function signPsbt( mnemonic: string, psbtBase64: string, @@ -398,33 +107,23 @@ export async function signPsbt( options: SignPsbtOptions = {} ): Promise { try { - // Validate inputs validateMnemonic(mnemonic, 'mnemonic'); - const { bip39 } = await ensureBaseDependencies(); - if (!bip39 || typeof bip39.mnemonicToSeedSync !== 'function') { - throw new CryptoError('bip39 module not loaded correctly'); - } - - let seed: Buffer; + let seed: Uint8Array; try { seed = bip39.mnemonicToSeedSync(mnemonic); - } catch (_error) { + } catch { throw new ValidationError('Invalid mnemonic format', 'mnemonic'); } - const normalizedNetwork = normalizeNetwork(network); - const deps = await ensureSignerDependencies(); return await signPsbtFromSeedInternal( seed, psbtBase64, normalizedNetwork, - options, - deps + options ); } catch (error) { - if (error instanceof ValidationError || error instanceof CryptoError) { + if (error instanceof ValidationError || error instanceof CryptoError) throw error; - } throw new CryptoError( 'Unexpected error during PSBT signing', error as Error @@ -432,21 +131,6 @@ export async function signPsbt( } } -/** - * Legacy sync-named wrapper (still async under the hood). - */ -export async function signPsbtSync( - mnemonic: string, - psbtBase64: string, - network: Network = 'testnet', - options: SignPsbtOptions = {} -): Promise { - return signPsbt(mnemonic, psbtBase64, network, options); -} - -/** - * Sign a PSBT using a raw BIP39 seed (hex string or Uint8Array) - */ export async function signPsbtFromSeed( seed: string | Uint8Array, psbtBase64: string, @@ -455,175 +139,26 @@ export async function signPsbtFromSeed( ): Promise { const normalizedSeed = normalizeSeedInput(seed); const normalizedNetwork = normalizeNetwork(network); - const deps = await ensureSignerDependencies(); return signPsbtFromSeedInternal( normalizedSeed, psbtBase64, normalizedNetwork, - options, - deps - ); -} - -function ensureMessageInput(message: string | Uint8Array): Uint8Array { - if (typeof message === 'string') { - if (!message.length) { - throw new ValidationError('message must not be empty', 'message'); - } - return Buffer.from(message, 'utf8'); - } - if (message instanceof Uint8Array) { - if (!message.length) { - throw new ValidationError('message must not be empty', 'message'); - } - return Buffer.from(message); - } - throw new ValidationError( - 'message must be a string or Uint8Array', - 'message' + options ); } -async function deriveRootFromSeedInput( - seed: string | Uint8Array, - network: Network -): Promise { - const { ecc, factory } = await ensureBaseDependencies(); - const normalizedSeed = normalizeSeedInput(seed, 'seed'); - const versions = getNetworkVersions(network); - const bip32 = factory(ecc); - try { - return bip32.fromSeed(normalizedSeed, versions); - } catch (error) { - throw new CryptoError( - 'Failed to create BIP32 root node from seed', - error as Error - ); - } -} - -const DEFAULT_RELATIVE_PATH = '0/0'; - -export interface SignMessageParams { - message: string | Uint8Array; - seed: string | Uint8Array; - network?: Network; -} - -export interface SignMessageResult { - signature: string; - accountXpub: string; -} - -export interface VerifyMessageParams { - message: string | Uint8Array; - signature: string; - accountXpub: string; - network?: Network; -} - -export interface EstimateFeeResult { - fee: number; - vbytes: number; - feeRate: number; -} -export async function signMessage(params: SignMessageParams): Promise { - const { message, seed } = params; - if (!seed) { - throw new ValidationError('seed is required', 'seed'); - } - const normalizedNetwork = normalizeNetwork(params.network ?? 'regtest'); - const relativePath = DEFAULT_RELATIVE_PATH; - const accountPath = accountDerivationPath(normalizedNetwork, false); - - const messageBytes = ensureMessageInput(message); - const { ecc } = await ensureBaseDependencies(); - const root = await deriveRootFromSeedInput(seed, normalizedNetwork); - const accountNode = root.derivePath(accountPath); - const child = accountNode.derivePath(relativePath); - const privateKey = child.privateKey; - - if (!privateKey) { - throw new CryptoError('Derived node does not contain a private key'); - } - if (!ecc || typeof ecc.signSchnorr !== 'function') { - throw new CryptoError('Schnorr signing not supported by ECC module'); - } - - const messageHash = await sha256(messageBytes); - const signature = Buffer.from( - ecc.signSchnorr(messageHash, privateKey) - ).toString('base64'); - // const accountXpub = accountNode.neutered().toBase58(); - return signature; -} - -export async function verifyMessage( - params: VerifyMessageParams -): Promise { - const { message, signature, accountXpub } = params; - const messageBytes = ensureMessageInput(message); - const relativePath = DEFAULT_RELATIVE_PATH; - // const signatureBytes = decodeSignatureBase64(signature); - const signatureBytes = Buffer.from(signature, 'base64'); - - const normalizedNetwork = normalizeNetwork(params.network ?? 'regtest'); - const versions = getNetworkVersions(normalizedNetwork); - const { ecc, factory } = await ensureBaseDependencies(); - - if ( - !ecc || - typeof ecc.verifySchnorr !== 'function' || - typeof ecc.xOnlyPointFromPoint !== 'function' - ) { - throw new CryptoError('Schnorr verification not supported by ECC module'); - } - - let accountNode: BIP32Interface; - try { - accountNode = factory(ecc).fromBase58(accountXpub, versions); - } catch (_error) { - throw new ValidationError('Invalid account xpub provided', 'accountXpub'); - } - - const child = accountNode.derivePath(relativePath); - const pubkeyBuffer = - child.publicKey instanceof Buffer - ? child.publicKey - : Buffer.from(child.publicKey); - const xOnlyPubkey = ecc.xOnlyPointFromPoint(pubkeyBuffer); - - const messageHash = await sha256(messageBytes); - - try { - return ecc.verifySchnorr(messageHash, xOnlyPubkey, signatureBytes); - } catch { - return false; - } -} - export async function estimatePsbt( psbtBase64: string ): Promise { - if (!psbtBase64) { - throw new ValidationError('psbt is required', 'psbt'); - } - - const { Psbt } = await ensureSignerDependencies(); - if (!Psbt) { - throw new CryptoError('BitcoinJS Psbt module not loaded'); - } - - let psbt: BitcoinJsPsbt; + if (!psbtBase64) throw new ValidationError('psbt is required', 'psbt'); try { - psbt = Psbt.fromBase64(psbtBase64.trim()) as BitcoinJsPsbt; + const psbt = Psbt.fromBase64(psbtBase64.trim()); return { fee: psbt.getFee(), feeRate: psbt.getFeeRate(), vbytes: psbt.extractTransaction().virtualSize(), }; - } catch (error) { - console.log('error', error); + } catch { throw new ValidationError('Invalid PSBT provided', 'psbt'); } } diff --git a/src/crypto/types.ts b/src/crypto/types.ts index fcb0c2b..47b2856 100644 --- a/src/crypto/types.ts +++ b/src/crypto/types.ts @@ -1,83 +1,30 @@ /** - * RGB Crypto module types - * - * Type definitions for RGB-specific cryptographic operations including - * PSBT signing and key derivation for RGB protocol + * RGB Crypto module types — shared types re-exported from core, BDK types kept local. */ -/** - * Bitcoin network type - */ -export type Network = 'mainnet' | 'testnet' | 'testnet4' | 'signet' | 'regtest'; - -/** - * PSBT type (create_utxo or send) - */ -export type PsbtType = 'create_utxo' | 'send'; - -/** - * Network versions for BIP32 - */ -export interface NetworkVersions { - bip32: { - public: number; - private: number; - }; - wif: number; -} - -/** - * Descriptors for wallet derivation - */ -export interface Descriptors { - external: string; - internal: string; -} +// Shared types now live in core +export type { + Network, + PsbtType, + NetworkVersions, + Descriptors, + BufferLike, + BIP32Interface, +} from '@utexo/rgb-sdk-core'; -/** - * Buffer-like object that can be converted to Buffer or Uint8Array - */ -export type BufferLike = - | Buffer - | Uint8Array - | ArrayBuffer - | { - buffer?: ArrayBuffer; - byteOffset?: number; - byteLength?: number; - length?: number; - } - | number[]; - -/** - * BDK Network type (from bdk-wasm) - */ +// BDK-specific types (Node SDK only) export type BDKNetwork = string | number; -/** - * BDK Wallet instance - */ export interface BDKWallet { sign(psbt: BDKPsbt, signOptions: BDKSignOptions): BDKPsbt; } -/** - * BDK PSBT instance - */ export interface BDKPsbt { extract_tx(): string; } -/** - * BDK SignOptions - */ -export interface BDKSignOptions { - // BDK SignOptions properties -} +export interface BDKSignOptions {} -/** - * BDK Module interface - */ export interface BDKModule { Wallet: { create: ( @@ -90,91 +37,5 @@ export interface BDKModule { from_string: (psbt: string) => BDKPsbt; }; SignOptions: new () => BDKSignOptions; - Network?: { - [key: string]: BDKNetwork; - }; -} - -/** - * BDK Init function - */ -export type BDKInit = unknown; - -/** - * BIP39 module interface - */ -export interface BIP39Module { - mnemonicToSeedSync: (mnemonic: string) => Buffer; - validateMnemonic: (mnemonic: string, wordlist?: string[]) => boolean; - setDefaultWordlist?: (wordlist: string) => void; - generateMnemonic?: ( - strength?: number, - rng?: (size: number) => Buffer, - wordlist?: string[] - ) => string; - [key: string]: unknown; -} - -/** - * ECC module interface (from @bitcoinerlab/secp256k1) - * Note: This is a simplified interface that matches the actual ECC module - */ -export interface ECCModule { - signSchnorr: ( - message: Uint8Array, - privateKey: Uint8Array, - auxRand?: Uint8Array - ) => Uint8Array; - verifySchnorr: ( - message: Uint8Array, - publicKey: Uint8Array, - signature: Uint8Array - ) => boolean; - xOnlyPointFromPoint: (point: Uint8Array) => Uint8Array; - [key: string]: unknown; -} - -/** - * BIP32 Factory function type - */ -export type BIP32Factory = (ecc: unknown) => { - fromSeed: ( - seed: Buffer | Uint8Array, - versions?: NetworkVersions - ) => import('bip32').BIP32Interface; - fromBase58: ( - base58: string, - versions?: NetworkVersions - ) => import('bip32').BIP32Interface; -}; - -/** - * BitcoinJS Payments module - */ -export interface BitcoinJsPayments { - p2tr: (options: { - internalPubkey: Buffer; - network: import('bitcoinjs-lib').Network; - }) => { - output?: Buffer; - address?: string; - }; -} - -/** - * BitcoinJS Networks module - */ -export interface BitcoinJsNetworks { - bitcoin: import('bitcoinjs-lib').Network; - testnet: import('bitcoinjs-lib').Network; - regtest?: import('bitcoinjs-lib').Network; - signet?: import('bitcoinjs-lib').Network; -} - -/** - * BIP341 module interface - */ -export interface BIP341Module { - toXOnly?: (pubkey: Buffer) => Buffer; - [key: string]: unknown; + Network?: { [key: string]: BDKNetwork }; } diff --git a/src/crypto/vss-keys.ts b/src/crypto/vss-keys.ts deleted file mode 100644 index 2ac01ad..0000000 --- a/src/crypto/vss-keys.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * VSS (Versioned Storage Service) backup key derivation. - * - * Derives a 32-byte signing key from a BIP39 mnemonic for use with rgb-lib's - * VssBackupClient (server_url, store_id, signing_key). rgb-lib does not define - * mnemonic → signing_key; this SDK uses HMAC-SHA256 with the same domain string - * as rgb-lib's HKDF so backup/restore stay deterministic per wallet. - * - * Derivation: HMAC-SHA256(key = "rgb-lib-vss-backup-encryption-v1", message = mnemonic), - * output as 64-char hex (32 bytes). - */ - -import { hmac } from '@noble/hashes/hmac.js'; -import { sha256 } from '@noble/hashes/sha2.js'; -import { validateMnemonic } from '../utils/validation'; - -const VSS_SIGNING_KEY_DOMAIN = 'rgb-lib-vss-backup-encryption-v1'; - -/** - * Derive the VSS backup signing key from a BIP39 mnemonic. - * The result is a 64-character hex string (32 bytes) suitable for - * VssBackupConfig.signingKey. Must match rgb-lib's Rust derivation. - * - * @param mnemonic - BIP39 mnemonic phrase (12 or 24 words) - * @returns 32-byte signing key as hex string (same mnemonic always yields same key for backup/restore) - */ -export function deriveVssSigningKeyFromMnemonic(mnemonic: string): string { - validateMnemonic(mnemonic, 'mnemonic'); - const keyBytes = new TextEncoder().encode(VSS_SIGNING_KEY_DOMAIN); - const messageBytes = new TextEncoder().encode(mnemonic.trim()); - const digest = hmac(sha256, keyBytes, messageBytes); - return Array.from(digest) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} diff --git a/src/errors/index.ts b/src/errors/index.ts deleted file mode 100644 index 75e39f1..0000000 --- a/src/errors/index.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Custom error classes for the RGB SDK - */ - -/** - * Base SDK error class with error codes and context - */ -export class SDKError extends Error { - public readonly code: string; - public readonly statusCode?: number; - public readonly cause?: Error; - - constructor( - message: string, - code: string, - statusCode?: number, - cause?: Error - ) { - super(message); - this.name = 'SDKError'; - this.code = code; - this.statusCode = statusCode; - this.cause = cause; - Object.setPrototypeOf(this, SDKError.prototype); - - // Maintains proper stack trace for where error was thrown (V8 only) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, SDKError); - } - } - - /** - * Convert error to JSON for logging - */ - toJSON() { - return { - name: this.name, - message: this.message, - code: this.code, - statusCode: this.statusCode, - cause: this.cause?.message, - stack: this.stack, - }; - } -} - -/** - * Network-related errors (API calls, connectivity) - */ -export class NetworkError extends SDKError { - constructor(message: string, statusCode?: number, cause?: Error) { - super(message, 'NETWORK_ERROR', statusCode, cause); - this.name = 'NetworkError'; - Object.setPrototypeOf(this, NetworkError.prototype); - } -} - -/** - * Validation errors (invalid input parameters) - */ -export class ValidationError extends SDKError { - public readonly field?: string; - - constructor(message: string, field?: string) { - super(message, 'VALIDATION_ERROR'); - this.name = 'ValidationError'; - this.field = field; - Object.setPrototypeOf(this, ValidationError.prototype); - } -} - -/** - * Wallet-related errors (initialization, operations) - */ -export class WalletError extends SDKError { - constructor(message: string, code?: string, cause?: Error) { - super(message, code || 'WALLET_ERROR', undefined, cause); - this.name = 'WalletError'; - Object.setPrototypeOf(this, WalletError.prototype); - } -} - -/** - * Cryptographic errors (signing, key derivation) - */ -export class CryptoError extends SDKError { - constructor(message: string, cause?: Error) { - super(message, 'CRYPTO_ERROR', undefined, cause); - this.name = 'CryptoError'; - Object.setPrototypeOf(this, CryptoError.prototype); - } -} - -/** - * Configuration errors (missing or invalid configuration) - */ -export class ConfigurationError extends SDKError { - constructor(message: string, _field?: string) { - super(message, 'CONFIGURATION_ERROR'); - this.name = 'ConfigurationError'; - Object.setPrototypeOf(this, ConfigurationError.prototype); - } -} - -/** - * Bad request errors (400) - Invalid request parameters or data - */ -export class BadRequestError extends SDKError { - constructor(message: string, cause?: Error) { - super(message, 'BAD_REQUEST', 400, cause); - this.name = 'BadRequestError'; - Object.setPrototypeOf(this, BadRequestError.prototype); - } -} - -/** - * Not found errors (404) - Resource not found - */ -export class NotFoundError extends SDKError { - constructor(message: string, cause?: Error) { - super(message, 'NOT_FOUND', 404, cause); - this.name = 'NotFoundError'; - Object.setPrototypeOf(this, NotFoundError.prototype); - } -} - -/** - * Conflict errors (409) - Resource conflict (e.g., wallet state already exists) - */ -export class ConflictError extends SDKError { - constructor(message: string, cause?: Error) { - super(message, 'CONFLICT', 409, cause); - this.name = 'ConflictError'; - Object.setPrototypeOf(this, ConflictError.prototype); - } -} - -/** - * RGB Node errors (500, 502, 503, 504) - RGB Node server errors - */ -export class RgbNodeError extends SDKError { - constructor(message: string, statusCode: number, cause?: Error) { - super(message, 'RGB_NODE_ERROR', statusCode, cause); - this.name = 'RgbNodeError'; - Object.setPrototypeOf(this, RgbNodeError.prototype); - } -} diff --git a/src/index.ts b/src/index.ts index 14ba597..f45b8d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,28 +5,30 @@ export { WalletManager, createWalletManager, restoreFromBackup, -} from './wallet/index'; -export type { WalletInitParams } from './wallet/index'; +} from './wallet/wallet-manager'; +export type { WalletInitParams } from './wallet/wallet-manager'; // UTEXO wallet exports +export { UTEXOWallet } from './utexo/utexo-wallet'; +export { + restoreUtxoWalletFromVss, + restoreUtxoWalletFromBackup, +} from './utexo/restore'; export { - UTEXOWallet, UTEXOProtocol, LightningProtocol, OnchainProtocol, - restoreUtxoWalletFromVss, - restoreUtxoWalletFromBackup, DEFAULT_VSS_SERVER_URL, -} from './utexo'; +} from '@utexo/rgb-sdk-core'; export type { ConfigOptions, IUTEXOProtocol, ILightningProtocol, IOnchainProtocol, -} from './utexo'; +} from '@utexo/rgb-sdk-core'; // VSS backup exports (single-wallet restore; use restoreUtxoWalletFromVss for UTEXOWallet) -export { restoreFromVss } from './client/rgb-lib-client'; +export { restoreFromVss } from './binding/NodeRgbLibBinding'; // Type exports export * from './types/rgb-model'; @@ -34,25 +36,21 @@ export type { TransferStatus, BridgeTransferStatus, OnchainSendStatus, -} from './types/wallet-model'; +} from '@utexo/rgb-sdk-core'; export type { Network, PsbtType, SignPsbtOptions, NetworkVersions, Descriptors, -} from './crypto'; -export type { GeneratedKeys, AccountXpubs } from './crypto'; +} from './crypto/signer'; +export type { GeneratedKeys, AccountXpubs } from '@utexo/rgb-sdk-core'; // Function exports +export { signPsbt, signPsbtFromSeed, estimatePsbt } from './crypto/signer'; export { - signPsbt, - signPsbtSync, - signPsbtFromSeed, signMessage, verifyMessage, -} from './crypto'; -export { generateKeys, deriveKeysFromMnemonic, deriveKeysFromSeed, @@ -63,7 +61,8 @@ export { getXpubFromXpriv, deriveKeysFromXpriv, deriveVssSigningKeyFromMnemonic, -} from './crypto'; + bip39, +} from '@utexo/rgb-sdk-core'; // Error exports export { @@ -77,12 +76,11 @@ export { NotFoundError, ConflictError, RgbNodeError, -} from './errors'; +} from '@utexo/rgb-sdk-core'; // Utility exports -export { logger, configureLogging, LogLevel } from './utils/logger'; -export { isNode, isBrowser, getEnvironment } from './utils/environment'; -export type { Environment } from './utils/environment'; +export { logger, configureLogging, LogLevel } from '@utexo/rgb-sdk-core'; +export { isNode } from './utils/environment'; export { validateNetwork, normalizeNetwork, @@ -92,9 +90,35 @@ export { validateHex, validateRequired, validateString, -} from './utils/validation'; -// normalizeNetwork is exported from validation.ts above -// network.ts is kept for backward compatibility but normalizeNetwork from validation.ts is preferred + isNetwork, +} from '@utexo/rgb-sdk-core'; -// Constants exports -export * from './constants'; +// Constants +export { + DEFAULT_NETWORK, + DEFAULT_API_TIMEOUT, + DEFAULT_MAX_RETRIES, + DEFAULT_LOG_LEVEL, + DERIVATION_PURPOSE, + DERIVATION_ACCOUNT, + KEYCHAIN_RGB, + KEYCHAIN_BTC, + COIN_RGB_MAINNET, + COIN_RGB_TESTNET, + COIN_BITCOIN_MAINNET, + COIN_BITCOIN_TESTNET, + NETWORK_MAP, + BIP32_VERSIONS, + utexoNetworkMap, + utexoNetworkIdMap, + getDestinationAsset, + getUtxoNetworkConfig, +} from '@utexo/rgb-sdk-core'; +export type { + NetworkAsset, + UtxoNetworkId, + UtxoNetworkPreset, + UtxoNetworkMap, + UtxoNetworkIdMap, + UtxoNetworkPresetConfig, +} from '@utexo/rgb-sdk-core'; diff --git a/src/signer/NodeSigner.ts b/src/signer/NodeSigner.ts new file mode 100644 index 0000000..89ae1e8 --- /dev/null +++ b/src/signer/NodeSigner.ts @@ -0,0 +1,54 @@ +/** + * NodeSigner — implements ISigner for the Node SDK. + * + * Delegates to the existing signer.ts implementation which uses + * bip32 npm + bitcoinjs-lib for PSBT preprocessing + BDK for signing. + * + * Phase F (crypto unification) will replace the underlying signer + * implementation with @scure/bip32 + @scure/btc-signer + BDK, + * making NodeSigner, RNSigner, and WASMSigner share identical logic. + */ + +import type { ISigner } from '@utexo/rgb-sdk-core'; +import type { Network, EstimateFeeResult } from '@utexo/rgb-sdk-core'; +import { signMessage, verifyMessage } from '@utexo/rgb-sdk-core'; +import { signPsbt, signPsbtFromSeed, estimatePsbt } from '../crypto/signer'; + +export class NodeSigner implements ISigner { + async signPsbtWithMnemonic( + mnemonic: string, + psbt: string, + network: Network + ): Promise { + return signPsbt(mnemonic, psbt, network); + } + + async signPsbtWithSeed( + seed: Uint8Array, + psbt: string, + network: Network + ): Promise { + return signPsbtFromSeed(seed, psbt, network); + } + + async signMessage(params: { + message: string | Uint8Array; + seed: Uint8Array; + network: Network; + }): Promise { + return signMessage(params); + } + + async verifyMessage(params: { + message: string | Uint8Array; + signature: string; + accountXpub: string; + network: Network; + }): Promise { + return verifyMessage(params); + } + + async estimateFee(psbt: string): Promise { + return estimatePsbt(psbt); + } +} diff --git a/src/types/utexo.ts b/src/types/utexo.ts deleted file mode 100644 index 01f5fe7..0000000 --- a/src/types/utexo.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { InvoiceRequest, SendResult } from './wallet-model'; - -/** - * UTEXO Protocol Types - */ - -export type PublicKeys = { - xpub: string; - accountXpubVanilla: string; - accountXpubColored: string; - masterFingerprint: string; -}; - -/** - * Lightning API Types - */ -export interface LightningAsset { - /** - * @type {string} - * @memberof LightningAsset - */ - assetId: string; - - /** - * @type {number} - * @memberof LightningAsset - */ - amount: number; -} -/** - * Request model for creating Lightning invoice. - * - * @export - * @interface CreateLightningInvoiceRequestModel - */ -export interface CreateLightningInvoiceRequestModel { - /** - * @type {number} - * @memberof CreateLightningInvoiceRequestModel - */ - amountSats?: number; - - /** - * @type {LightningAsset} - * @memberof CreateLightningInvoiceRequestModel - */ - asset: LightningAsset; - - /** - * @type {number} - * @memberof CreateLightningInvoiceRequestModel - */ - expirySeconds?: number; -} - -export interface LightningReceiveRequest { - lnInvoice: string; - expiresAt?: number; -} - -export interface LightningSendRequest extends SendResult {} - -export interface GetLightningSendFeeEstimateRequestModel { - invoice: string; - assetId?: string; -} - -export interface PayLightningInvoiceRequestModel { - lnInvoice: string; - amount?: number; - assetId?: string; - maxFee?: number; -} - -export interface PayLightningInvoiceEndRequestModel { - signedPsbt: string; - lnInvoice: string; -} - -/** - * Onchain API Types - */ - -export interface OnchainReceiveRequestModel extends InvoiceRequest { - amount: number; - assetId: string; -} - -export interface OnchainReceiveResponse { - /** Mainnet invoice */ - invoice: string; -} - -export interface OnchainSendRequestModel { - /** Mainnet invoice */ - invoice: string; - assetId?: string; - amount?: number; -} - -export interface OnchainSendEndRequestModel { - /** Mainnet invoice */ - invoice: string; - signedPsbt: string; -} - -export interface OnchainSendResponse extends SendResult {} - -export interface GetOnchainSendResponse { - sendId: string; - txid?: string; - status: string; - amount: number; - assetId?: string; - fee?: number; - createdAt: number; - completedAt?: number; -} - -export interface ListLightningPaymentsResponse { - payments: LightningSendRequest[]; -} diff --git a/src/types/wallet-model.ts b/src/types/wallet-model.ts deleted file mode 100644 index aaa401b..0000000 --- a/src/types/wallet-model.ts +++ /dev/null @@ -1,547 +0,0 @@ -export type RGBHTTPClientParams = { - xpubVan: string; - xpubCol: string; - masterFingerprint: string; - rgbEndpoint: string; -}; - -export type BitcoinNetwork = - | 'mainnet' - | 'testnet' - | 'testnet4' - | 'regtest' - | 'signet'; - -export interface FailTransfersRequest { - batchTransferIdx?: number; - noAssetOnly?: boolean; - skipSync?: boolean; -} - -export interface WalletBackupResponse { - message: string; - backupPath: string; -} - -export interface WalletRestoreResponse { - message: string; -} - -export interface RestoreWalletRequestModel { - backupFilePath: string; - password: string; - dataDir: string; -} - -export interface WitnessData { - amountSat: number; - blinding?: number; -} -export interface InvoiceRequest { - amount?: number; - assetId?: string; - minConfirmations?: number; - durationSeconds?: number; -} -export interface Recipient { - recipientId: string; - witnessData?: WitnessData; - amount: number; - transportEndpoints: string[]; -} - -export type BatchRecipient = { - recipientId: string; - witnessData?: { amountSat: string; blinding?: number | null } | null; - assignment: { Fungible: number }; - transportEndpoints: string[]; -}; - -export type RecipientMap = Record; - -// VSS (Versioned Storage Service) backup types - -/** - * VSS backup mode: Async (fire-and-forget) or Blocking (wait for upload). - */ -export type VssBackupMode = 'Async' | 'Blocking'; - -/** - * VSS backup configuration for cloud backup. - * - * serverUrl, storeId and signingKey are required; other fields are optional - * and default to the underlying rgb-lib defaults when omitted: - * - encryptionEnabled: true - * - autoBackup: false - * - backupMode: 'Async' - */ -export interface VssBackupConfig { - serverUrl: string; - storeId: string; - /** - * Signing key as a hex-encoded 32-byte secret key string. - */ - signingKey: string; - encryptionEnabled?: boolean; - autoBackup?: boolean; - backupMode?: VssBackupMode; -} - -/** - * Information about the current VSS backup status for a wallet. - */ -export interface VssBackupInfo { - backupExists: boolean; - serverVersion?: number | null; - backupRequired: boolean; -} - -export interface IssueAssetNiaRequestModel { - ticker: string; - name: string; - amounts: number[]; - precision: number; -} - -export interface IssueAssetIfaRequestModel { - ticker: string; - name: string; - precision: number; - amounts: number[]; - inflationAmounts: number[]; - replaceRightsNum: number; - rejectListUrl: string | null; -} -export interface SendAssetBeginRequestModel { - invoice: string; - witnessData?: WitnessData; - assetId?: string; - amount?: number; - // recipientMap: Record; - donation?: boolean; // default: false - feeRate?: number; // default: 1 - minConfirmations?: number; // default: 1 -} - -export interface SendAssetEndRequestModel { - signedPsbt: string; - skipSync?: boolean; -} - -export interface SendResult { - txid: string; - batchTransferIdx: number; -} - -export interface OperationResult { - txid: string; - batchTransferIdx: number; -} - -export interface CreateUtxosBeginRequestModel { - upTo?: boolean; - num?: number; - size?: number; - feeRate?: number; -} - -export interface CreateUtxosEndRequestModel { - signedPsbt: string; - skipSync?: boolean; -} - -export interface InflateAssetIfaRequestModel { - assetId: string; - inflationAmounts: number[]; - feeRate?: number; - minConfirmations?: number; -} - -export interface InflateEndRequestModel { - signedPsbt: string; -} - -export interface SendBtcBeginRequestModel { - address: string; - amount: number; - feeRate: number; - skipSync?: boolean; -} -export interface SendBtcEndRequestModel { - signedPsbt: string; - skipSync?: boolean; -} - -export interface GetFeeEstimationRequestModel { - blocks: number; -} - -export type GetFeeEstimationResponse = Record | number; - -export enum BindingTransactionType { - RGB_SEND = 0, - DRAIN = 1, - CREATE_UTXOS = 2, - USER = 3, -} -export type TransactionType = 'RgbSend' | 'Drain' | 'CreateUtxos' | 'User'; - -export interface BlockTime { - height: number; - timestamp: number; -} - -export interface Transaction { - transactionType: TransactionType; - txid: string; - received: number; - sent: number; - fee: number; - confirmationTime?: BlockTime; -} -export type TransferKind = - | 'Issuance' - | 'ReceiveBlind' - | 'ReceiveWitness' - | 'Send' - | 'Inflation'; - -export type Outpoint = { - txid: string; - vout: number; -}; - -export interface Transfer { - idx: number; - batchTransferIdx: number; - createdAt: number; - updatedAt: number; - status: TransferStatus; - requestedAssignment?: Assignment; - assignments: Assignment[]; - kind: TransferKind; - txid?: string; - recipientId?: string; - receiveUtxo?: Outpoint; - changeUtxo?: Outpoint; - expiration?: number; - transportEndpoints: { - endpoint: string; - transportType: string; - used: boolean; - }[]; - invoiceString?: string; - consignmentPath?: string; -} - -export type TransferStatus = - | 'WaitingCounterparty' - | 'WaitingConfirmations' - | 'Settled' - | 'Failed'; - -/** Bridge transfer statuses (from UTEXO bridge API) */ -export type BridgeTransferStatus = - | 'Unspecified' - | 'Confirming' - | 'Canceled' - | 'Finished' - | 'Waiting' - | 'Cancelling' - | 'Failed' - | 'Fetching'; - -/** Unified status for on-chain operations (from RGB wallet or bridge) */ -export type OnchainSendStatus = TransferStatus | BridgeTransferStatus; - -export interface Unspent { - utxo: Utxo; - rgbAllocations: RgbAllocation[]; - pendingBlinded: number; -} -export interface Utxo { - outpoint: { - txid: string; - vout: number; - }; - btcAmount: number; - colorable: boolean; - exists: boolean; -} - -export interface RgbAllocation { - assetId?: string; - assignment: Assignment; - settled: boolean; -} - -export interface Balance { - settled: number; - future: number; - spendable: number; -} - -export interface BtcBalance { - vanilla: Balance; - colored: Balance; -} -export interface InvoiceReceiveData { - invoice: string; - recipientId: string; - expirationTimestamp: number | null; - batchTransferIdx: number; -} -export interface AssetNIA { - /** - * @type {string} - * @memberof AssetNIA - * @example rgb:2dkSTbr-jFhznbPmo-TQafzswCN-av4gTsJjX-ttx6CNou5-M98k8Zd - */ - assetId: string; - - /** - * @type {AssetIface} - * @memberof AssetNIA - */ - assetIface?: AssetIface; - - /** - * @type {string} - * @memberof AssetNIA - * @example USDT - */ - ticker: string; - - /** - * @type {string} - * @memberof AssetNIA - * @example Tether - */ - name: string; - - /** - * @type {string | null} - * @memberof AssetNIA - * @example asset details (API may return null) - */ - details?: string | null; - - /** - * @type {number} - * @memberof AssetNIA - * @example 0 - */ - precision: number; - - /** - * @type {number} - * @memberof AssetNIA - * @example 777 - */ - issuedSupply: number; - - /** - * @type {number} - * @memberof AssetNIA - * @example 1691160565 - */ - timestamp: number; - - /** - * @type {number} - * @memberof AssetNIA - * @example 1691161979 - */ - addedAt: number; - - /** - * @type {Balance} - * @memberof AssetNIA - */ - balance: Balance; - - /** - * @type {Media | null} - * @memberof AssetNIA - * @example API may return null - */ - media?: Media | null; -} - -export interface AssetIfa { - assetId: string; - ticker: string; - name: string; - details?: string; - precision: number; - initialSupply: number; - maxSupply: number; - knownCirculatingSupply: number; - timestamp: number; - addedAt: number; - balance: Balance; - media?: Media; - rejectListUrl?: string; -} - -export interface Media { - /** - * @type {string} - * @memberof Media - * @example /path/to/media - */ - filePath?: string; - - /** - * @type {string} - * @memberof Media - * @example text/plain - */ - mime?: string; -} - -export enum AssetIface { - RGB20 = 'RGB20', - RGB21 = 'RGB21', - RGB25 = 'RGB25', -} - -export enum AssetSchema { - Nia = 'Nia', - Uda = 'Uda', - Cfa = 'Cfa', -} - -export type ListAssets = { - nia: AssetNIA[]; - uda: AssetUDA[]; - cfa: AssetCFA[]; - ifa: AssetIfa[]; -}; -export type AssetUDA = { - assetId: string; - ticker: string; - name: string; - details?: string; - precision: number; - timestamp: number; - addedAt: number; - balance: Balance; - token?: { - index: number; - ticker?: string; - name?: string; - details?: string; - embeddedMedia: boolean; - media?: Media; - attachments: Array<{ - key: number; - filePath: string; - mime: string; - digest: string; - }>; - reserves: boolean; - }; -}; -export type AssetIFA = { - assetId: string; - ticker: string; - name: string; - details?: string; - precision: number; - initialSupply: number; - maxSupply: number; - knownCirculatingSupply: number; - timestamp: number; - addedAt: number; - balance: Balance; - media?: Media; - rejectListUrl?: string; -}; - -export type AssetCFA = { - assetId: string; - name: string; - details?: string; - precision: number; - issuedSupply: number; - timestamp: number; - addedAt: number; - balance: Balance; - media?: Media; -}; - -export interface IssueAssetNIAResponse { - /** - * @type {AssetNIA} - * @memberof IssueAssetNIAResponse - */ - asset?: AssetNIA; -} - -/** - * - * - * @export - * @interface AssetBalance - */ -export interface AssetBalance { - /** - * @type {number} - * @memberof AssetBalance - * @example 777 - */ - settled?: number; - - /** - * @type {number} - * @memberof AssetBalance - * @example 777 - */ - future?: number; - - /** - * @type {number} - * @memberof AssetBalance - * @example 777 - */ - spendable?: number; - - /** - * @type {number} - * @memberof AssetBalance - * @example 444 - */ - offchainOutbound?: number; - - /** - * @type {number} - * @memberof AssetBalance - * @example 0 - */ - offchainInbound?: number; -} - -export interface InvoiceData { - invoice: string; - recipientId: string; - assetSchema?: AssetSchema; - assetId?: string; - network: BitcoinNetwork; - assignment: Assignment; - assignmentName?: string; - expirationTimestamp: number | null; - transportEndpoints: string[]; -} - -export type AssignmentType = - | 'Fungible' - | 'NonFungible' - | 'InflationRight' - | 'ReplaceRight' - | 'Any'; - -export type Assignment = { - type: AssignmentType; - amount?: number; -}; diff --git a/src/utexo/IUTEXOProtocol.ts b/src/utexo/IUTEXOProtocol.ts deleted file mode 100644 index 55f3339..0000000 --- a/src/utexo/IUTEXOProtocol.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * UTEXO Protocol Interfaces - * - * These interfaces define the contract for UTEXO-specific operations. - * They are separated by concern (Lightning vs Onchain) and combined into IUTEXOProtocol. - */ - -import type { - CreateLightningInvoiceRequestModel, - LightningReceiveRequest, - LightningSendRequest, - GetLightningSendFeeEstimateRequestModel, - PayLightningInvoiceRequestModel, - OnchainSendRequestModel, - OnchainSendResponse, - ListLightningPaymentsResponse, -} from '../types/utexo'; -import type { - SendAssetEndRequestModel, - Transfer, - TransferStatus, - OnchainSendStatus, -} from '../types/wallet-model'; - -/** - * Lightning Protocol Interface - * - * Defines methods for Lightning Network operations including - * invoice creation, payments, and fee estimation. - */ -export interface ILightningProtocol { - /** - * Creates a Lightning invoice for receiving BTC or asset payments. - * - * @param params - Request parameters for creating the Lightning invoice - * @returns Promise resolving to Lightning invoice response - */ - createLightningInvoice( - params: CreateLightningInvoiceRequestModel - ): Promise; - - /** - * Returns the status of a Lightning invoice created with createLightningInvoice. - * Supports both BTC and asset invoices. - * - * @param id - The request ID of the Lightning invoice - * @returns Promise resolving to Lightning invoice response or null if not found - */ - getLightningReceiveRequest(id: string): Promise; - - /** - * Returns the current status of a Lightning payment initiated with payLightningInvoice. - * Works for both BTC and asset payments. - * - * @param id - The request ID of the Lightning send request - * @returns Promise resolving to Lightning send request response or null if not found - */ - getLightningSendRequest(id: string): Promise; - - /** - * Estimates the routing fee required to pay a Lightning invoice. - * For asset payments, the returned fee is always denominated in satoshis. - * - * @param params - Request parameters containing the invoice and optional asset - * @returns Promise resolving to estimated fee in satoshis - */ - getLightningSendFeeEstimate( - params: GetLightningSendFeeEstimateRequestModel - ): Promise; - - /** - * Begins a Lightning invoice payment process. - * Returns the invoice string as a mock PSBT (later will be constructed base64 PSBT). - * - * @param params - Request parameters containing the invoice and max fee - * @returns Promise resolving to PSBT string (currently returns invoice, later will be base64 PSBT) - */ - payLightningInvoiceBegin( - params: PayLightningInvoiceRequestModel - ): Promise; - - /** - * Completes a Lightning invoice payment using signed PSBT. - * Works the same as pay-invoice but uses signed_psbt instead of invoice. - * - * @param params - Request parameters containing the signed PSBT - * @returns Promise resolving to Lightning send request response - */ - payLightningInvoiceEnd( - params: SendAssetEndRequestModel - ): Promise; - - /** - * Pays a Lightning invoice using the UTEXOWallet. - * This method supports BTC Lightning payments and asset-based Lightning payments. - * - * This is a convenience method that combines: - * 1. payLightningInvoiceBegin - to get the PSBT - * 2. signPsbt - to sign the PSBT (mock for now) - * 3. payLightningInvoiceEnd - to complete the payment - * - * @param params - Request parameters containing the invoice and max fee - * @param mnemonic - Optional mnemonic for signing (uses wallet's mnemonic if not provided) - * @returns Promise resolving to Lightning send request response - */ - payLightningInvoice( - params: PayLightningInvoiceRequestModel, - mnemonic?: string - ): Promise; - - /** - * Lists Lightning payments. - * - * @returns Promise resolving to response containing array of Lightning payments - */ - listLightningPayments(): Promise; -} - -/** - * Onchain Protocol Interface - * - * Defines methods for on-chain withdrawal operations from UTEXO layer. - */ -export interface IOnchainProtocol { - /** - * Begins an on-chain send process from UTEXO. - * Returns the request encoded as base64 (mock PSBT). - * Later this should construct and return a real base64 PSBT. - * - * @param params - Request parameters for on-chain send - * @returns Promise resolving to PSBT string (currently returns encoded request, later will be base64 PSBT) - */ - onchainSendBegin(params: OnchainSendRequestModel): Promise; - - /** - * Completes an on-chain send from UTEXO using signed PSBT. - * - * @param params - Request parameters containing the signed PSBT - * @returns Promise resolving to on-chain send response - */ - onchainSendEnd( - params: SendAssetEndRequestModel - ): Promise; - - /** - * Sends BTC or assets on-chain from the UTEXO layer. - * This operation creates a Bitcoin transaction that releases funds from UTEXO to a specified on-chain address. - * - * This is a convenience method that combines: - * 1. onchainSendBegin - to get the PSBT - * 2. signPsbt - to sign the PSBT (mock for now) - * 3. onchainSendEnd - to complete the on-chain send - * - * @param params - Request parameters for on-chain send - * @param mnemonic - Optional mnemonic for signing (uses wallet's mnemonic if not provided) - * @returns Promise resolving to on-chain send response - */ - onchainSend( - params: OnchainSendRequestModel, - mnemonic?: string - ): Promise; - - /** - * Gets the status of an on-chain send by send ID. - * - * @param send_id - The on-chain send ID - * @returns Promise resolving to on-chain send status response - */ - getOnchainSendStatus(send_id: string): Promise; - - /** - * Lists on-chain transfers for a specific asset. - * - * @param asset_id - The asset ID to list transfers for - * @returns Promise resolving to array of on-chain transfers - */ - listOnchainTransfers(asset_id?: string): Promise; -} - -/** - * UTEXO Protocol Interface - * - * Combines Lightning and Onchain protocol interfaces. - * This is the main interface that UTEXOWallet implements. - */ -export interface IUTEXOProtocol extends ILightningProtocol, IOnchainProtocol {} diff --git a/src/utexo/bridge/api.ts b/src/utexo/bridge/api.ts deleted file mode 100644 index 515bfdd..0000000 --- a/src/utexo/bridge/api.ts +++ /dev/null @@ -1,209 +0,0 @@ -import axios, { AxiosError, AxiosInstance } from 'axios'; -import type { UtxoNetworkPreset } from '../utils/network'; -import { DEFAULT_GATEWAY_BASE_URLS } from '../config/gateway'; -import { - BridgeInSignatureRequest, - BridgeInSignatureResponse, - ReceiverInvoiceResponse, - SubmitTransactionRequest, - SubmitTransactionResponse, - TransferByMainnetInvoiceResponse, - TransferStatuses, - VerifyBridgeInRequest, -} from './types'; - -export const encodeTransferStatus = (transferStatus: string): number => { - const textEncoder = new TextEncoder(); - - return textEncoder.encode(transferStatus.toString())[0]; -}; -/** - * Utexo Bridge API Client - * - * Client for interacting with the utexo bridge API endpoints. - * All endpoints are prefixed with `/v1/utexo/bridge`. - */ -class UtexoBridgeApiClient { - private axios: AxiosInstance; - private basePath: string; - - /** - * Creates a new UtexoBridgeApiClient instance - * - * @param axiosInstance - Axios instance to use for HTTP requests (required) - * @param basePath - Base path for API endpoints (defaults to '/v1/utexo/bridge') - * - * @example - * ```typescript - * import axios from 'axios'; - * import { UtexoBridgeApiClient } from './utexoBridge'; - * - * const axiosInstance = axios.create({ - * baseURL: 'https://api.example.com' - * }); - * - * const client = new UtexoBridgeApiClient(axiosInstance); - * ``` - */ - constructor( - axiosInstance: AxiosInstance, - basePath: string = '/v1/utexo/bridge' - ) { - this.axios = axiosInstance; - this.basePath = basePath; - } - - /** - * Gets bridge-in signature for a transfer - * - * @param request - Bridge-in signature request data - * @returns Promise resolving to bridge-in signature response - * @throws {ApiError} If the request fails - */ - async getBridgeInSignature( - request: BridgeInSignatureRequest - ): Promise { - try { - const { data } = await this.axios.post( - `${this.basePath}/bridge-in-signature`, - request - ); - return data; - } catch (error) { - const responseData = (error as AxiosError).response?.data; - if (responseData !== undefined) { - const message = - typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData); - throw new Error(message); - } - throw error; - } - } - - /** - * Submits a signed transaction to the blockchain - * - * @param request - Submit transaction request data - * @returns Promise resolving to transaction hash - * @throws {ApiError} If the request fails - */ - async submitTransaction(request: SubmitTransactionRequest): Promise { - const { data } = await this.axios.post( - `${this.basePath}/submit-transaction`, - request - ); - return data.txHash; - } - - /** - * Verifies a bridge-in transaction after it has been sent - * - * @param request - Verify bridge-in request data - * @returns Promise that resolves when verification is complete - * @throws {ApiError} If the request fails - */ - async verifyBridgeIn(request: VerifyBridgeInRequest): Promise { - await this.axios.post(`${this.basePath}/verify-bridge-in`, request); - } - - /** - * Gets receiver invoice by transfer ID and network ID - * - * @param transferId - Transfer ID - * @param networkId - Network ID - * @returns Promise resolving to invoice string - * @throws {ApiError} If the request fails - */ - async getReceiverInvoice( - transferId: number, - networkId: number - ): Promise { - const { data } = await this.axios.get( - `${this.basePath}/receiver-invoice/${transferId}/${networkId}` - ); - return data.invoice; - } - - async getWithdrawTransfer( - invoice: string, - networkId: number - ): Promise { - const { data } = await this.axios.get<{ - transfers: TransferByMainnetInvoiceResponse[]; - }>(`${this.basePath}/transfers/history`, { - params: { - network_id: String(networkId), - offset: String(0), - limit: String(10), - address: 'rgb-address', - }, - }); - - if (data.transfers.length === 0) { - return null; - } - - const withdrawTransfer = data.transfers - .map((transfer) => ({ - ...transfer, - status: TransferStatuses[encodeTransferStatus(transfer.status)], - })) - .find((transfer) => transfer.recipient.address === invoice); - if (!withdrawTransfer) { - return null; - } - - return withdrawTransfer; - } - - /** - * Gets transfer information by mainnet invoice - * - * @param mainnetInvoice - Mainnet invoice string - * @param networkId - Network ID - * @returns Promise resolving to transfer information - * @throws {ApiError} If the request fails - */ - async getTransferByMainnetInvoice( - mainnetInvoice: string, - networkId: number - ): Promise { - try { - const { data } = await this.axios.get( - `${this.basePath}/transfer-by-mainnet-invoice`, - { - params: { - mainnet_invoice: mainnetInvoice, - network_id: networkId, - }, - } - ); - if (data) { - return { - ...data, - status: TransferStatuses[encodeTransferStatus(data.status)], - }; - } - return data; - } catch (_error) { - console.log('Mainnet invoice not found'); - return null; - } - } -} - -/** - * Returns a UTEXO Bridge API client for network. - * - * @param network - 'mainnet' | 'testnet' - */ -export function getBridgeAPI( - network: UtxoNetworkPreset = 'mainnet' -): UtexoBridgeApiClient { - const axiosInstance = axios.create({ - baseURL: DEFAULT_GATEWAY_BASE_URLS[network], - }); - return new UtexoBridgeApiClient(axiosInstance); -} diff --git a/src/utexo/bridge/index.ts b/src/utexo/bridge/index.ts deleted file mode 100644 index 6e4fe41..0000000 --- a/src/utexo/bridge/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getBridgeAPI } from './api'; diff --git a/src/utexo/bridge/types.ts b/src/utexo/bridge/types.ts deleted file mode 100644 index 234bb4b..0000000 --- a/src/utexo/bridge/types.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Network address representation - */ -export type NetworkAddress = { - /** Address string */ - address: string; - /** Network name (optional) */ - networkName?: string; - /** Network ID */ - networkId: number; -}; - -/** - * Types of transfers - */ -export type TransferType = 'LP' | 'WU' | 'CCTP' | 'NTV' | 'MLT'; - -/** - * Estimation data for transfer calculations - */ -export type Estimation = { - /** Expected converted gas fee for this transfer in transferring token */ - fee?: string; - /** Stable fee percent */ - feePercentage?: string; - /** Estimated confirmation time */ - estimatedConfirmationTime: string; - /** Stable fee in transferring token */ - stableFee?: string; - /** Result amount */ - resultAmount: string; - /** Expected converted gas fee for this transfer in sender native token (for multitoken transfers) */ - nativeFee?: string; - /** Expected converted stable fee for this transfer in sender native token (for multitoken transfers) */ - nativeStableFee?: string; - /** Sum of NativeFee and NativeStableFee if present */ - totalNativeCommission?: string; - /** Native token symbol if present (for multi-token transfer estimation) */ - nativeTokenSymbol?: string; - /** Amount in the smallest token units in recipient tokens, which user will receive */ - swapResultAmount?: string; -}; - -/** - * Request to get bridge-in signature - */ -export type BridgeInSignatureRequest = { - /** Sender network address */ - sender: NetworkAddress; - /** Token ID */ - tokenId: number; - /** Amount to transfer (as string) */ - amount: string; - /** Destination network address */ - destination: NetworkAddress; - /** Additional addresses (optional) */ - additionalAddresses?: string[]; -}; - -/** - * Response containing bridge-in signature data - */ -export type BridgeInSignatureResponse = { - /** Token address */ - token: string; - /** Amount to transfer */ - amount: string; - /** Gas commission */ - gasCommission: string; - /** Destination network address */ - destination: NetworkAddress; - /** Transaction deadline */ - deadline: string; - /** Nonce value */ - nonce: number; - /** Transfer ID */ - transferId: number; - /** Signature for the transaction */ - signature: string; - /** Transfer type */ - transferType: TransferType; - /** Estimation data */ - estimation: Estimation; - /** Total commission */ - totalCommission: string; -}; - -/** - * Request to submit a transaction - */ -export type SubmitTransactionRequest = { - /** Transfer ID */ - transferId: number; - /** Network ID */ - networkId: number; - /** Transaction data (base64 encoded for Bitcoin/Concordium, hex for others) */ - txData: string; - /** Public key */ - publicKey: string; - /** Authentication signature (hex encoded) */ - authenticationSignature: string; -}; - -/** - * Response from submit transaction - */ -export type SubmitTransactionResponse = { - /** Transaction hash */ - txHash: string; -}; - -/** - * Request to verify bridge-in transaction - */ -export type VerifyBridgeInRequest = { - /** Transfer ID */ - transferId: number; - /** Network ID */ - networkId: number; - /** Transaction hash */ - txHash: string; - /** Public key */ - publicKey: string; - /** Authentication signature (hex encoded) */ - authenticationSignature: string; -}; - -/** - * Response from receiver invoice endpoint - */ -export type ReceiverInvoiceResponse = { - /** Invoice string */ - invoice: string; -}; - -/** - * Token information - */ -export type TokenInfo = { - /** Token ID */ - id: number; - /** Short name of the token */ - shortName: string; - /** Long name of the token */ - longName: string; - /** Icon link for the token */ - iconLink: string; -}; - -/** - * Transaction hash information - */ -export type TransactionHash = { - /** Network name */ - networkName: string; - /** Transaction hash */ - hash: string; -}; - -/** - * Response from transfer-by-mainnet-invoice endpoint - */ -export type TransferByMainnetInvoiceResponse = { - /** Transfer ID */ - id: number; - /** Sender amount */ - senderAmount: string; - /** Recipient amount */ - recipientAmount: string; - /** Commission amount */ - commission: string; - /** Sender token information */ - senderToken: TokenInfo; - /** Recipient token information */ - recipientToken: TokenInfo; - /** Sender network address */ - sender: NetworkAddress; - /** Recipient network address */ - recipient: NetworkAddress; - /** Transfer status */ - status: string; - /** Triggering transaction */ - triggeringTx: TransactionHash; - /** Outbound transaction */ - outboundTx: TransactionHash; - /** Creation timestamp */ - createdAt: string; -}; - -/** - * API Error response - */ -export type ApiError = { - error: string; - code?: number; -}; -export enum TransferStatuses { - 'Unspecified', - 'Confirming', - 'Canceled', - 'Finished', - 'Waiting', - 'Cancelling', - 'Failed', - 'Fetching', -} diff --git a/src/utexo/config/gateway.ts b/src/utexo/config/gateway.ts deleted file mode 100644 index 49d55e7..0000000 --- a/src/utexo/config/gateway.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * UTEXO Bridge gateway configuration by network. - */ - -import type { UtxoNetworkPreset } from '../utils/network'; - -export const DEFAULT_GATEWAY_BASE_URLS: Record = { - mainnet: 'https://gateway.utexo.utexo.com/', - testnet: 'https://dev.gateway.utexo.tricorn.network/', -}; diff --git a/src/utexo/config/index.ts b/src/utexo/config/index.ts deleted file mode 100644 index c376aee..0000000 --- a/src/utexo/config/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * UTEXO config: network presets, wallet options, gateway URLs, and VSS defaults. - */ - -export { testnetPreset, mainnetPreset } from './utexo-presets'; -export { DEFAULT_GATEWAY_BASE_URLS } from './gateway'; -export { DEFAULT_VSS_SERVER_URL } from './vss'; -export type { ConfigOptions } from './options'; diff --git a/src/utexo/config/options.ts b/src/utexo/config/options.ts deleted file mode 100644 index 551ddfe..0000000 --- a/src/utexo/config/options.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * UTEXOWallet constructor and runtime options. - */ - -import type { UtxoNetworkPreset } from '../utils/network'; - -/** - * Options for UTEXOWallet. When omitted, defaults apply (e.g. DEFAULT_VSS_SERVER_URL for VSS). - */ -export interface ConfigOptions { - /** - * Network preset: 'mainnet' (production) or 'testnet' (development). - * Default: 'mainnet'. - */ - network?: UtxoNetworkPreset; - /** - * Optional base directory for wallet data. When set, each wallet uses a subdir by network + fingerprint: - * utexoRGBWallet → dataDir/{networkMap.utexo}/{masterFingerprint} (e.g. ./utexo/signet/3780bc30) - * layer1RGBWallet → dataDir/{networkMap.mainnet}/{masterFingerprint} (e.g. ./utexo/testnet/3780bc30) - * Same structure is used by restoreUtxoWalletFromVss so restored data can be loaded with this dataDir. - */ - dataDir?: string; - /** - * Optional VSS server URL. When omitted, DEFAULT_VSS_SERVER_URL is used. - * vssBackup() / vssBackupInfo() build config from mnemonic + this URL when config is not passed. - */ - vssServerUrl?: string; -} diff --git a/src/utexo/config/utexo-presets.ts b/src/utexo/config/utexo-presets.ts deleted file mode 100644 index 5e48249..0000000 --- a/src/utexo/config/utexo-presets.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * UTEXO Network Preset Configurations - * - * This file contains the network preset configurations for different environments. - * Each preset defines the Bitcoin network types and RGB/UTEXO network IDs and assets. - */ - -import type { UtxoNetworkPresetConfig } from '../utils/network'; - -/** - * Network configuration for a single network (RGB, RGB Lightning, or UTEXO) - */ -type NetworkConfig = { - networkName: string; - networkId: number; - assets: { - assetId: string; - tokenName: string; - longName: string; - precision: number; - tokenId: number; - }[]; -}; - -/** - * Helper function to add getAssetById method to network config - */ -function withGetAssetById( - config: T -): T & { getAssetById(tokenId: number): T['assets'][number] | undefined } { - return { - ...config, - getAssetById(tokenId: number) { - return config.assets.find((a) => a.tokenId === tokenId); - }, - }; -} - -/** - * Testnet preset configuration (development) - * Uses testnet/signet for Bitcoin networks - */ -export const testnetPreset: UtxoNetworkPresetConfig = { - networkMap: { - mainnet: 'testnet', - utexo: 'signet', - }, - networkIdMap: { - mainnet: withGetAssetById({ - networkName: 'RGB', - networkId: 36, - assets: [ - { - assetId: 'rgb:WPRv95Nj-icdrgPp-zpQhIp_-2TyJ~Ge-k~FvuMZ-~vVnkA0', - tokenName: 'tUSD', - longName: 'USDT', - precision: 6, - tokenId: 4, - }, - ], - }), - mainnetLightning: withGetAssetById({ - networkName: 'RGB Lightning', - networkId: 94, - assets: [ - { - assetId: 'rgb:WPRv95Nj-icdrgPp-zpQhIp_-2TyJ~Ge-k~FvuMZ-~vVnkA0', - tokenName: 'tUSD', - longName: 'USDT', - precision: 6, - tokenId: 4, - }, - ], - }), - utexo: withGetAssetById({ - networkName: 'UTEXO', - networkId: 96, - assets: [ - { - assetId: 'rgb:yJW4k8si-~8JdNfl-nM91qFu-r5rH_HS-1hM7jpi-L~lBf90', - tokenName: 'tUSD', - longName: 'USDT', - precision: 6, - tokenId: 4, - }, - ], - }), - }, -}; - -/** - * Mainnet preset configuration (production) - * Uses mainnet for Bitcoin networks - * TODO: Update asset IDs and network IDs for production when available - */ -export const mainnetPreset: UtxoNetworkPresetConfig = { - networkMap: { - mainnet: 'mainnet', - utexo: 'signet', - }, - networkIdMap: { - mainnet: withGetAssetById({ - networkName: 'RGB', - networkId: 36, // TODO: Update to production network ID - assets: [ - { - assetId: 'rgb:nkHbmy97-R4cjRCe-j~VvT~E-0UQ0OW8-jOCCW6O-EqeCq9M', // TODO: Update to production asset ID - tokenName: 'tUSD', - longName: 'USDT', - precision: 6, - tokenId: 3, - }, - ], - }), - mainnetLightning: withGetAssetById({ - networkName: 'RGB Lightning', - networkId: 94, // TODO: Update to production network ID - assets: [ - { - assetId: 'rgb:nkHbmy97-R4cjRCe-j~VvT~E-0UQ0OW8-jOCCW6O-EqeCq9M', // TODO: Update to production asset ID - tokenName: 'tUSD', - longName: 'USDT', - precision: 6, - tokenId: 3, - }, - ], - }), - utexo: withGetAssetById({ - networkName: 'UTEXO', - networkId: 96, // TODO: Update to production network ID - assets: [ - { - assetId: 'rgb:0yyfySrb-TArdWKB-6Y0yhUX-dbqMpN3-NnjsV2F-2fMhOI4', // TODO: Update to production asset ID - tokenName: 'tUSD', - longName: 'USDT', - precision: 6, - tokenId: 3, - }, - ], - }), - }, -}; diff --git a/src/utexo/config/vss.ts b/src/utexo/config/vss.ts deleted file mode 100644 index d4a11c6..0000000 --- a/src/utexo/config/vss.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * VSS (Versioned Storage Service) configuration defaults and helpers for UTEXO wallet backup/restore. - */ - -import type { VssBackupConfig } from '../../types/wallet-model'; - -/** Default VSS server URL used when vssServerUrl is not set in config or restore params. */ -export const DEFAULT_VSS_SERVER_URL = 'https://vss-server.utexo.com/vss'; - -/** - * Split a base VSS config into layer1 and utexo configs (storeId_layer1, storeId_utexo). - * Same convention used by UTEXOWallet backup and restore. - */ -export function getVssConfigs(config: VssBackupConfig): { - layer1: VssBackupConfig; - utexo: VssBackupConfig; -} { - const base = { ...config }; - return { - layer1: { ...base, storeId: `${config.storeId}_layer1` }, - utexo: { ...base, storeId: `${config.storeId}_utexo` }, - }; -} diff --git a/src/utexo/index.ts b/src/utexo/index.ts deleted file mode 100644 index 85fc7b3..0000000 --- a/src/utexo/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * UTEXO module exports - * - * This module provides the UTEXOWallet class and UTEXO protocol interfaces - * for managing UTEXO-specific operations including Lightning Network and on-chain withdrawals. - */ - -export { UTEXOWallet } from './utexo-wallet'; -export type { ConfigOptions } from './config'; -export { DEFAULT_VSS_SERVER_URL } from './config'; -export { - restoreUtxoWalletFromVss, - restoreUtxoWalletFromBackup, -} from './restore'; -export { - UTEXOProtocol, - LightningProtocol, - OnchainProtocol, -} from './utexo-protocol'; -export type { - IUTEXOProtocol, - ILightningProtocol, - IOnchainProtocol, -} from './IUTEXOProtocol'; diff --git a/src/utexo/restore.ts b/src/utexo/restore.ts index ae122d1..d5fa3c7 100644 --- a/src/utexo/restore.ts +++ b/src/utexo/restore.ts @@ -1,18 +1,27 @@ /** * UTEXO wallet restore: VSS and file backup restore helpers. + * + * Pure crypto helpers (getBackupStoreId, buildVssConfigFromMnemonic) are + * re-exported from @utexo/rgb-sdk-core. Node-specific (fs/path) helpers + * stay here. */ import path from 'path'; import fs from 'fs'; import { - deriveKeysFromMnemonic, - deriveVssSigningKeyFromMnemonic, -} from '../crypto'; -import { getUtxoNetworkConfig, type UtxoNetworkPreset } from './utils/network'; -import { restoreFromVss, restoreWallet } from '../client/rgb-lib-client'; -import { ValidationError } from '../errors'; -import type { VssBackupConfig } from '../types/wallet-model'; -import { DEFAULT_VSS_SERVER_URL } from './config/vss'; + getUtxoNetworkConfig, + type UtxoNetworkPreset, + DEFAULT_VSS_SERVER_URL, +} from '@utexo/rgb-sdk-core'; +import { restoreFromVss, restoreWallet } from '../binding/NodeRgbLibBinding'; +import { ValidationError } from '@utexo/rgb-sdk-core'; +import type { VssBackupConfig } from '@utexo/rgb-sdk-core'; +import { + getBackupStoreId, + buildVssConfigFromMnemonic, +} from '@utexo/rgb-sdk-core'; + +export { getBackupStoreId, buildVssConfigFromMnemonic }; /** Backup file extension; used by createBackup and restore. */ export const BACKUP_FILE_SUFFIX = '.backup'; @@ -23,11 +32,6 @@ export const UTEXO_BACKUP_SUFFIX = '_utexo.backup'; const UTEXO_BACKUP_TMP_LAYER1 = '.layer1'; const UTEXO_BACKUP_TMP_UTEXO = '.utexo'; -/** Store id for backup/restore (same convention as VSS: wallet_). */ -export function getBackupStoreId(masterFingerprint: string): string { - return `wallet_${masterFingerprint}`; -} - export interface PrepareUtxoBackupDirsResult { storeId: string; layer1TmpDir: string; @@ -38,7 +42,6 @@ export interface PrepareUtxoBackupDirsResult { /** * Prepare backup directory and temp dirs for UTEXO createBackup. - * Creates backupPath if needed and .layer1/.utexo temp subdirs; returns paths for createBackup and final filenames. */ export function prepareUtxoBackupDirs( backupPath: string, @@ -68,7 +71,7 @@ export function prepareUtxoBackupDirs( } /** - * Move backup files from temp dirs to final paths and remove temp dirs. Call after createBackup into layer1TmpDir/utexoTmpDir. + * Move backup files from temp dirs to final paths and remove temp dirs. */ export function finalizeUtxoBackupPaths(params: { layer1BackupPath: string; @@ -92,39 +95,14 @@ export function finalizeUtxoBackupPaths(params: { fs.rmdirSync(utexoTmpDir); } -/** - * Build VSS config from mnemonic (storeId = wallet_, signingKey derived). - * Used when config is not passed to vssBackup or restoreUtxoWalletFromVss. - */ -export async function buildVssConfigFromMnemonic( - mnemonic: string, - serverUrl: string, - networkPreset: UtxoNetworkPreset = 'testnet' -): Promise { - const keys = await deriveKeysFromMnemonic(networkPreset, mnemonic.trim()); - return { - serverUrl, - storeId: `wallet_${keys.masterFingerprint}`, - signingKey: deriveVssSigningKeyFromMnemonic(mnemonic.trim()), - backupMode: 'Blocking', - }; -} - /** * Restore a UTEXOWallet from VSS by restoring both layer1 and utexo stores. - * Mnemonic is required; config is optional (built from mnemonic when omitted; vssServerUrl uses DEFAULT_VSS_SERVER_URL if omitted). - * Uses the same storeId suffix convention as UTEXOWallet VSS backup (storeId_layer1, storeId_utexo). - * Restored data is written to targetDir/{layer1Network}/{masterFingerprint} and - * targetDir/{utexoNetwork}/{masterFingerprint} (same layout as when using dataDir on UTEXOWallet). */ export async function restoreUtxoWalletFromVss(params: { mnemonic: string; targetDir: string; - /** Optional; when omitted, config is built from mnemonic (vssServerUrl defaults to DEFAULT_VSS_SERVER_URL). */ config?: VssBackupConfig; - /** Preset to derive layer1/utexo network names; defaults to 'testnet'. */ networkPreset?: UtxoNetworkPreset; - /** Optional; when omitted and config not passed, DEFAULT_VSS_SERVER_URL is used. */ vssServerUrl?: string; }): Promise<{ layer1Path: string; utexoPath: string; targetDir: string }> { const { @@ -174,9 +152,6 @@ export async function restoreUtxoWalletFromVss(params: { /** * Restore a UTEXOWallet from a regular (file) backup created by UTEXOWallet.createBackup. - * Expects one folder with wallet__layer1.backup and wallet__utexo.backup - * (same naming convention as VSS: storeId_layer1, storeId_utexo with storeId = wallet_). - * Restores into targetDir (same layout as VSS restore). */ export function restoreUtxoWalletFromBackup(params: { backupPath: string; diff --git a/src/utexo/utexo-protocol.ts b/src/utexo/utexo-protocol.ts deleted file mode 100644 index 0024ab6..0000000 --- a/src/utexo/utexo-protocol.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * UTEXO Protocol Base Implementations - * - * These classes provide empty implementations for UTEXO-specific operations. - * They should be extended or used as mixins for concrete implementations. - */ - -import type { - ILightningProtocol, - IOnchainProtocol, - IUTEXOProtocol, -} from './IUTEXOProtocol'; -import type { - CreateLightningInvoiceRequestModel, - LightningReceiveRequest, - LightningSendRequest, - GetLightningSendFeeEstimateRequestModel, - PayLightningInvoiceRequestModel, - OnchainSendRequestModel, - OnchainSendResponse, - ListLightningPaymentsResponse, - OnchainReceiveRequestModel, - OnchainReceiveResponse, -} from '../types/utexo'; -import type { - SendAssetEndRequestModel, - Transfer, - TransferStatus, - OnchainSendStatus, -} from '../types/wallet-model'; - -/** - * Lightning Protocol Base Class - * - * Provides empty implementations for all Lightning protocol methods. - * Concrete implementations should override these methods. - */ -export class LightningProtocol implements ILightningProtocol { - async createLightningInvoice( - _params: CreateLightningInvoiceRequestModel - ): Promise { - throw new Error('createLightningInvoice not implemented'); - } - - async getLightningReceiveRequest( - _id: string - ): Promise { - throw new Error('getLightningReceiveRequest not implemented'); - } - - async getLightningSendRequest(_id: string): Promise { - throw new Error('getLightningSendRequest not implemented'); - } - - async getLightningSendFeeEstimate( - _params: GetLightningSendFeeEstimateRequestModel - ): Promise { - throw new Error('getLightningSendFeeEstimate not implemented'); - } - - async payLightningInvoiceBegin( - _params: PayLightningInvoiceRequestModel - ): Promise { - throw new Error('payLightningInvoiceBegin not implemented'); - } - - async payLightningInvoiceEnd( - _params: SendAssetEndRequestModel - ): Promise { - throw new Error('payLightningInvoiceEnd not implemented'); - } - - async payLightningInvoice( - _params: PayLightningInvoiceRequestModel, - _mnemonic?: string - ): Promise { - throw new Error('payLightningInvoice not implemented'); - } - - async listLightningPayments(): Promise { - throw new Error('listLightningPayments not implemented'); - } -} - -/** - * Onchain Protocol Base Class - * - * Provides empty implementations for all onchain protocol methods. - * Concrete implementations should override these methods. - */ -export class OnchainProtocol implements IOnchainProtocol { - async onchainReceive( - _params: OnchainReceiveRequestModel - ): Promise { - throw new Error('onchainReceive not implemented'); - } - async onchainSendBegin(_params: OnchainSendRequestModel): Promise { - throw new Error('onchainSendBegin not implemented'); - } - - async onchainSendEnd( - _params: SendAssetEndRequestModel - ): Promise { - throw new Error('onchainSendEnd not implemented'); - } - - async onchainSend( - _params: OnchainSendRequestModel, - _mnemonic?: string - ): Promise { - throw new Error('onchainSend not implemented'); - } - - async getOnchainSendStatus( - _send_id: string - ): Promise { - throw new Error('getOnchainSendStatus not implemented'); - } - - async listOnchainTransfers(_asset_id?: string): Promise { - throw new Error('listOnchainTransfers not implemented'); - } -} - -/** - * UTEXO Protocol Base Class - * - * Combines Lightning and Onchain protocol implementations. - * Provides empty implementations for all UTEXO protocol methods. - * Concrete implementations should override these methods. - */ -export class UTEXOProtocol extends LightningProtocol implements IUTEXOProtocol { - private onchainProtocol = new OnchainProtocol(); - - async onchainReceive( - params: OnchainReceiveRequestModel - ): Promise { - return this.onchainProtocol.onchainReceive(params); - } - async onchainSendBegin(params: OnchainSendRequestModel): Promise { - return this.onchainProtocol.onchainSendBegin(params); - } - - async onchainSendEnd( - params: SendAssetEndRequestModel - ): Promise { - return this.onchainProtocol.onchainSendEnd(params); - } - - async onchainSend( - params: OnchainSendRequestModel, - mnemonic?: string - ): Promise { - return this.onchainProtocol.onchainSend(params, mnemonic); - } - - async getOnchainSendStatus( - send_id: string - ): Promise { - return this.onchainProtocol.getOnchainSendStatus(send_id); - } - - async listOnchainTransfers(asset_id?: string): Promise { - return this.onchainProtocol.listOnchainTransfers(asset_id); - } -} diff --git a/src/utexo/utexo-wallet.ts b/src/utexo/utexo-wallet.ts index 05a653b..e38936a 100644 --- a/src/utexo/utexo-wallet.ts +++ b/src/utexo/utexo-wallet.ts @@ -1,152 +1,46 @@ /** - * UTEXOWallet - Wallet class for UTEXO operations + * UTEXOWallet — Node SDK concrete implementation of UTEXOWalletCore. * - * This class provides a wallet interface that accepts either a mnemonic or seed - * for initialization. It implements both IWalletManager (standard RGB operations) - * and IUTEXOProtocol (UTEXO-specific Lightning and on-chain operations). + * Extends UTEXOWalletCore from @utexo/rgb-sdk-core, overriding: + * - initialize(): creates Node WalletManager instances + * - createBackup(): dual backup (layer1 + utexo files) */ -import { PublicKeys } from '../types/utexo'; -import { deriveKeysFromMnemonicOrSeed } from '../crypto'; -import type { Network, EstimateFeeResult } from '../crypto'; -import { BitcoinNetwork } from '../types/wallet-model'; -import { getDestinationAsset } from '../constants'; +import { UTEXOWalletCore } from '@utexo/rgb-sdk-core'; import { WalletManager } from '../wallet/wallet-manager'; -import { ValidationError, WalletError } from '../errors'; -import type { IWalletManager } from '../wallet/IWalletManager'; -import type { IUTEXOProtocol } from './IUTEXOProtocol'; -import { UTEXOProtocol } from './utexo-protocol'; -import { - getUtxoNetworkConfig, - type UtxoNetworkPreset, - type UtxoNetworkMap, - type UtxoNetworkIdMap, -} from './utils/network'; +import type { WalletBackupResponse } from '@utexo/rgb-sdk-core'; +import { ValidationError } from '@utexo/rgb-sdk-core'; import path from 'path'; +import { prepareUtxoBackupDirs, finalizeUtxoBackupPaths } from './restore'; -export { UTEXOProtocol } from './utexo-protocol'; -export type { IUTEXOProtocol } from './IUTEXOProtocol'; -import type { - CreateLightningInvoiceRequestModel, - LightningReceiveRequest, - LightningSendRequest, - PayLightningInvoiceRequestModel, - OnchainSendRequestModel, - OnchainSendResponse, - OnchainReceiveRequestModel, - OnchainReceiveResponse, - OnchainSendEndRequestModel, - PayLightningInvoiceEndRequestModel, -} from '../types/utexo'; -import type { - CreateUtxosBeginRequestModel, - CreateUtxosEndRequestModel, - FailTransfersRequest, - InvoiceRequest, - InvoiceReceiveData, - IssueAssetNiaRequestModel, - IssueAssetIfaRequestModel, - SendAssetBeginRequestModel, - SendAssetEndRequestModel, - SendResult, - BtcBalance, - Unspent, - WalletBackupResponse, - SendBtcBeginRequestModel, - SendBtcEndRequestModel, - GetFeeEstimationResponse, - InflateAssetIfaRequestModel, - InflateEndRequestModel, - OperationResult, - AssetNIA, - AssetBalance, - ListAssets, - Transaction, - Transfer, - InvoiceData, - OnchainSendStatus, - TransferStatus, - VssBackupConfig, - VssBackupInfo, -} from '../types/wallet-model'; -import { getBridgeAPI } from './bridge'; -import { TransferByMainnetInvoiceResponse } from './bridge/types'; -import { NetworkAsset } from './utils/network'; -import { - decodeBridgeInvoice, - fromUnitsNumber, - toUnitsNumber, -} from './utils/helpers'; -import { DEFAULT_VSS_SERVER_URL, getVssConfigs } from './config/vss'; -import type { ConfigOptions } from './config/options'; -import { - buildVssConfigFromMnemonic, - prepareUtxoBackupDirs, - finalizeUtxoBackupPaths, -} from './restore'; +export { UTEXOProtocol } from '@utexo/rgb-sdk-core'; +export type { IUTEXOProtocol } from '@utexo/rgb-sdk-core'; -/** - * UTEXOWallet - Combines standard RGB wallet operations with UTEXO protocol features - * - * Architecture: - * - Implements IWalletManager for standard RGB operations (via WalletManager delegation) - * - Implements IUTEXOProtocol for UTEXO-specific operations (Lightning, on-chain sends) - * - Manages two WalletManager instances: layer1 (Bitcoin) and utexo (UTEXO network) - */ -export class UTEXOWallet - extends UTEXOProtocol - implements IWalletManager, IUTEXOProtocol -{ - private readonly mnemonicOrSeed: string | Uint8Array; - private readonly options: ConfigOptions; - private readonly networkMap: UtxoNetworkMap; - private readonly networkIdMap: UtxoNetworkIdMap; - private readonly bridge: ReturnType; - private layer1Keys: PublicKeys | null = null; - private utexoKeys: PublicKeys | null = null; - private layer1RGBWallet: WalletManager | null = null; - private utexoRGBWallet: WalletManager | null = null; - - /** - * Creates a new UTEXOWallet instance - * @param mnemonicOrSeed - Either a mnemonic phrase (string) or seed (Uint8Array) - * @param options - Optional configuration options (defaults to { network: 'mainnet' }) - */ - constructor( - mnemonicOrSeed: string | Uint8Array, - options: ConfigOptions = {} - ) { - super(); - this.mnemonicOrSeed = mnemonicOrSeed; - this.options = options; - - const preset: UtxoNetworkPreset = options.network ?? 'mainnet'; - - const networkConfig = getUtxoNetworkConfig(preset); - this.networkMap = networkConfig.networkMap; - this.networkIdMap = networkConfig.networkIdMap; - this.bridge = getBridgeAPI(preset); - } +export class UTEXOWallet extends UTEXOWalletCore { + private _masterFingerprint: string | null = null; async initialize(): Promise { - this.layer1Keys = await this.derivePublicKeys(this.networkMap.mainnet); - this.utexoKeys = await this.derivePublicKeys(this.networkMap.utexo); - const fp = this.utexoKeys.masterFingerprint; + const layer1Keys = await this.derivePublicKeys(this.networkMap.mainnet); + const utexoKeys = await this.derivePublicKeys(this.networkMap.utexo); + this._masterFingerprint = utexoKeys.masterFingerprint; + const fp = utexoKeys.masterFingerprint; const dataDir = this.options.dataDir; - this.utexoRGBWallet = new WalletManager({ - xpubVan: this.utexoKeys.accountXpubVanilla, - xpubCol: this.utexoKeys.accountXpubColored, - masterFingerprint: this.utexoKeys.masterFingerprint, + + this.utexoWallet = new WalletManager({ + xpubVan: utexoKeys.accountXpubVanilla, + xpubCol: utexoKeys.accountXpubColored, + masterFingerprint: utexoKeys.masterFingerprint, network: this.networkMap.utexo, mnemonic: this.mnemonicOrSeed as string, dataDir: dataDir ? path.join(dataDir, String(this.networkMap.utexo), fp) : undefined, }); - this.layer1RGBWallet = new WalletManager({ - xpubVan: this.layer1Keys.accountXpubVanilla, - xpubCol: this.layer1Keys.accountXpubColored, - masterFingerprint: this.layer1Keys.masterFingerprint, + + this.layer1Wallet = new WalletManager({ + xpubVan: layer1Keys.accountXpubVanilla, + xpubCol: layer1Keys.accountXpubColored, + masterFingerprint: layer1Keys.masterFingerprint, network: this.networkMap.mainnet, mnemonic: this.mnemonicOrSeed as string, dataDir: dataDir @@ -155,240 +49,12 @@ export class UTEXOWallet }); } - /** - * Derive public keys from mnemonic or seed - * @param network - BitcoinNetwork identifier - * @returns Promise resolving to PublicKeys containing xpub, accountXpubVanilla, accountXpubColored, and masterFingerprint - * @throws {ValidationError} If mnemonic is invalid - */ - async derivePublicKeys(network: BitcoinNetwork): Promise { - const generatedKeys = await deriveKeysFromMnemonicOrSeed( - network, - this.mnemonicOrSeed - ); - const { xpub, accountXpubVanilla, accountXpubColored, masterFingerprint } = - generatedKeys; - return { xpub, accountXpubVanilla, accountXpubColored, masterFingerprint }; - } - - async getPubKeys(): Promise { - if (!this.layer1Keys) { - throw new ValidationError('Public keys are not set', 'publicKeys'); - } - return this.layer1Keys; - } - - /** - * Guard method to ensure wallet is initialized - * @throws {WalletError} if wallet is not initialized - */ - private ensureInitialized(): void { - if (!this.utexoRGBWallet) { - throw new WalletError('Wallet not initialized. Call initialize() first.'); - } - } - - // ========================================== - // IWalletManager Implementation - // ========================================== - - async goOnline(): Promise { - this.ensureInitialized(); - // TODO: Implement goOnline for UTEXO wallet - throw new Error('goOnline not implemented'); - } - - getXpub(): { xpubVan: string; xpubCol: string } { - this.ensureInitialized(); - return this.utexoRGBWallet!.getXpub(); - } - - getNetwork(): Network { - this.ensureInitialized(); - return this.utexoRGBWallet!.getNetwork(); - } - - async dispose(): Promise { - if (this.layer1RGBWallet) { - await this.layer1RGBWallet.dispose(); - } - if (this.utexoRGBWallet) { - await this.utexoRGBWallet.dispose(); - } - } - - isDisposed(): boolean { - if (!this.utexoRGBWallet) { - return false; - } - return this.utexoRGBWallet.isDisposed(); - } - - async getBtcBalance(): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.getBtcBalance(); - } - - async getAddress(): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.getAddress(); - } - - async listUnspents(): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.listUnspents(); - } - - async createUtxosBegin( - params: CreateUtxosBeginRequestModel - ): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.createUtxosBegin(params); - } - - async createUtxosEnd(params: CreateUtxosEndRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.createUtxosEnd(params); - } - - async createUtxos(params: { - upTo?: boolean; - num?: number; - size?: number; - feeRate?: number; - }): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.createUtxos(params); - } - - async listAssets(): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.listAssets(); - } - - async getAssetBalance(asset_id: string): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.getAssetBalance(asset_id); - } - - async issueAssetNia(params: IssueAssetNiaRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.issueAssetNia(params); - } - - async issueAssetIfa(params: IssueAssetIfaRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.issueAssetIfa(params); - } - - async inflateBegin(params: InflateAssetIfaRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.inflateBegin(params); - } - - async inflateEnd(params: InflateEndRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.inflateEnd(params); - } - - async inflate( - params: InflateAssetIfaRequestModel, - mnemonic?: string - ): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.inflate(params, mnemonic); - } - - async sendBegin(params: SendAssetBeginRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.sendBegin(params); - } - - async sendEnd(params: SendAssetEndRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.sendEnd(params); - } - - async send( - invoiceTransfer: SendAssetBeginRequestModel, - mnemonic?: string - ): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.send(invoiceTransfer, mnemonic); - } - - async sendBtcBegin(params: SendBtcBeginRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.sendBtcBegin(params); - } - - async sendBtcEnd(params: SendBtcEndRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.sendBtcEnd(params); - } - - async sendBtc(params: SendBtcBeginRequestModel): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.sendBtc(params); - } - - async blindReceive(params: InvoiceRequest): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.blindReceive(params); - } - - async witnessReceive(params: InvoiceRequest): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.witnessReceive(params); - } - - async decodeRGBInvoice(params: { invoice: string }): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.decodeRGBInvoice(params); - } - - async listTransactions(): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.listTransactions(); - } - - async listTransfers(asset_id?: string): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.listTransfers(asset_id); - } - - async failTransfers(params: FailTransfersRequest): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.failTransfers(params); - } - - async refreshWallet(): Promise { - this.ensureInitialized(); - this.utexoRGBWallet!.refreshWallet(); - } - - async syncWallet(): Promise { - this.ensureInitialized(); - this.utexoRGBWallet!.syncWallet(); - } - - async estimateFeeRate(blocks: number): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.estimateFeeRate(blocks); - } - - async estimateFee(psbtBase64: string): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.estimateFee(psbtBase64); - } - /** * Create backup for both layer1 and utexo stores in one folder. - * Writes backupPath/wallet_{masterFingerprint}_layer1.backup and backupPath/wallet_{masterFingerprint}_utexo.backup - * (same naming convention as VSS: storeId_layer1, storeId_utexo with storeId = wallet_). - * Use restoreUtxoWalletFromBackup with the same backupPath to restore both. + * Writes backupPath/wallet_{masterFingerprint}_layer1.backup and + * backupPath/wallet_{masterFingerprint}_utexo.backup */ - async createBackup(params: { + override async createBackup(params: { backupPath: string; password: string; }): Promise< @@ -402,14 +68,14 @@ export class UTEXOWallet 'createBackup' ); } - const fp = this.utexoKeys!.masterFingerprint; + const fp = this._masterFingerprint!; const { layer1TmpDir, utexoTmpDir, layer1FinalPath, utexoFinalPath } = prepareUtxoBackupDirs(backupPath, fp); - const layer1Result = await this.layer1RGBWallet!.createBackup({ + const layer1Result = await this.layer1Wallet!.createBackup({ backupPath: layer1TmpDir, password, }); - const utexoResult = await this.utexoRGBWallet!.createBackup({ + const utexoResult = await this.utexoWallet!.createBackup({ backupPath: utexoTmpDir, password, }); @@ -428,701 +94,4 @@ export class UTEXOWallet utexoBackupPath: utexoFinalPath, }; } - - async configureVssBackup(config: VssBackupConfig): Promise { - this.ensureInitialized(); - const { layer1, utexo } = getVssConfigs(config); - await this.layer1RGBWallet!.configureVssBackup(layer1); - await this.utexoRGBWallet!.configureVssBackup(utexo); - } - - async disableVssAutoBackup(): Promise { - this.ensureInitialized(); - await this.layer1RGBWallet!.disableVssAutoBackup(); - await this.utexoRGBWallet!.disableVssAutoBackup(); - } - - /** - * Run VSS backup for both layer1 and utexo stores. - * Config is optional: when omitted, builds config from mnemonic (option param or wallet mnemonic) - * and options.vssServerUrl (or DEFAULT_VSS_SERVER_URL if not set). - * - * @param config - Optional; when omitted, built from mnemonic and vssServerUrl - * @param mnemonic - Optional; when omitted, uses wallet mnemonic (only if wallet was created with mnemonic string) - */ - async vssBackup( - config?: VssBackupConfig, - mnemonic?: string - ): Promise { - this.ensureInitialized(); - let vssConfig: VssBackupConfig; - if (config) { - vssConfig = config; - } else { - const mnemonicToUse = - mnemonic ?? - (typeof this.mnemonicOrSeed === 'string' ? this.mnemonicOrSeed : null); - if (!mnemonicToUse) { - throw new ValidationError( - 'mnemonic is required for VSS backup when config is not passed (wallet was created with seed)', - 'mnemonic' - ); - } - const serverUrl = this.options.vssServerUrl ?? DEFAULT_VSS_SERVER_URL; - const preset: UtxoNetworkPreset = this.options.network ?? 'mainnet'; - vssConfig = await buildVssConfigFromMnemonic( - mnemonicToUse.trim(), - serverUrl, - preset - ); - } - const { layer1, utexo } = getVssConfigs(vssConfig); - await this.layer1RGBWallet!.vssBackup(layer1); - const version = await this.utexoRGBWallet!.vssBackup(utexo); - return version; - } - - /** - * Get VSS backup info. Config is optional; when omitted, built from mnemonic (param or wallet) - * and options.vssServerUrl (or DEFAULT_VSS_SERVER_URL if not set). - */ - async vssBackupInfo( - config?: VssBackupConfig, - mnemonic?: string - ): Promise { - this.ensureInitialized(); - let vssConfig: VssBackupConfig; - if (config) { - vssConfig = config; - } else { - const mnemonicToUse = - mnemonic ?? - (typeof this.mnemonicOrSeed === 'string' ? this.mnemonicOrSeed : null); - if (!mnemonicToUse) { - throw new ValidationError( - 'config or mnemonic required for vssBackupInfo', - 'config' - ); - } - const serverUrl = this.options.vssServerUrl ?? DEFAULT_VSS_SERVER_URL; - const preset: UtxoNetworkPreset = this.options.network ?? 'mainnet'; - vssConfig = await buildVssConfigFromMnemonic( - mnemonicToUse.trim(), - serverUrl, - preset - ); - } - const { utexo } = getVssConfigs(vssConfig); - return this.utexoRGBWallet!.vssBackupInfo(utexo); - } - - async signPsbt(psbt: string, mnemonic?: string): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.signPsbt(psbt, mnemonic); - } - - async signMessage(message: string): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.signMessage(message); - } - - async verifyMessage( - message: string, - signature: string, - accountXpub?: string - ): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.verifyMessage(message, signature, accountXpub); - } - - /** - * Validates that the wallet has sufficient spendable balance for the given asset and amount. - * @param assetId - Asset ID to check balance for - * @param amount - Required amount (in asset units) - * @throws {ValidationError} If balance is not found or insufficient - */ - async validateBalance(assetId: string, amount: number): Promise { - const assetBalance = await this.getAssetBalance(assetId); - if (!assetBalance || !assetBalance.spendable) { - throw new ValidationError('Asset balance is not found', 'assetBalance'); - } - if (assetBalance.spendable < amount) { - throw new ValidationError( - `Insufficient balance ${assetBalance.spendable} < ${amount}`, - 'amount' - ); - } - } - /** - * Extracts invoice data and destination asset from a bridge transfer. - * - * @param bridgeTransfer - Bridge transfer response containing recipient invoice and token info - * @returns Object containing invoice string, decoded invoice data, and destination asset - * @throws {ValidationError} If destination asset is not supported - */ - private async extractInvoiceAndAsset( - bridgeTransfer: TransferByMainnetInvoiceResponse - ): Promise<{ - utexoInvoice: string; - invoiceData: InvoiceData; - destinationAsset: NetworkAsset; - }> { - const utexoInvoice = bridgeTransfer.recipient.address; - const invoiceData = await this.decodeRGBInvoice({ invoice: utexoInvoice }); - const destinationAsset = this.networkIdMap.utexo.getAssetById( - bridgeTransfer.recipientToken.id - ); - - if (!destinationAsset) { - throw new ValidationError( - 'Destination asset is not supported', - 'assetId' - ); - } - - return { utexoInvoice, invoiceData, destinationAsset }; - } - - /** - * IUTEXOProtocol Implementation - */ - - async onchainReceive( - params: OnchainReceiveRequestModel - ): Promise { - this.ensureInitialized(); - - const destinationAsset = getDestinationAsset( - 'mainnet', - 'utexo', - params.assetId ?? null, - this.networkIdMap - ); - if (!destinationAsset) { - throw new ValidationError( - 'Destination asset is not supported', - 'assetId' - ); - } - - if (!params.amount) { - throw new ValidationError('Amount is required', 'amount'); - } - - const destinationInvoice = await this.utexoRGBWallet!.witnessReceive({ - assetId: '', //invoice can receive any asset - amount: params.amount, - minConfirmations: params.minConfirmations, - durationSeconds: params.durationSeconds, - }); - - const bridgeTransfer = await this.bridge.getBridgeInSignature({ - sender: { - address: 'rgb-address', - networkName: this.networkIdMap.mainnet.networkName, - networkId: this.networkIdMap.mainnet.networkId, - }, - tokenId: destinationAsset.tokenId, - amount: params.amount.toString(), - destination: { - address: destinationInvoice.invoice, - networkName: this.networkIdMap.utexo.networkName, - networkId: this.networkIdMap.utexo.networkId, - }, - additionalAddresses: [], - }); - - const decodedInvoice = decodeBridgeInvoice(bridgeTransfer.signature); - - return { - invoice: decodedInvoice, - }; - } - - async onchainSendBegin(params: OnchainSendRequestModel): Promise { - this.ensureInitialized(); - /** Get the bridge RGB utexo invoice by tempRequestId should be by invoice */ - const bridgeTransfer = await this.bridge.getTransferByMainnetInvoice( - params.invoice, - this.networkIdMap.mainnet.networkId - ); - if (!bridgeTransfer) { - console.log('External invoice UTEXO -> Mainnet initiated'); - return this.UTEXOToMainnetRGB(params); - } - const utexoInvoice = bridgeTransfer.recipient.address; - const invoiceData = await this.decodeRGBInvoice({ invoice: utexoInvoice }); - const bridgeAmount = bridgeTransfer.recipientAmount; - const destinationAsset = this.networkIdMap.utexo.getAssetById( - bridgeTransfer.recipientToken.id - ); - if (!destinationAsset) { - throw new ValidationError( - 'Destination asset is not supported', - 'assetId' - ); - } - // const amount = invoiceData.assignment.amount; - const amount = toUnitsNumber(bridgeAmount, destinationAsset.precision); - - const isWitness = invoiceData.recipientId.includes('wvout:'); - - await this.validateBalance(destinationAsset.assetId, amount); - - const psbt = await this.utexoRGBWallet!.sendBegin({ - invoice: utexoInvoice, - amount: amount, - assetId: destinationAsset.assetId, - donation: true, - ...(isWitness && { - witnessData: { - amountSat: 1000, - blinding: 0, - }, - }), - }); - - return psbt; - } - - async onchainSendEnd( - params: OnchainSendEndRequestModel - ): Promise { - this.ensureInitialized(); - const sendResult = await this.utexoRGBWallet!.sendEnd({ - signedPsbt: params.signedPsbt, - }); - - // TODO: there should be func that allow to cancel or mark as paid Tricorn Bridge Transfer - // Best-effort finalize bridge transfer (complete/cancel/status) via BridgeClient (depending on Tricorn semantics) - - return sendResult; - } - - async onchainSend( - params: OnchainSendRequestModel, - mnemonic?: string - ): Promise { - this.ensureInitialized(); - const psbt = await this.onchainSendBegin(params); - const signed_psbt = await this.utexoRGBWallet!.signPsbt(psbt, mnemonic); - return await this.onchainSendEnd({ - signedPsbt: signed_psbt, - invoice: params.invoice, - }); - } - - async getOnchainSendStatus( - invoice: string - ): Promise { - const bridgeTransfer = await this.bridge.getTransferByMainnetInvoice( - invoice, - this.networkIdMap.mainnet.networkId - ); - if (!bridgeTransfer) { - const withdrawTransfer = await this.bridge.getWithdrawTransfer( - invoice, - this.networkIdMap.utexo.networkId - ); - if (!withdrawTransfer) { - return null; - } - return withdrawTransfer.status as OnchainSendStatus; - } - const { invoiceData, destinationAsset } = - await this.extractInvoiceAndAsset(bridgeTransfer); - const assets = await this.utexoRGBWallet!.listAssets(); - const walletAsset = assets.nia.find( - (a) => a.assetId === destinationAsset.assetId - ); - const transfers = await this.utexoRGBWallet!.listTransfers( - walletAsset?.assetId - ); - const transfer = transfers.find( - (transfer) => transfer.recipientId === invoiceData.recipientId - ); - if (transfer) { - return transfer.status; - } - if (bridgeTransfer) { - return bridgeTransfer.status as OnchainSendStatus; - } - return null; - } - - async listOnchainTransfers(asset_id?: string): Promise { - this.ensureInitialized(); - return this.utexoRGBWallet!.listTransfers(asset_id); - } - - async createLightningInvoice( - params: CreateLightningInvoiceRequestModel - ): Promise { - this.ensureInitialized(); - - const asset = params.asset; - if (!asset) { - throw new ValidationError('Asset is required', 'asset'); - } - - if (!asset.assetId) { - throw new ValidationError('Asset ID is required', 'assetId'); - } - if (!asset.amount) { - throw new ValidationError('Amount is required', 'amount'); - } - - const destinationAsset = getDestinationAsset( - 'mainnet', - 'utexo', - asset.assetId, - this.networkIdMap - ); - if (!destinationAsset) { - throw new ValidationError( - 'Destination asset is not supported', - 'assetId' - ); - } - - const destinationInvoice = await this.utexoRGBWallet!.witnessReceive({ - assetId: '', //invoice can receive any asset - amount: asset.amount, - }); - - const bridgeTransfer = await this.bridge.getBridgeInSignature({ - sender: { - address: 'rgb-address', - networkName: this.networkIdMap.mainnetLightning.networkName, - networkId: this.networkIdMap.mainnetLightning.networkId, - }, - tokenId: destinationAsset.tokenId, - amount: asset.amount.toString(), - destination: { - address: destinationInvoice.invoice, - networkName: this.networkIdMap.utexo.networkName, - networkId: this.networkIdMap.utexo.networkId, - }, - additionalAddresses: [], - }); - - const decodedLnInvoice = decodeBridgeInvoice(bridgeTransfer.signature); - - return { - lnInvoice: decodedLnInvoice, - }; - } - - async payLightningInvoiceBegin( - params: PayLightningInvoiceRequestModel - ): Promise { - this.ensureInitialized(); - - const bridgeTransfer = await this.bridge.getTransferByMainnetInvoice( - params.lnInvoice, - this.networkIdMap.mainnetLightning.networkId - ); - if (!bridgeTransfer) { - console.log('External invoice UTEXO -> Mainnet Lightning initiated'); - return this.UtexoToMainnetLightning(params); - } - - const bridgeAmount = bridgeTransfer.recipientAmount; - const { utexoInvoice, invoiceData, destinationAsset } = - await this.extractInvoiceAndAsset(bridgeTransfer); - const amount = toUnitsNumber(bridgeAmount, destinationAsset.precision); - - const isWitness = invoiceData.recipientId.includes('wvout:'); - - const psbt = await this.utexoRGBWallet!.sendBegin({ - invoice: utexoInvoice, - amount: amount, - assetId: destinationAsset.assetId, - donation: true, - ...(isWitness && { - witnessData: { - amountSat: 1000, - blinding: 0, - }, - }), - }); - - return psbt; - } - - async payLightningInvoiceEnd( - params: PayLightningInvoiceEndRequestModel - ): Promise { - this.ensureInitialized(); - const sendResult = await this.utexoRGBWallet!.sendEnd({ - signedPsbt: params.signedPsbt, - }); - // TODO: there should be func that allow to cancel or mark as paid Tricorn Bridge Transfer - // Best-effort finalize bridge transfer (complete/cancel/status) via BridgeClient (depending on Tricorn semantics) - - return sendResult; - } - - async payLightningInvoice( - params: PayLightningInvoiceRequestModel, - mnemonic?: string - ): Promise { - this.ensureInitialized(); - const psbt = await this.payLightningInvoiceBegin(params); - const signed_psbt = await this.utexoRGBWallet!.signPsbt(psbt, mnemonic); - return await this.payLightningInvoiceEnd({ - signedPsbt: signed_psbt, - lnInvoice: params.lnInvoice, - }); - } - - async getLightningSendRequest( - lnInvoice: string - ): Promise { - this.ensureInitialized(); - const bridgeTransfer = await this.bridge.getTransferByMainnetInvoice( - lnInvoice, - this.networkIdMap.mainnetLightning.networkId - ); - if (!bridgeTransfer) { - const withdrawTransfer = await this.bridge.getWithdrawTransfer( - lnInvoice, - this.networkIdMap.utexo.networkId - ); - if (!withdrawTransfer) { - return null; - } - return withdrawTransfer.status as TransferStatus; - } - const { invoiceData, destinationAsset } = - await this.extractInvoiceAndAsset(bridgeTransfer); - const transfers = await this.utexoRGBWallet!.listTransfers( - destinationAsset.assetId - ); - return transfers.length > 0 - ? (transfers.find( - (transfer) => transfer.recipientId === invoiceData.recipientId - )?.status ?? null) - : null; - } - async getLightningReceiveRequest( - lnInvoice: string - ): Promise { - this.ensureInitialized(); - const bridgeTransfer = await this.bridge.getTransferByMainnetInvoice( - lnInvoice, - this.networkIdMap.mainnetLightning.networkId - ); - if (!bridgeTransfer) { - const withdrawTransfer = await this.bridge.getWithdrawTransfer( - lnInvoice, - this.networkIdMap.utexo.networkId - ); - if (!withdrawTransfer) { - return null; - } - return withdrawTransfer.status as TransferStatus; - } - const { invoiceData, destinationAsset } = - await this.extractInvoiceAndAsset(bridgeTransfer); - const transfers = await this.utexoRGBWallet!.listTransfers( - destinationAsset.assetId - ); - return transfers.length > 0 - ? (transfers.find( - (transfer) => transfer.recipientId === invoiceData.recipientId - )?.status ?? null) - : null; - } - - private async UTEXOToMainnetRGB( - params: OnchainSendRequestModel - ): Promise { - this.ensureInitialized(); - const invoiceData = await this.decodeRGBInvoice({ - invoice: params.invoice, - }); - if (!params.assetId && !invoiceData.assetId) { - throw new ValidationError( - 'Asset ID is required for external invoice', - 'assetId' - ); - } - const assetId = params.assetId ?? invoiceData.assetId; - const utexoAsset = getDestinationAsset( - 'mainnet', - 'utexo', - assetId ?? null, - this.networkIdMap - ); - if (!utexoAsset) { - throw new ValidationError('UTEXO asset is not supported', 'assetId'); - } - - const destinationAsset = this.networkIdMap.mainnet.getAssetById( - utexoAsset?.tokenId ?? 0 - ); - // return; - if (!destinationAsset) { - throw new ValidationError( - 'Destination asset is not supported', - 'assetId' - ); - } - - if (!params.amount && !invoiceData.assignment.amount) { - throw new ValidationError( - 'Amount is required for external invoice', - 'amount' - ); - } - - let amount: number; - if (params.amount) { - amount = params.amount; - } else if (invoiceData.assignment.amount) { - amount = fromUnitsNumber( - invoiceData.assignment.amount, - destinationAsset.precision - ); - } else { - throw new ValidationError('Amount is required', 'amount'); - } - - await this.validateBalance( - utexoAsset.assetId, - toUnitsNumber(amount.toString(), utexoAsset.precision) - ); - - const payload = { - sender: { - address: 'rgb-address', - networkName: this.networkIdMap.utexo.networkName, - networkId: this.networkIdMap.utexo.networkId, - }, - tokenId: destinationAsset.tokenId, - amount: amount.toString(), - destination: { - address: params.invoice, - networkName: this.networkIdMap.mainnet.networkName, - networkId: this.networkIdMap.mainnet.networkId, - }, - additionalAddresses: [], - }; - - console.log('payload', payload); - - const bridgeOutTransfer = await this.bridge.getBridgeInSignature(payload); - const decodedInvoice = decodeBridgeInvoice(bridgeOutTransfer.signature); - const isWitness = decodedInvoice.includes('wvout:'); - - const psbt = await this.utexoRGBWallet!.sendBegin({ - invoice: decodedInvoice, - amount: Number(bridgeOutTransfer.amount), - assetId: utexoAsset.assetId, - donation: true, - ...(isWitness && { - witnessData: { - amountSat: 1000, - blinding: 0, - }, - }), - }); - - return psbt; - } - - private async UtexoToMainnetLightning( - params: PayLightningInvoiceRequestModel - ): Promise { - this.ensureInitialized(); - if (!params.assetId) { - throw new ValidationError( - 'Asset ID is required for external invoice', - 'assetId' - ); - } - const assetId = params.assetId; - const utexoAsset = getDestinationAsset( - 'mainnet', - 'utexo', - assetId ?? null, - this.networkIdMap - ); - const destinationAsset = this.networkIdMap.mainnet.getAssetById( - utexoAsset?.tokenId ?? 0 - ); - - if (!destinationAsset) { - throw new ValidationError( - 'Destination asset is not supported', - 'assetId' - ); - } - if (!utexoAsset) { - throw new ValidationError( - 'Destination asset is not supported', - 'assetId' - ); - } - - if (!params.amount) { - throw new ValidationError( - 'Amount is required for external invoice', - 'amount' - ); - } - - const amount = params.amount; - - await this.validateBalance( - utexoAsset.assetId, - toUnitsNumber(amount.toString(), utexoAsset.precision) - ); - - const bridgeOutTransfer = await this.bridge.getBridgeInSignature({ - sender: { - address: 'rgb-address', - networkName: this.networkIdMap.utexo.networkName, - networkId: this.networkIdMap.utexo.networkId, - }, - tokenId: destinationAsset.tokenId, - amount: amount.toString(), - destination: { - address: params.lnInvoice, - networkName: this.networkIdMap.mainnetLightning.networkName, - networkId: this.networkIdMap.mainnetLightning.networkId, - }, - additionalAddresses: [], - }); - const decodedInvoice = decodeBridgeInvoice(bridgeOutTransfer.signature); - const isWitness = decodedInvoice.includes('wvout:'); - - const psbt = await this.utexoRGBWallet!.sendBegin({ - invoice: decodedInvoice, - amount: Number(bridgeOutTransfer.amount), - assetId: utexoAsset.assetId, - donation: true, - ...(isWitness && { - witnessData: { - amountSat: 1000, - blinding: 0, - }, - }), - }); - - return psbt; - } - // TODO: Implement remaining methods as needed: - // - createLightningInvoice() - will use utexoRGBWallet - // - getLightningReceiveRequest() - will use utexoRGBWallet - // - getLightningSendRequest() - will use utexoRGBWallet - // - getLightningSendFeeEstimate() - will use utexoRGBWallet - // - payLightningInvoiceBegin() - will use utexoRGBWallet - // - payLightningInvoiceEnd() - will use utexoRGBWallet - // - onchainSendBegin() - will use layer1RGBWallet or utexoRGBWallet - // - onchainSendEnd() - will use layer1RGBWallet or utexoRGBWallet - // - getOnchainSendStatus() - will use layer1RGBWallet or utexoRGBWallet - // - listOnchainTransfers() - will use layer1RGBWallet or utexoRGBWallet - // - listLightningPayments() - will use utexoRGBWallet } diff --git a/src/utexo/utils/helpers.ts b/src/utexo/utils/helpers.ts deleted file mode 100644 index 1d69b2b..0000000 --- a/src/utexo/utils/helpers.ts +++ /dev/null @@ -1,41 +0,0 @@ -const UTXO_PATH_INDEX = 2; - -export function toUnitsNumber(value: string, precision: number) { - const s = String(value).trim(); - const neg = s.startsWith('-'); - const [iRaw, fRaw = ''] = (neg ? s.slice(1) : s).split('.'); - const frac = (fRaw + '0'.repeat(precision)).slice(0, precision); - - const unitsStr = (iRaw || '0') + frac; - const units = Number(unitsStr); - - if (!Number.isSafeInteger(units)) { - throw new Error( - `Amount exceeds MAX_SAFE_INTEGER. Use BigInt instead. got=${unitsStr}` - ); - } - - return neg ? -units : units; -} - -export function fromUnitsNumber(units: number, precision: number) { - const neg = units < 0; - const base = 10 ** precision; - - const value = Math.abs(units) / base; - return neg ? -value : value; -} - -/** - * Decodes a hex invoice from bridge transfer signature. - * Handles hex strings that may start with '0x' prefix. - * - * @param hexInvoice - Hex string from bridge transfer signature - * @returns Decoded UTF-8 string invoice - */ -export function decodeBridgeInvoice(hexInvoice: string): string { - const hex = hexInvoice.startsWith('0x') - ? hexInvoice.slice(UTXO_PATH_INDEX) - : hexInvoice; - return Buffer.from(hex, 'hex').toString('utf-8'); -} diff --git a/src/utexo/utils/index.ts b/src/utexo/utils/index.ts deleted file mode 100644 index a31b9ed..0000000 --- a/src/utexo/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './network'; diff --git a/src/utexo/utils/network.ts b/src/utexo/utils/network.ts deleted file mode 100644 index 1f1e680..0000000 --- a/src/utexo/utils/network.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * UTEXO network and asset mapping - */ - -import type { Network } from '../../crypto/types'; -// Import preset configurations from config file -import { testnetPreset, mainnetPreset } from '../config/utexo-presets'; - -/** - * Network preset type - determines which configuration bundle to use - */ -export type UtxoNetworkPreset = 'mainnet' | 'testnet'; - -/** - * Network map configuration - maps logical network names to Bitcoin network types - */ -export type UtxoNetworkMap = { - mainnet: Network; - utexo: Network; -}; - -/** - * Network configuration for a single network (RGB, RGB Lightning, or UTEXO) - */ -type NetworkConfig = { - networkName: string; - networkId: number; - assets: { - assetId: string; - tokenName: string; - longName: string; - precision: number; - tokenId: number; - }[]; -}; - -/** - * Network ID map configuration - contains all network configs with asset lookup - */ -export type UtxoNetworkIdMap = { - mainnet: NetworkConfig & { - getAssetById(tokenId: number): NetworkConfig['assets'][number] | undefined; - }; - mainnetLightning: NetworkConfig & { - getAssetById(tokenId: number): NetworkConfig['assets'][number] | undefined; - }; - utexo: NetworkConfig & { - getAssetById(tokenId: number): NetworkConfig['assets'][number] | undefined; - }; -}; - -/** - * Complete network preset configuration bundle - */ -export type UtxoNetworkPresetConfig = { - networkMap: UtxoNetworkMap; - networkIdMap: UtxoNetworkIdMap; -}; - -/** - * Network preset configurations map - */ -const NETWORK_PRESETS: Record = { - mainnet: mainnetPreset, - testnet: testnetPreset, -}; - -/** - * Gets the network configuration for a given preset - * @param preset - Network preset ('mainnet' or 'testnet') - * @returns Network preset configuration bundle - */ -export function getUtxoNetworkConfig( - preset: UtxoNetworkPreset -): UtxoNetworkPresetConfig { - return NETWORK_PRESETS[preset]; -} - -/** - * Backward compatibility: Export testnet preset as default (current behavior) - * @deprecated Use getUtxoNetworkConfig('testnet') or getUtxoNetworkConfig('mainnet') instead - */ -export const utexoNetworkMap: UtxoNetworkMap = testnetPreset.networkMap; - -/** - * Backward compatibility: Export testnet preset as default (current behavior) - * @deprecated Use getUtxoNetworkConfig('testnet') or getUtxoNetworkConfig('mainnet') instead - */ -export const utexoNetworkIdMap: UtxoNetworkIdMap = testnetPreset.networkIdMap; - -export type NetworkAsset = - (typeof utexoNetworkIdMap)[keyof typeof utexoNetworkIdMap]['assets'][number]; - -export type UtxoNetworkId = keyof typeof utexoNetworkIdMap; - -/** - * Resolves the destination network's asset object from sender network, destination network, and sender asset ID. - * Uses tokenId as the cross-network identifier (same tokenId = same logical asset). - * - * @param networkIdMap - Optional. When provided (e.g. from wallet's preset), uses this config. Otherwise uses deprecated testnet preset. - */ -export function getDestinationAsset( - senderNetwork: UtxoNetworkId, - destinationNetwork: UtxoNetworkId, - assetIdSender: string | null, - networkIdMap?: UtxoNetworkIdMap -): NetworkAsset | undefined { - const config = networkIdMap ?? utexoNetworkIdMap; - const destinationConfig = config[destinationNetwork]; - if (assetIdSender == null) return destinationConfig.assets[0]; - const senderConfig = config[senderNetwork]; - const senderAsset = senderConfig.assets.find( - (a) => a.assetId === assetIdSender - ); - if (!senderAsset) return undefined; - return destinationConfig.assets.find( - (a) => a.tokenId === senderAsset.tokenId - ); -} diff --git a/src/utils/bip32-helpers.ts b/src/utils/bip32-helpers.ts deleted file mode 100644 index 124fd27..0000000 --- a/src/utils/bip32-helpers.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { isNode } from './environment'; -import { CryptoError } from '../errors'; -import type { Network, NetworkVersions, BufferLike } from '../crypto/types'; -import { BIP32_VERSIONS } from '../constants/network'; - -function getWifVersion(network: Network): number { - return network === 'mainnet' ? 0x80 : 0xef; // testnet, testnet4, signet, and regtest all use 0xef -} - -function getNetworkVersionsFromConstants(network: Network): NetworkVersions { - const bip32Versions = BIP32_VERSIONS[network]; - return { - bip32: bip32Versions, - wif: getWifVersion(network), - }; -} - -/** - * Normalize seed to Buffer/Uint8Array for BIP32 operations - * Handles Buffer, Uint8Array, ArrayBuffer, and buffer-like objects - */ -export function normalizeSeedBuffer(seed: BufferLike): Buffer | Uint8Array { - if (!seed) { - throw new CryptoError('Failed to generate seed - seed is undefined'); - } - - let seedBuffer: Buffer | Uint8Array; - - if (seed instanceof Uint8Array) { - seedBuffer = seed; - } else if (seed instanceof ArrayBuffer) { - seedBuffer = new Uint8Array(seed); - } else if (seed && typeof seed === 'object') { - if ('buffer' in seed && seed.buffer) { - const bufferValue = seed.buffer; - - if (bufferValue instanceof ArrayBuffer) { - if (isNode() && seed instanceof Buffer) { - seedBuffer = seed as Buffer; - } else { - const byteOffset = seed.byteOffset || 0; - const byteLength = - seed.byteLength || - (seed as { length?: number }).length || - bufferValue.byteLength; - seedBuffer = new Uint8Array(bufferValue, byteOffset, byteLength); - } - } else { - try { - seedBuffer = new Uint8Array(seed as ArrayLike); - } catch (error) { - throw new CryptoError( - `Failed to convert seed to Uint8Array (buffer property invalid): ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } else { - try { - seedBuffer = new Uint8Array(seed as ArrayLike); - } catch (error) { - throw new CryptoError( - `Failed to convert seed to Uint8Array: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - } else { - throw new CryptoError(`Invalid seed type: ${typeof seed}`); - } - - return seedBuffer; -} - -export function toNetworkName(bitcoinNetwork: string | number): Network { - const n = String(bitcoinNetwork).toLowerCase(); - if (n.includes('main')) return 'mainnet'; - if (n.includes('reg')) return 'regtest'; - if (n.includes('sig')) return 'signet'; - if (n.includes('testnet4')) return 'testnet4'; - return 'testnet'; -} - -export function getNetworkVersions( - bitcoinNetwork: string | number -): NetworkVersions { - const net = toNetworkName(bitcoinNetwork); - return getNetworkVersionsFromConstants(net); -} diff --git a/src/utils/crypto-browser.ts b/src/utils/crypto-browser.ts deleted file mode 100644 index 02437a4..0000000 --- a/src/utils/crypto-browser.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { isBare, isNode } from './environment'; -import { convertToArrayBuffer } from './crypto-helpers'; -import * as noble from '@noble/hashes/legacy.js'; - -export async function sha256(data: Uint8Array | Buffer): Promise { - if (isNode() || isBare()) { - // String concatenation prevents bundlers from analyzing the import - const nodeCrypto = 'node:' + 'crypto'; - const { createHash } = await import(nodeCrypto); - return createHash('sha256') - .update(data as any) - .digest(); - } else { - if (!data) { - throw new Error('sha256: data is undefined or null'); - } - const arrayBuffer = convertToArrayBuffer(data); - return new Uint8Array(await crypto.subtle.digest('SHA-256', arrayBuffer)); - } -} - -/** - * RIPEMD160 hash - uses polyfill in browser (Web Crypto API doesn't support it) - */ -export async function ripemd160(data: Uint8Array): Promise { - if (isNode()) { - const nodeCrypto = 'node:' + 'crypto'; - const { createHash } = await import(nodeCrypto); - return createHash('ripemd160').update(data).digest(); - } else if (isBare()) { - return new Uint8Array(await noble.ripemd160(data)); - } else { - // @ts-ignore - ripemd160 doesn't have type definitions - const ripemd160Module = await import('ripemd160'); - const RIPEMD160 = ripemd160Module.default || ripemd160Module; - const BufferPolyfill = - (globalThis as any).Buffer || (await import('buffer')).Buffer; - const hasher = new (RIPEMD160 as any)(); - hasher.update(BufferPolyfill.from(data)); - return new Uint8Array(hasher.digest()); - } -} - -let nodeCrypto: typeof import('node:crypto') | null = null; - -async function getNodeCrypto() { - if (!isNode()) { - throw new Error('Node.js crypto is only available in Node.js environment'); - } - if (!nodeCrypto) { - const nodeCryptoPath = 'node:' + 'crypto'; - nodeCrypto = await import(nodeCryptoPath); - } - return nodeCrypto; -} - -export async function sha256Sync( - data: Uint8Array | Buffer -): Promise { - if (!isNode()) { - return sha256(data); - } - if (!data) { - throw new Error('sha256Sync: data is undefined'); - } - const crypto = await getNodeCrypto(); - if (!crypto) { - throw new Error('Node.js crypto is not available'); - } - return crypto - .createHash('sha256') - .update(data as any) - .digest(); -} - -export const ripemd160Sync: ( - data: Uint8Array | Buffer -) => Promise = async ( - data: Uint8Array | Buffer -): Promise => { - if (!isNode()) { - return ripemd160(data); - } - if (!data) { - throw new Error('ripemd160Sync: data is undefined'); - } - const crypto = await getNodeCrypto(); - if (!crypto) { - throw new Error('Node.js crypto is not available'); - } - return crypto - .createHash('ripemd160') - .update(data as any) - .digest(); -}; diff --git a/src/utils/crypto-helpers.ts b/src/utils/crypto-helpers.ts deleted file mode 100644 index 39881cb..0000000 --- a/src/utils/crypto-helpers.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Convert various data formats to ArrayBuffer for Web Crypto API - */ -export function convertToArrayBuffer(data: any): ArrayBuffer { - if (!data) { - throw new Error('convertToArrayBuffer: data is undefined or null'); - } - - if (data instanceof Uint8Array) { - return data.buffer as ArrayBuffer; - } - - if ( - data && - typeof data === 'object' && - 'byteLength' in data && - Object.prototype.toString.call(data) === '[object ArrayBuffer]' - ) { - return data as ArrayBuffer; - } - - if (data && typeof data === 'object') { - if ('buffer' in data && (data as any).buffer) { - const buffer = (data as any).buffer; - if (buffer instanceof ArrayBuffer) { - return buffer; - } - const uint8 = new Uint8Array(data as any); - return uint8.buffer; - } - - try { - const uint8 = new Uint8Array(data as any); - return uint8.buffer; - } catch (error) { - throw new Error( - `convertToArrayBuffer: Failed to convert data to ArrayBuffer: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - try { - const uint8 = new Uint8Array(data as any); - return uint8.buffer; - } catch (error) { - throw new Error( - `convertToArrayBuffer: Failed to convert data to ArrayBuffer: ${error instanceof Error ? error.message : String(error)}` - ); - } -} diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 63c1d43..c939e33 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -5,21 +5,3 @@ export function isNode(): boolean { process.versions.node != null ); } - -export function isBare(): boolean { - return typeof globalThis !== 'undefined' && (globalThis as any).Bare; -} - -export function isBrowser(): boolean { - return ( - typeof window !== 'undefined' && typeof window.document !== 'undefined' - ); -} - -export type Environment = 'node' | 'browser' | 'unknown'; - -export function getEnvironment(): Environment { - if (isNode()) return 'node'; - if (isBrowser()) return 'browser'; - return 'unknown'; -} diff --git a/src/utils/fingerprint.ts b/src/utils/fingerprint.ts deleted file mode 100644 index 55b301c..0000000 --- a/src/utils/fingerprint.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ripemd160Sync, sha256Sync } from './crypto-browser'; -import type { BIP32Interface } from 'bip32'; -import { CryptoError } from '../errors'; - -/** - * Calculate master fingerprint from BIP32 node - * fingerprint = first 4 bytes of HASH160(pubkey) - */ -export async function calculateMasterFingerprint( - node: BIP32Interface -): Promise { - const pubkey = node.publicKey; - if (!pubkey) { - throw new CryptoError('Public key is undefined'); - } - - const pubkeyData = - pubkey instanceof Uint8Array ? pubkey : new Uint8Array(pubkey); - const sha = await sha256Sync(pubkeyData); - const ripemd160Fn = ripemd160Sync as ( - data: Uint8Array | Buffer - ) => Promise; - const ripe = await ripemd160Fn(sha as Uint8Array); - - // Convert to Array first to avoid Buffer/Uint8Array serialization differences between Node.js and browser - const fingerprintBytes = Array.from(ripe.subarray(0, 4)); - - return fingerprintBytes - .map((b) => { - const hex = b.toString(16); - return hex.length === 1 ? '0' + hex : hex; - }) - .join(''); -} diff --git a/src/utils/logger.ts b/src/utils/logger.ts deleted file mode 100644 index ed570f5..0000000 --- a/src/utils/logger.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Logger utility for the SDK - * Provides structured logging with configurable log levels - */ - -export enum LogLevel { - DEBUG = 0, - INFO = 1, - WARN = 2, - ERROR = 3, - NONE = 4, -} - -class Logger { - private level: LogLevel = LogLevel.ERROR; - - /** - * Set the log level - */ - setLevel(level: LogLevel): void { - this.level = level; - } - - /** - * Get the current log level - */ - getLevel(): LogLevel { - return this.level; - } - - /** - * Log debug messages - */ - debug(...args: unknown[]): void { - if (this.level <= LogLevel.DEBUG) { - console.debug('[SDK DEBUG]', ...args); - } - } - - /** - * Log info messages - */ - info(...args: unknown[]): void { - if (this.level <= LogLevel.INFO) { - console.info('[SDK INFO]', ...args); - } - } - - /** - * Log warning messages - */ - warn(...args: unknown[]): void { - if (this.level <= LogLevel.WARN) { - console.warn('[SDK WARN]', ...args); - } - } - - /** - * Log error messages - */ - error(...args: unknown[]): void { - if (this.level <= LogLevel.ERROR) { - console.error('[SDK ERROR]', ...args); - } - } -} - -export const logger = new Logger(); - -/** - * Configure SDK logging - */ -export function configureLogging(level: LogLevel): void { - logger.setLevel(level); -} diff --git a/src/utils/network.ts b/src/utils/network.ts deleted file mode 100644 index 5dfd187..0000000 --- a/src/utils/network.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Network } from '../crypto/types'; -import { NETWORK_MAP } from '../constants'; -import { validateNetwork } from './validation'; - -/** - * @deprecated Use `normalizeNetwork` from `validation.ts` instead - */ -export function normalizeNetwork(network: string | number): Network { - validateNetwork(network); - const key = String(network); - return NETWORK_MAP[key as keyof typeof NETWORK_MAP] as Network; -} - -export function isNetwork(value: unknown): value is Network { - if (typeof value !== 'string') return false; - const normalized = NETWORK_MAP[value as keyof typeof NETWORK_MAP]; - return ( - !!normalized && - (normalized === 'mainnet' || - normalized === 'testnet' || - normalized === 'testnet4' || - normalized === 'signet' || - normalized === 'regtest') - ); -} diff --git a/src/utils/validation.ts b/src/utils/validation.ts deleted file mode 100644 index efe8a1f..0000000 --- a/src/utils/validation.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { ValidationError } from '../errors'; -import type { Network } from '../crypto/types'; -import { NETWORK_MAP } from '../constants'; - -const VALID_NETWORKS: Network[] = [ - 'mainnet', - 'testnet', - 'testnet4', - 'signet', - 'regtest', -]; - -export function validateNetwork( - network: string | number -): asserts network is Network { - const key = String(network); - const normalized = NETWORK_MAP[key as keyof typeof NETWORK_MAP]; - - if (!normalized || !VALID_NETWORKS.includes(normalized)) { - throw new ValidationError( - `Invalid network: ${network}. Must be one of: ${VALID_NETWORKS.join(', ')}`, - 'network' - ); - } -} - -export function normalizeNetwork(network: string | number): Network { - validateNetwork(network); - const key = String(network); - return NETWORK_MAP[key as keyof typeof NETWORK_MAP] as Network; -} - -export function validateMnemonic( - mnemonic: unknown, - field: string = 'mnemonic' -): asserts mnemonic is string { - if ( - !mnemonic || - typeof mnemonic !== 'string' || - mnemonic.trim().length === 0 - ) { - throw new ValidationError(`${field} must be a non-empty string`, field); - } - - const words = mnemonic.trim().split(/\s+/); - if (words.length !== 12 && words.length !== 24) { - throw new ValidationError( - `${field} must be 12 or 24 words, got ${words.length} words`, - field - ); - } -} - -export function validateBase64( - base64: unknown, - field: string = 'data' -): asserts base64 is string { - if (!base64 || typeof base64 !== 'string' || base64.trim().length === 0) { - throw new ValidationError(`${field} must be a non-empty string`, field); - } - - const base64Regex = /^[A-Za-z0-9+/=]+$/; - if (!base64Regex.test(base64.trim())) { - throw new ValidationError(`Invalid base64 format for ${field}`, field); - } - - try { - Buffer.from(base64.trim(), 'base64'); - } catch (error) { - console.error(error); - throw new ValidationError(`Invalid base64 encoding for ${field}`, field); - } -} - -export function validatePsbt( - psbt: unknown, - field: string = 'psbt' -): asserts psbt is string { - validateBase64(psbt, field); - - const psbtString = String(psbt).trim(); - if (psbtString.length < 50) { - throw new ValidationError( - `${field} appears to be too short to be a valid PSBT`, - field - ); - } -} - -export function validateHex( - hex: unknown, - field: string = 'data' -): asserts hex is string { - if (!hex || typeof hex !== 'string' || hex.trim().length === 0) { - throw new ValidationError(`${field} must be a non-empty string`, field); - } - - const hexRegex = /^[0-9a-fA-F]+$/; - if (!hexRegex.test(hex.trim())) { - throw new ValidationError(`Invalid hex format for ${field}`, field); - } -} - -export function validateRequired( - value: T | null | undefined, - field: string -): asserts value is T { - if (value === null || value === undefined) { - throw new ValidationError(`${field} is required`, field); - } -} - -export function validateString( - value: unknown, - field: string -): asserts value is string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new ValidationError(`${field} must be a non-empty string`, field); - } -} diff --git a/src/wallet/IWalletManager.ts b/src/wallet/IWalletManager.ts deleted file mode 100644 index 66f11e5..0000000 --- a/src/wallet/IWalletManager.ts +++ /dev/null @@ -1,477 +0,0 @@ -/** - * IWalletManager - Unified interface for WalletManager implementations - * - * This interface defines the contract that all WalletManager implementations must follow - * for cross-platform compatibility. - * - * All methods are async to support native module requirements. - * Synchronous implementations should wrap operations in Promise.resolve(). - * - * Type Standards: - * - All enum-like types use PascalCase: 'RgbSend', 'WaitingCounterparty', 'Nia', etc. - * - Network identifiers use lowercase: 'mainnet', 'testnet', 'regtest', 'signet', 'testnet4' - * - Transaction types: 'RgbSend' | 'Drain' | 'CreateUtxos' | 'User' - * - Transfer status: 'WaitingCounterparty' | 'WaitingConfirmations' | 'Settled' | 'Failed' - * - Transfer kind: 'Issuance' | 'ReceiveBlind' | 'ReceiveWitness' | 'Send' | 'Inflation' - * - Asset schemas: 'Nia' | 'Uda' | 'Cfa' | 'Ifa' - * - Assignment types: 'Fungible' | 'NonFungible' | 'InflationRight' | 'ReplaceRight' | 'Any' - */ - -import type { - CreateUtxosBeginRequestModel, - CreateUtxosEndRequestModel, - FailTransfersRequest, - InvoiceRequest, - InvoiceReceiveData, - IssueAssetNiaRequestModel, - IssueAssetIfaRequestModel, - SendAssetBeginRequestModel, - SendAssetEndRequestModel, - SendResult, - BtcBalance, - Unspent, - WalletBackupResponse, - SendBtcBeginRequestModel, - SendBtcEndRequestModel, - GetFeeEstimationResponse, - InflateAssetIfaRequestModel, - InflateEndRequestModel, - OperationResult, - AssetNIA, - AssetBalance, - ListAssets, - Transaction, - Transfer, - InvoiceData, - VssBackupConfig, - VssBackupInfo, -} from '../types/wallet-model'; -import type { EstimateFeeResult, Network } from '../crypto'; - -/** - * Wallet initialization parameters - * - * @param network - Network identifier: 'mainnet' | 'testnet' | 'testnet4' | 'regtest' | 'signet' (lowercase) - * or network number (0 for mainnet, 1 for testnet, etc.) - */ -export interface WalletInitParams { - xpubVan: string; - xpubCol: string; - mnemonic?: string; - seed?: Uint8Array; - network?: string | number; - xpub?: string; - masterFingerprint: string; - transportEndpoint?: string; - indexerUrl?: string; - dataDir?: string; -} - -/** - * Unified WalletManager interface for cross-platform compatibility - * - * This interface ensures all implementations provide the same API surface, - * allowing clients to switch between implementations based on environment. - */ -export interface IWalletManager { - // ============================================ - // Initialization & Lifecycle - // ============================================ - - /** - * Initialize the wallet and establish online connection - * Must be called before performing operations that require network access. - * - * @returns Promise that resolves when initialization is complete - * @throws {WalletError} if initialization fails - * - * NOTE: Some implementations require this method to be called explicitly, - * while others may initialize automatically in the constructor. - */ - initialize(): Promise; - - /** - * Connect the wallet to an online indexer service for syncing and transaction operations. - * Must be called before performing operations that require network connectivity. - * - * @param indexerUrl - The URL of the RGB indexer service to connect to - * @param skipConsistencyCheck - If true, skips the consistency check with the indexer (default: false) - * @returns Promise that resolves when the wallet is successfully connected online - * @throws {WalletError} if connection fails - */ - goOnline(indexerUrl: string, skipConsistencyCheck?: boolean): Promise; - - /** - * Get wallet's extended public keys - * @returns Object containing vanilla and colored extended public keys - */ - getXpub(): { xpubVan: string; xpubCol: string }; - - /** - * Get wallet's network - * @returns Network identifier - */ - getNetwork(): Network; - - /** - * Dispose of sensitive wallet data - * Clears mnemonic and seed from memory, closes connections - * Idempotent - safe to call multiple times - * - * @returns Promise that resolves when disposal is complete - */ - dispose(): Promise; - - /** - * Check if wallet has been disposed - * @returns true if wallet has been disposed, false otherwise - */ - isDisposed(): boolean; - - // ============================================ - // Wallet Registration & Basic Info - // ============================================ - - /** - * Register wallet and get initial address and balance - * This is typically called once after wallet creation - * - * @returns Promise resolving to address and BTC balance - */ - // registerWallet(): Promise<{ address: string; btcBalance: BtcBalance }>; - - /** - * Get current BTC balance - * @returns Promise resolving to BTC balance information - */ - getBtcBalance(): Promise; - - /** - * Get wallet's receiving address - * @returns Promise resolving to Bitcoin address string - */ - getAddress(): Promise; - - // ============================================ - // UTXO Management - // ============================================ - - /** - * List all unspent transaction outputs (UTXOs) - * @returns Promise resolving to array of unspent outputs - */ - listUnspents(): Promise; - - /** - * Begin creating UTXOs (first step of two-step process) - * @param params - UTXO creation parameters - * @returns Promise resolving to base64-encoded PSBT that needs to be signed - */ - createUtxosBegin(params: CreateUtxosBeginRequestModel): Promise; - - /** - * Complete UTXO creation (second step after signing) - * @param params - Signed PSBT from createUtxosBegin - * @returns Promise resolving to number of UTXOs created - */ - createUtxosEnd(params: CreateUtxosEndRequestModel): Promise; - - /** - * Complete UTXO creation flow: begin → sign → end - * Convenience method that combines createUtxosBegin, signing, and createUtxosEnd - * - * @param params - UTXO creation parameters - * @returns Promise resolving to number of UTXOs created - */ - createUtxos(params: { - upTo?: boolean; - num?: number; - size?: number; - feeRate?: number; - }): Promise; - - // ============================================ - // Asset Operations - // ============================================ - - /** - * List all assets in the wallet - * Returns assets grouped by schema (NIA, UDA, CFA, IFA) - * @returns Promise resolving to assets information grouped by schema - */ - listAssets(): Promise; - - /** - * Get balance for a specific asset - * @param asset_id - Asset identifier - * @returns Promise resolving to asset balance information - */ - getAssetBalance(asset_id: string): Promise; - - /** - * Issue a new NIA (Non-Inflatable Asset) - * @param params - Asset issuance parameters - * @returns Promise resolving to issued asset information - */ - issueAssetNia(params: IssueAssetNiaRequestModel): Promise; - - /** - * Issue a new IFA (Inflatable Fungible Asset) - * @param params - Asset issuance parameters - * @returns Promise resolving to issued asset information - */ - issueAssetIfa(params: IssueAssetIfaRequestModel): Promise; - - /** - * Begin asset inflation (first step of two-step process) - * @param params - Inflation parameters - * @returns Promise resolving to base64-encoded PSBT that needs to be signed - */ - inflateBegin(params: InflateAssetIfaRequestModel): Promise; - - /** - * Complete asset inflation (second step after signing) - * @param params - Signed PSBT from inflateBegin - * @returns Promise resolving to operation result - */ - inflateEnd(params: InflateEndRequestModel): Promise; - - /** - * Complete inflation flow: begin → sign → end - * Convenience method that combines inflateBegin, signing, and inflateEnd - * - * @param params - Inflation parameters - * @param mnemonic - Optional mnemonic for signing (uses wallet's mnemonic if not provided) - * @returns Promise resolving to operation result - */ - inflate( - params: InflateAssetIfaRequestModel, - mnemonic?: string - ): Promise; - - // ============================================ - // Sending Assets - // ============================================ - - /** - * Begin sending assets (first step of two-step process) - * @param params - Send parameters including invoice - * @returns Promise resolving to base64-encoded PSBT that needs to be signed - */ - sendBegin(params: SendAssetBeginRequestModel): Promise; - - /** - * Complete sending assets (second step after signing) - * @param params - Signed PSBT from sendBegin - * @returns Promise resolving to send result with txid - */ - sendEnd(params: SendAssetEndRequestModel): Promise; - - /** - * Complete send flow: begin → sign → end - * Convenience method that combines sendBegin, signing, and sendEnd - * - * @param invoiceTransfer - Send parameters including invoice - * @param mnemonic - Optional mnemonic for signing (uses wallet's mnemonic if not provided) - * @returns Promise resolving to send result with txid - */ - send( - invoiceTransfer: SendAssetBeginRequestModel, - mnemonic?: string - ): Promise; - - // ============================================ - // Sending BTC - // ============================================ - - /** - * Begin sending BTC (first step of two-step process) - * @param params - Send BTC parameters - * @returns Promise resolving to base64-encoded PSBT that needs to be signed - */ - sendBtcBegin(params: SendBtcBeginRequestModel): Promise; - - /** - * Complete sending BTC (second step after signing) - * @param params - Signed PSBT from sendBtcBegin - * @returns Promise resolving to transaction ID - */ - sendBtcEnd(params: SendBtcEndRequestModel): Promise; - - /** - * Complete BTC send flow: begin → sign → end - * Convenience method that combines sendBtcBegin, signing, and sendBtcEnd - * - * @param params - Send BTC parameters - * @returns Promise resolving to transaction ID - */ - sendBtc(params: SendBtcBeginRequestModel): Promise; - - // ============================================ - // Receiving Assets - // ============================================ - - /** - * Generate blind receive invoice - * Creates an invoice for receiving assets without revealing the amount - * - * @param params - Invoice generation parameters including assignment type - * Assignment types: 'Fungible' | 'NonFungible' | 'InflationRight' | 'ReplaceRight' | 'Any' - * @returns Promise resolving to invoice data including invoice string - */ - blindReceive(params: InvoiceRequest): Promise; - - /** - * Generate witness receive invoice - * Creates an invoice for receiving assets with amount visible - * - * @param params - Invoice generation parameters including assignment type - * Assignment types: 'Fungible' | 'NonFungible' | 'InflationRight' | 'ReplaceRight' | 'Any' - * @returns Promise resolving to invoice data including invoice string - */ - witnessReceive(params: InvoiceRequest): Promise; - - /** - * Decode RGB invoice - * Extracts information from an RGB invoice string - * - * @param params - Invoice string to decode - * @returns Promise resolving to decoded invoice data including recipientId, assetSchema, assignment, etc. - */ - decodeRGBInvoice(params: { invoice: string }): Promise; - - // ============================================ - // Transaction & Transfer Management - // ============================================ - - /** - * List all transactions - * @returns Promise resolving to array of transactions - * Each transaction includes transactionType ('RgbSend' | 'Drain' | 'CreateUtxos' | 'User'), - * txid, received/sent amounts, fee, and optional confirmationTime - */ - listTransactions(): Promise; - - /** - * List transfers for a specific asset or all assets - * @param asset_id - Optional asset identifier (lists all if not provided) - * @returns Promise resolving to array of transfers - * Each transfer includes status ('WaitingCounterparty' | 'WaitingConfirmations' | 'Settled' | 'Failed'), - * kind ('Issuance' | 'ReceiveBlind' | 'ReceiveWitness' | 'Send' | 'Inflation'), - * assignments, and transport endpoints - */ - listTransfers(asset_id?: string): Promise; - - /** - * Mark transfers as failed - * @param params - Transfer failure parameters - * @returns Promise resolving to boolean indicating success - */ - failTransfers(params: FailTransfersRequest): Promise; - - /** - * Refresh wallet state - * Syncs wallet with the network and updates internal state - * - * @returns Promise that resolves when refresh is complete - */ - refreshWallet(): Promise; - - /** - * Sync wallet with network - * Performs a full synchronization with the indexer - * - * @returns Promise that resolves when sync is complete - */ - syncWallet(): Promise; - - // ============================================ - // VSS Cloud Backup (optional) - // ============================================ - - /** - * Configure VSS (Versioned Storage Service) cloud backup for this wallet. - * When configured with autoBackup enabled, the wallet will perform automatic - * backups after state-changing operations according to the underlying rgb-lib behavior. - */ - configureVssBackup(config: VssBackupConfig): Promise; - - /** - * Disable automatic VSS backup for this wallet. - */ - disableVssAutoBackup(): Promise; - - /** - * Trigger a VSS backup immediately and return the server version of the stored backup. - */ - vssBackup(config: VssBackupConfig): Promise; - - /** - * Get VSS backup status information for this wallet. - */ - vssBackupInfo(config: VssBackupConfig): Promise; - - // ============================================ - // Fee Estimation - // ============================================ - - /** - * Estimate fee rate for a given number of blocks - * @param blocks - Number of blocks for fee estimation - * @returns Promise resolving to fee estimation response - */ - estimateFeeRate(blocks: number): Promise; - - /** - * Estimate fee for a specific PSBT - * @param psbtBase64 - Base64-encoded PSBT - * @returns Promise resolving to fee estimation result - */ - estimateFee(psbtBase64: string): Promise; - - // ============================================ - // Backup & Restore - // ============================================ - - /** - * Create wallet backup - * @param params - Backup parameters including path and password - * @returns Promise resolving to backup response - */ - createBackup(params: { - backupPath: string; - password: string; - }): Promise; - - // ============================================ - // Cryptographic Operations - // ============================================ - - /** - * Sign a PSBT using the wallet's mnemonic or a provided mnemonic - * @param psbt - Base64 encoded PSBT - * @param mnemonic - Optional mnemonic (uses wallet's mnemonic if not provided) - * @returns Promise resolving to signed PSBT (base64-encoded) - */ - signPsbt(psbt: string, mnemonic?: string): Promise; - - /** - * Sign a message using Schnorr signature - * @param message - Message to sign - * @returns Promise resolving to signature string - */ - signMessage(message: string): Promise; - - /** - * Verify a Schnorr message signature - * @param message - Original message - * @param signature - Signature to verify - * @param accountXpub - Optional account xpub (uses wallet's xpubVan if not provided) - * @returns Promise resolving to boolean indicating if signature is valid - */ - verifyMessage( - message: string, - signature: string, - accountXpub?: string - ): Promise; -} diff --git a/src/wallet/index.ts b/src/wallet/index.ts deleted file mode 100644 index 87ff948..0000000 --- a/src/wallet/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Wallet module exports - * - * This module provides the WalletManager class and related functionality for - * managing RGB wallets, combining RGB Node API client and cryptographic operations. - */ - -export { - WalletManager, - createWalletManager, - wallet, - createWallet, - restoreFromBackup, -} from './wallet-manager'; -export type { WalletInitParams } from './wallet-manager'; diff --git a/src/wallet/wallet-manager.ts b/src/wallet/wallet-manager.ts index 1005309..b8c1175 100644 --- a/src/wallet/wallet-manager.ts +++ b/src/wallet/wallet-manager.ts @@ -1,23 +1,16 @@ -import { RGBLibClient, restoreWallet } from '../client/index'; -import * as IWalletModel from '../types/wallet-model'; -import { - signPsbt, - signPsbtFromSeed, - signMessage as signSchnorrMessage, - verifyMessage as verifySchnorrMessage, - estimatePsbt, -} from '../crypto'; -import type { EstimateFeeResult, Network } from '../crypto'; -import { generateKeys } from '../crypto'; -import { normalizeNetwork } from '../utils/validation'; -import { ValidationError, WalletError } from '../errors'; +import { NodeRgbLibBinding, restoreWallet } from '../binding/NodeRgbLibBinding'; +import * as IWalletModel from '@utexo/rgb-sdk-core'; +import { ValidationError, WalletError } from '@utexo/rgb-sdk-core'; +import { BaseWalletManager } from '@utexo/rgb-sdk-core'; +import type { WalletInitParams } from '@utexo/rgb-sdk-core'; +import { generateKeys } from '@utexo/rgb-sdk-core'; +import { NodeSigner } from '../signer/NodeSigner'; import path from 'path'; -import type { IWalletManager } from './IWalletManager'; + +export type { WalletInitParams }; + /** * Restore wallet from backup - * This should be called before creating a WalletManager instance - * @param params - Restore parameters including backup file path, password, and restore directory - * @returns Wallet restore response */ export const restoreFromBackup = ( params: IWalletModel.RestoreWalletRequestModel @@ -34,618 +27,74 @@ export const restoreFromBackup = ( throw new ValidationError('restore directory is required', 'restoreDir'); } - return restoreWallet({ - backupFilePath, - password, - dataDir, - }); + return restoreWallet({ backupFilePath, password, dataDir }); }; /** * Generate a new wallet with keys - * @param network - Network string (default: 'regtest') - * @returns Generated keys including mnemonic, xpubs, and master fingerprint */ export const createWallet = async (network: string = 'regtest') => { - // return await generateKeys(network); return await generateKeys(network); }; -export type WalletInitParams = { - xpubVan: string; - xpubCol: string; - mnemonic?: string; - seed?: Uint8Array; - network?: string | number; - xpub?: string; - masterFingerprint: string; - transportEndpoint?: string; - indexerUrl?: string; - dataDir?: string; -}; - /** - * Wallet Manager - High-level wallet interface combining RGB API client and cryptographic operations - * - * This class provides a unified interface for: - * - RGB operations (via RGBLibClient - local rgb-lib) - * - PSBT signing operations - * - Wallet state management + * Wallet Manager — concrete Node SDK implementation of BaseWalletManager. * - * @example - * ```typescript - * const keys = generateKeys('testnet'); - * const wallet = new WalletManager({ - * xpubVan: keys.accountXpubVanilla, - * xpubCol: keys.accountXpubColored, - * masterFingerprint: keys.masterFingerprint, - * mnemonic: keys.mnemonic, - * network: 'testnet', - * transportEndpoint: 'rpcs://proxy.iriswallet.com/0.2/json-rpc', - * indexerUrl: 'ssl://electrum.iriswallet.com:50013' - * }); - * - * const balance = await wallet.getBtcBalance(); - * ``` + * Phase 14: NodeRgbLibBinding implements IRgbLibBinding directly — no wrapper layer. + * NodeSigner implements ISigner. Both are injected into BaseWalletManager, + * eliminating ~35 abstract method overrides. Only initialize() and goOnline() + * remain as Node-specific implementations. */ -export class WalletManager implements IWalletManager { - private readonly client: RGBLibClient; - private readonly xpub: string | null; - private readonly xpubVan: string; - private readonly xpubCol: string; - private mnemonic: string | null; - private seed: Uint8Array | null; - private readonly network: Network; - private readonly masterFingerprint: string; - private disposed: boolean = false; - private readonly dataDir: string; - constructor(params: WalletInitParams) { - if (!params.xpubVan) { - throw new ValidationError('xpubVan is required', 'xpubVan'); - } - if (!params.xpubCol) { - throw new ValidationError('xpubCol is required', 'xpubCol'); - } - if (!params.masterFingerprint) { - throw new ValidationError( - 'masterFingerprint is required', - 'masterFingerprint' - ); - } - - this.network = normalizeNetwork(params.network ?? 'regtest'); +export class WalletManager extends BaseWalletManager { + private readonly client: NodeRgbLibBinding; - this.xpubVan = params.xpubVan; - this.xpubCol = params.xpubCol; - this.seed = params.seed ?? null; - this.mnemonic = params.mnemonic ?? null; - this.xpub = params.xpub ?? null; - this.masterFingerprint = params.masterFingerprint; - this.dataDir = + constructor(params: WalletInitParams) { + const dataDir = params.dataDir ?? path.join( process.cwd(), '.rgb-wallet', - this.network, - this.masterFingerprint + String(params.network ?? 'regtest'), + params.masterFingerprint ); - this.client = new RGBLibClient({ + const client = new NodeRgbLibBinding({ xpubVan: params.xpubVan, xpubCol: params.xpubCol, masterFingerprint: params.masterFingerprint, - network: this.network, + network: String(params.network ?? 'regtest'), transportEndpoint: params.transportEndpoint, indexerUrl: params.indexerUrl, - dataDir: params.dataDir ?? this.dataDir, - }); - } - - public async initialize(): Promise { - console.log('initializing is not reqire'); - } - - public async goOnline(): Promise { - this.client.getOnline(); - } - - /** - * Get wallet's extended public keys - */ - public getXpub(): { xpubVan: string; xpubCol: string } { - return { - xpubVan: this.xpubVan, - xpubCol: this.xpubCol, - }; - } - - /** - * Get wallet's network - */ - public getNetwork(): Network { - return this.network; - } - - /** - * Dispose of sensitive wallet data - * Clears mnemonic and seed from memory - * Idempotent - safe to call multiple times - */ - public async dispose(): Promise { - if (this.disposed) { - return; - } - - if (this.mnemonic !== null) { - this.mnemonic = null; - } - - if (this.seed !== null && this.seed.length > 0) { - this.seed.fill(0); - this.seed = null; - } - this.client.dropWallet(); - - this.disposed = true; - } - - /** - * Check if wallet has been disposed - */ - public isDisposed(): boolean { - return this.disposed; - } - - /** - * Guard method to ensure wallet has not been disposed - * @throws {WalletError} if wallet has been disposed - */ - private ensureNotDisposed(): void { - if (this.disposed) { - throw new WalletError('Wallet has been disposed'); - } - } - - public registerWallet(): { - address: string; - btcBalance: IWalletModel.BtcBalance; - } { - return this.client.registerWallet(); - } - - public async getBtcBalance(): Promise { - return this.client.getBtcBalance(); - } - - public async getAddress(): Promise { - return this.client.getAddress(); - } - - public async listUnspents(): Promise { - const unspents = this.client.listUnspents(); - return unspents.map((unspent) => ({ - utxo: { - ...unspent.utxo, - exists: (unspent.utxo as any).exists ?? true, - }, - rgbAllocations: unspent.rgbAllocations.map((allocation) => { - const assignmentKeys = Object.keys(allocation.assignment); - const assignmentType = assignmentKeys[0] as - | IWalletModel.AssignmentType - | undefined; - const assignment: IWalletModel.Assignment = { - type: assignmentType ?? 'Any', - amount: - assignmentType && allocation.assignment[assignmentType] - ? Number(allocation.assignment[assignmentType]) - : undefined, - }; - return { - assetId: allocation.assetId, - assignment, - settled: allocation.settled, - }; - }), - pendingBlinded: (unspent as any).pendingBlinded ?? 0, - })); - } - - public async listAssets(): Promise { - const assets = this.client.listAssets(); - return assets; - } - - public async getAssetBalance( - asset_id: string - ): Promise { - const balance = this.client.getAssetBalance(asset_id); - return { - settled: balance.settled ?? 0, - future: balance.future ?? 0, - spendable: balance.spendable ?? 0, - offchainOutbound: balance.offchainOutbound ?? 0, - offchainInbound: balance.offchainInbound ?? 0, - }; - } - - public async createUtxosBegin( - params: IWalletModel.CreateUtxosBeginRequestModel - ): Promise { - return this.client.createUtxosBegin(params); - } - - public async createUtxosEnd( - params: IWalletModel.CreateUtxosEndRequestModel - ): Promise { - return this.client.createUtxosEnd(params); - } - - public async sendBegin( - params: IWalletModel.SendAssetBeginRequestModel - ): Promise { - return this.client.sendBegin(params); - } - - /** - * Batch send begin: accepts already-built recipientMap. - * Returns unsigned PSBT (use signPsbt then sendEnd to complete). - */ - public async sendBeginBatch(params: { - recipientMap: IWalletModel.RecipientMap; - feeRate?: number; - minConfirmations?: number; - donation?: boolean; - }): Promise { - return this.client.sendBeginBatch(params); - } - - /** - * Complete batch send: sendBeginBatch → sign PSBT → sendEnd. - */ - public async sendBatch( - params: { - recipientMap: IWalletModel.RecipientMap; - feeRate?: number; - minConfirmations?: number; - donation?: boolean; - }, - mnemonic?: string - ): Promise { - this.ensureNotDisposed(); - const psbt = await this.sendBeginBatch(params); - const signedPsbt = await this.signPsbt(psbt, mnemonic); - return await this.sendEnd({ signedPsbt }); - } - - public async sendEnd( - params: IWalletModel.SendAssetEndRequestModel - ): Promise { - return this.client.sendEnd(params); - } - - public async sendBtcBegin( - params: IWalletModel.SendBtcBeginRequestModel - ): Promise { - return this.client.sendBtcBegin(params); - } - - public async sendBtcEnd( - params: IWalletModel.SendBtcEndRequestModel - ): Promise { - return this.client.sendBtcEnd(params); - } - - public async estimateFeeRate( - blocks: number - ): Promise { - if (!Number.isFinite(blocks)) { - throw new ValidationError('blocks must be a finite number', 'blocks'); - } - if (!Number.isInteger(blocks) || blocks <= 0) { - throw new ValidationError('blocks must be a positive integer', 'blocks'); - } - - const feeEstimation = await this.client.getFeeEstimation({ blocks }); - return feeEstimation as unknown as IWalletModel.GetFeeEstimationResponse; - } - - public async estimateFee(psbtBase64: string): Promise { - return await estimatePsbt(psbtBase64); - } - - public async sendBtc( - params: IWalletModel.SendBtcBeginRequestModel - ): Promise { - this.ensureNotDisposed(); - const psbt = await this.sendBtcBegin(params); - const signed = await this.signPsbt(psbt); - return await this.sendBtcEnd({ signedPsbt: signed }); - } - - public async blindReceive( - params: IWalletModel.InvoiceRequest - ): Promise { - const invoice = await this.client.blindReceive({ - ...params, - assetId: params.assetId ?? '', - amount: params.amount ?? 0, + dataDir, }); - return { - invoice: invoice.invoice, - recipientId: invoice.recipientId, - expirationTimestamp: invoice.expirationTimestamp ?? null, - batchTransferIdx: invoice.batchTransferIdx, - }; - } - public async witnessReceive( - params: IWalletModel.InvoiceRequest - ): Promise { - const invoice = await this.client.witnessReceive({ - ...params, - assetId: params.assetId ?? '', - amount: params.amount ?? 0, - }); - return { - invoice: invoice.invoice, - recipientId: invoice.recipientId, - expirationTimestamp: invoice.expirationTimestamp ?? null, - batchTransferIdx: invoice.batchTransferIdx, - }; + super(params, client, new NodeSigner()); + this.client = client; } - public async decodeRGBInvoice(params: { - invoice: string; - }): Promise { - const invoiceData = await this.client.decodeRGBInvoice(params); - // Transform assignment from { [key: string]: any } to { type: AssignmentType, amount?: number } - const assignmentKeys = Object.keys(invoiceData.assignment); - const assignmentType = assignmentKeys[0] as - | IWalletModel.AssignmentType - | undefined; - const assignment: IWalletModel.Assignment = { - type: assignmentType ?? 'Any', - amount: - assignmentType && invoiceData.assignment[assignmentType] - ? Number(invoiceData.assignment[assignmentType]) - : undefined, - }; - return { - invoice: params.invoice, - recipientId: invoiceData.recipientId, - assetSchema: invoiceData.assetSchema as - | IWalletModel.AssetSchema - | undefined, - assetId: invoiceData.assetId, - network: invoiceData.network as IWalletModel.BitcoinNetwork, - assignment, - assignmentName: invoiceData.assignmentName, - expirationTimestamp: invoiceData.expirationTimestamp ?? null, - transportEndpoints: invoiceData.transportEndpoints, - }; - } - public async issueAssetNia( - params: IWalletModel.IssueAssetNiaRequestModel - ): Promise { - const asset = await this.client.issueAssetNia(params); - return asset; + async initialize(): Promise { + // No-op for Node SDK — wallet is ready after construction. } - public async issueAssetIfa( - params: IWalletModel.IssueAssetIfaRequestModel - ): Promise { - const asset = await this.client.issueAssetIfa(params); - return asset; - } - public async inflateBegin( - params: IWalletModel.InflateAssetIfaRequestModel - ): Promise { - return this.client.inflateBegin(params); - } - public async inflateEnd( - params: IWalletModel.InflateEndRequestModel - ): Promise { - return this.client.inflateEnd(params); - } - /** - * Complete inflate operation: begin → sign → end - * @param params - Inflate parameters - * @param mnemonic - Optional mnemonic for signing - */ - public async inflate( - params: IWalletModel.InflateAssetIfaRequestModel, - mnemonic?: string - ): Promise { - this.ensureNotDisposed(); - const psbt = await this.inflateBegin(params); - const signedPsbt = await this.signPsbt(psbt, mnemonic); - return await this.inflateEnd({ - signedPsbt, - }); - } - - public async refreshWallet(): Promise { - this.client.refreshWallet(); - } - - public async listTransactions(): Promise { - const transactions = this.client.listTransactions(); - - return transactions; - } - - public async listTransfers( - asset_id?: string - ): Promise { - const transfers = this.client.listTransfers(asset_id); - return transfers; - } - - public async failTransfers( - params: IWalletModel.FailTransfersRequest - ): Promise { - return this.client.failTransfers(params); - } - - public async createBackup(params: { - backupPath: string; - password: string; - }): Promise { - return this.client.createBackup(params); - } - - /** - * Configure VSS cloud backup for this wallet. - */ - public async configureVssBackup( - config: IWalletModel.VssBackupConfig + async goOnline( + _indexerUrl: string, + _skipConsistencyCheck?: boolean ): Promise { - this.ensureNotDisposed(); - this.client.configureVssBackup(config); - } - - /** - * Disable automatic VSS backup. - */ - public async disableVssAutoBackup(): Promise { - this.ensureNotDisposed(); - this.client.disableVssAutoBackup(); - } - - /** - * Trigger a VSS backup immediately and return the server version. - */ - public async vssBackup( - config: IWalletModel.VssBackupConfig - ): Promise { - this.ensureNotDisposed(); - return this.client.vssBackup(config); - } - - /** - * Get VSS backup info for this wallet. - */ - public async vssBackupInfo( - config: IWalletModel.VssBackupConfig - ): Promise { - this.ensureNotDisposed(); - return this.client.vssBackupInfo(config); - } - - /** - * Sign a PSBT using the wallet's mnemonic or a provided mnemonic - * @param psbt - Base64 encoded PSBT - * @param mnemonic - Optional mnemonic (uses wallet's mnemonic if not provided) - */ - public async signPsbt(psbt: string, mnemonic?: string): Promise { - this.ensureNotDisposed(); - const mnemonicToUse = mnemonic ?? this.mnemonic; - - if (mnemonicToUse) { - return await signPsbt(mnemonicToUse, psbt, this.network); - } - if (this.seed) { - return await signPsbtFromSeed(this.seed, psbt, this.network); - } - - throw new WalletError( - 'mnemonic is required. Provide it as parameter or initialize wallet with mnemonic.' - ); + this.client.getOnline(); } /** - * Complete send operation: begin → sign → end - * @param invoiceTransfer - Transfer invoice parameters - * @param mnemonic - Optional mnemonic for signing + * Register wallet with the network — Node-specific convenience method. + * Not part of IWalletManager; used directly by UTEXOWallet and callers + * that need the initial address + balance snapshot. */ - public async send( - invoiceTransfer: IWalletModel.SendAssetBeginRequestModel, - mnemonic?: string - ): Promise { - this.ensureNotDisposed(); - const psbt = await this.sendBegin(invoiceTransfer); - const signedPsbt = await this.signPsbt(psbt, mnemonic); - return await this.sendEnd({ signedPsbt }); - } - - public async createUtxos({ - upTo, - num, - size, - feeRate, - }: { - upTo?: boolean; - num?: number; - size?: number; - feeRate?: number; - }): Promise { - this.ensureNotDisposed(); - const psbt = await this.createUtxosBegin({ upTo, num, size, feeRate }); - const signedPsbt = await this.signPsbt(psbt); - return this.createUtxosEnd({ signedPsbt }); - } - - public async syncWallet(): Promise { - this.client.syncWallet(); - } - - public async signMessage(message: string): Promise { - this.ensureNotDisposed(); - if (!message) { - throw new ValidationError('message is required', 'message'); - } - - if (!this.seed) { - throw new WalletError( - 'Wallet seed is required for message signing. Initialize the wallet with a seed.' - ); - } - - return signSchnorrMessage({ - message, - seed: this.seed, - network: this.network, - }); - } - - public async verifyMessage( - message: string, - signature: string, - accountXpub?: string - ): Promise { - if (!message) { - throw new ValidationError('message is required', 'message'); - } - if (!signature) { - throw new ValidationError('signature is required', 'signature'); - } - - return verifySchnorrMessage({ - message, - signature, - accountXpub: accountXpub ?? this.xpubVan, - network: this.network, - }); + registerWallet(): { address: string; btcBalance: IWalletModel.BtcBalance } { + return this.client.registerWallet(); } } /** * Factory function to create a WalletManager instance - * Provides a cleaner API than direct constructor - * - * @example - * ```typescript - * const keys = generateKeys('testnet'); - * const wallet = createWalletManager({ - * xpubVan: keys.accountXpubVanilla, - * xpubCol: keys.accountXpubColored, - * masterFingerprint: keys.masterFingerprint, - * mnemonic: keys.mnemonic, - * network: 'testnet', - * transportEndpoint: 'rpcs://proxy.iriswallet.com/0.2/json-rpc', - * indexerUrl: 'ssl://electrum.iriswallet.com:50013' - * }); - * ``` */ export function createWalletManager(params: WalletInitParams): WalletManager { return new WalletManager(params); @@ -653,7 +102,6 @@ export function createWalletManager(params: WalletInitParams): WalletManager { // Legacy singleton instance for backward compatibility // @deprecated Use `new WalletManager(params)` or `createWalletManager(params)` instead -// This singleton will throw an error when accessed, requiring proper initialization let _wallet: WalletManager | null = null; export const wallet = new Proxy({} as WalletManager, { diff --git a/tests/README.md b/tests/README.md index 04a66e2..e3be9f6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,6 +8,13 @@ This directory contains unit tests for the SDK. Run from repo root after `npm ru npm test ``` +Integration suites: + +```bash +npm run test:signet +npm run test:regtest +``` + Or with watch mode: ```bash @@ -82,3 +89,8 @@ MNEMONIC_A="..." MNEMONIC_B="..." ASSET_ID="rgb:..." AMOUNT=10 node examples/lig ``` See [examples/README.md](../examples/README.md) for full documentation. + +## Integration Test Suites + +- `tests/signet/README.md` — current UTEXO manual/integration baseline +- `tests/regtest/README.md` — current regtest e2e baseline diff --git a/tests/keys.test.ts b/tests/keys.test.ts index c822e5e..a3f14f9 100644 --- a/tests/keys.test.ts +++ b/tests/keys.test.ts @@ -10,8 +10,8 @@ import { deriveKeysFromXpriv, ValidationError, CryptoError, + bip39, } from '../dist/index.mjs'; -import bip39 from 'bip39'; describe('keys', () => { // Test data from user diff --git a/tests/regtest/README.md b/tests/regtest/README.md new file mode 100644 index 0000000..0112892 --- /dev/null +++ b/tests/regtest/README.md @@ -0,0 +1,306 @@ +# Regtest Tests + +This directory contains the regtest end-to-end tests for offline receiver flows and proxy behavior. + +## Current baseline + +The current regtest baseline covers: + +- `offline-receiver.test.ts` + - blind offline receiver happy-path + - `ack=true` + - `validated=true` + - current transfer reaches `Settled` + - receiver balance increases + - repeated `refreshWallet()` is idempotent + +- `restart-mid-flow.test.ts` + - receiver is disposed/recreated in the same `dataDir` during an active transfer + - persisted state still converges to `Settled` after restart + +- `sequential-sends-same-receiver.test.ts` + - two blind invoices are sent sequentially to one receiver wallet + - each send must ACK, validate, and reach `Settled` + - final receiver settled delta includes both amounts + +- `send-batch-same-receiver.test.ts` + - two blind invoices are paid in a single `sendBatch` + - both recipients must ACK, validate, and reach `Settled` + - final receiver settled delta includes both amounts + +- `restart-after-ack-before-settled.test.ts` + - transfer is ACKed first, receiver observes pre-settled status, then restarts + - post-restart refresh still converges that same transfer to `Settled` + +- `witness-receiver.test.ts` + - witness offline receiver happy-path + - `ack=true` + - `validated=true` + - current transfer reaches `Settled` + - receiver balance increases + - repeated `refreshWallet()` is idempotent + - negative path: witness invoice + `send()` without `witnessData` must fail and not credit receiver + +- `invalid-consignment.test.ts` + - malformed consignment triggers validation path + - current playground behavior is either: + - `ack=false` with `validated=false` + - or relay-only fallback with `ack=null` + - manual ACK is preserved against late validation + +- `ack-guard.test.ts` + - auto-ACK cannot be changed afterward + - `ack.post(false)` returns JSON-RPC error `-100` + +- `relay-only-mode.test.ts` + - proxy restarted without `INDEXER_URL` + - `ack` starts as `null` + - `validated` stays unset + - manual ACK works on a real blind transfer + - second scenario runs on a dedicated pre-issued asset for deterministic timing + - receiver refresh imports the transfer in relay-only mode + - a late manual ACK becomes a no-op while the transfer still converges to `Settled` + +- `upload-guard.test.ts` + - tests intentionally share one receiver wallet state in a single run + - duplicate upload of the same consignment returns `false` + - changed file for the same `recipientId` fails with JSON-RPC error `-101` + +- `pre-confirmation-gating.test.ts` + - proxy may already ACK/validate before mining + - receiver still does not settle before confirmation + - after mining, receiver converges to `Settled` + +- `relay-only-witness-mode.test.ts` + - relay-only witness receive path + - receiver refresh imports witness transfer + - late manual ACK becomes a no-op + +- `real-consignment-roundtrip.test.ts` + - `consignment.get` returns a real base64 payload for a valid transfer + - returned payload is non-empty and matches the transfer `txid` + - duplicate re-upload of the same real consignment returns `false` + - duplicate upload does not reset `validated` and keeps the same `txid` + +- `expired-invoice.test.ts` + - send after invoice expiry must not become a normal `Settled` transfer + - allows either sender-side rejection or non-settling proxy/receiver behavior + +- `expiry-race-near-boundary.test.ts` + - sends near `expiry` boundary (`~1s` before timeout) + - validates coherent outcome: sender-side reject or terminal transfer semantics without partial credit + +- `donation-false.test.ts` + - covers the `donation: false` send path + - tx does not reach the network before the receiver-side ACK path runs + - receiver refresh imports and ACKs the transfer + - sender refresh then makes the tx visible and the transfer settles + +- `witness-donation-false.test.ts` + - same deferred-broadcast contract as `donation-false`, but for witness receive/send path + - includes `witnessData` and validates tx appears on-chain only after receiver ACK path + sender refresh + +- `sequential-receives-same-wallet.test.ts` + - executes multiple blind receives sequentially on one long-lived wallet state + - validates no state drift/slot leakage across cycles and stable post-refresh settled balance + +- `proxy-down-during-send.test.ts` + - proxy is unavailable during `send()` + - sender gets a clear network- or transport-style error + - after recovery the receiver stays non-settled and does not receive phantom balance + +## Prerequisites + +The tests assume the regtest playground stack is already running. + +Current playground source: + +- `/path/to/test-rgb-proxy-playground` + +Start it with: + +```bash +cd /path/to/test-rgb-proxy-playground +./regtest.sh start +``` + +## Env vars + +Required for normal regtest runs: + +- `REGTEST_PROXY_HTTP_URL` +- `REGTEST_PROXY_RPC_URL` +- `REGTEST_BITCOIND_USER` +- `REGTEST_BITCOIND_PASS` +- `REGTEST_INDEXER_URL` +- `REGTEST_DATA_DIR` + +For bitcoind access, use one of: + +- `REGTEST_BITCOIND_URL` +- `REGTEST_BITCOIND_CONTAINER` + +Current working local setup uses: + +- `REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc"` +- `REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc"` +- `REGTEST_BITCOIND_CONTAINER="bitcoind"` +- `REGTEST_BITCOIND_USER="user"` +- `REGTEST_BITCOIND_PASS="password"` +- `REGTEST_INDEXER_URL="tcp://localhost:50001"` +- `REGTEST_DATA_DIR="/tmp/rgb-e2e"` + +Required env for relay-only and proxy-down tests: + +- `REGTEST_PLAYGROUND_COMPOSE_FILE="/path/to/test-rgb-proxy-playground/docker-compose.yaml"` + +## Run + +Run the whole regtest suite: + +```bash +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest +``` + +Run individual tests: + +```bash +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:smoke + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:witness + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:nack + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:ack-guard + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:upload-guard + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:restart-mid + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:sequential-sends + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:send-batch + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:restart-after-ack + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:expiry-race + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:witness-donation-false + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +npm run test:regtest:sequential-receives + +cd /path/to/rgb-sdk +REGTEST_PROXY_HTTP_URL="http://localhost:3000/json-rpc" \ +REGTEST_PROXY_RPC_URL="rpc://localhost:3000/json-rpc" \ +REGTEST_BITCOIND_CONTAINER="bitcoind" \ +REGTEST_BITCOIND_USER="user" \ +REGTEST_BITCOIND_PASS="password" \ +REGTEST_INDEXER_URL="tcp://localhost:50001" \ +REGTEST_DATA_DIR="/tmp/rgb-e2e" \ +REGTEST_PLAYGROUND_COMPOSE_FILE="/path/to/test-rgb-proxy-playground/docker-compose.yaml" \ +npm run test:regtest:relay-only +``` diff --git a/tests/regtest/ack-guard.test.ts b/tests/regtest/ack-guard.test.ts new file mode 100644 index 0000000..85911cb --- /dev/null +++ b/tests/regtest/ack-guard.test.ts @@ -0,0 +1,246 @@ +import { + pollCondition, + pollAck, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type AckGuardReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ack?: boolean; + validated?: boolean; + ackPostErrorCode?: number; + ackPostErrorMessage?: string; + ackAfterAttempt?: boolean | null; + }; +}; + +type JsonRpcErrorResponse = { + jsonrpc: string; + id: string | number; + error?: { + code: number; + message: string; + data?: unknown; + }; + result?: unknown; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', +}; + +async function ackPostRaw( + recipientId: string, + ack: boolean +): Promise { + const response = await fetch(PROXY_HTTP_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'ack.post', + params: { + recipient_id: recipientId, + ack, + }, + }), + }); + + return response.json(); +} + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + + const issuedAsset = await sender.issueAssetNia({ + ticker: `A${Date.now().toString().slice(-5)}`, + name: `Regtest Ack Guard Asset ${Date.now()}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued ack-guard asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest ACK guard', () => { + it('auto-ACK cannot be changed by receiver', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: AckGuardReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + await mine(1); + + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + report.phase1.ack = ack; + report.phase1.validated = validated; + + expect(ack).toBe(true); + expect(validated).toBe(true); + + const ackPostResponse = await ackPostRaw(invoiceData.recipientId, false); + report.phase1.ackPostErrorCode = ackPostResponse.error?.code; + report.phase1.ackPostErrorMessage = ackPostResponse.error?.message; + + expect(ackPostResponse.error?.code).toBe(-100); + + const ackAfterAttempt = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + report.phase1.ackAfterAttempt = ackAfterAttempt; + expect(ackAfterAttempt).toBe(true); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport(report, 'regtest-ack-guard.json'); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/donation-false.test.ts b/tests/regtest/donation-false.test.ts new file mode 100644 index 0000000..53cc0df --- /dev/null +++ b/tests/regtest/donation-false.test.ts @@ -0,0 +1,297 @@ +import { + pollCondition, + pollTransferByRecipientId, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + bitcoindRpc, + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type DonationFalseReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + unsignedPsbtLength?: number; + txid?: string; + ackBeforeRefresh?: boolean | null; + txKnownBeforeRefresh?: boolean; + transferStatusAfterRefresh?: string; + receiverSettledAfterRefresh?: number; + ackAfterRefresh?: boolean | null; + lateManualAckPosted?: boolean; + txKnownAfterReceiverRefresh?: boolean; + txKnownAfterSenderRefresh?: boolean; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfter?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +async function isTransactionKnown(txid: string): Promise { + try { + await bitcoindRpc('getrawtransaction', [txid]); + return true; + } catch { + return false; + } +} + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `D${Date.now().toString().slice(-5)}`, + name: `Donate${Date.now().toString().slice(-5)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await sender.refreshWallet(); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest donation=false flow', () => { + it('broadcasts only after the receiver-side ACK path and sender refresh run', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: DonationFalseReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const unsignedPsbt = await sender.sendBegin({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: false, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.unsignedPsbtLength = unsignedPsbt.length; + expect(unsignedPsbt.length).toBeGreaterThan(0); + + const signedPsbt = await sender.signPsbt(unsignedPsbt); + const sendResult = await sender.sendEnd({ signedPsbt }); + report.phase1.txid = sendResult.txid; + + const ackBeforeRefresh = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + report.phase1.ackBeforeRefresh = ackBeforeRefresh; + expect([null, true]).toContain(ackBeforeRefresh); + + const txKnownBeforeRefresh = await isTransactionKnown(sendResult.txid); + report.phase1.txKnownBeforeRefresh = txKnownBeforeRefresh; + expect(txKnownBeforeRefresh).toBe(false); + + await receiver.refreshWallet(); + const transferAfterRefresh = ( + await receiver.listTransfers(state.assetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase1.transferStatusAfterRefresh = transferAfterRefresh?.status; + const receiverBalanceAfterRefresh = await receiver.getAssetBalance( + state.assetId + ); + const receiverSettledAfterRefresh = Number( + receiverBalanceAfterRefresh.settled ?? 0 + ); + report.phase1.receiverSettledAfterRefresh = receiverSettledAfterRefresh; + expect(transferAfterRefresh?.status).toBe('WaitingConfirmations'); + expect(receiverSettledAfterRefresh).toBe(state.receiverSettledBefore); + + const ackAfterRefresh = await pollCondition( + async () => + proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: invoiceData.recipientId, + }), + (ack) => ack === true, + 10_000, + 500, + `Receiver-side ACK did not appear for recipient_id=${invoiceData.recipientId}` + ); + report.phase1.ackAfterRefresh = ackAfterRefresh; + expect(ackAfterRefresh).toBe(true); + + const lateManualAckPosted = await proxyRpc( + PROXY_HTTP_URL, + 'ack.post', + { + recipient_id: invoiceData.recipientId, + ack: true, + } + ); + report.phase1.lateManualAckPosted = lateManualAckPosted; + expect(lateManualAckPosted).toBe(false); + + const txKnownAfterReceiverRefresh = await isTransactionKnown( + sendResult.txid + ); + report.phase1.txKnownAfterReceiverRefresh = txKnownAfterReceiverRefresh; + expect(txKnownAfterReceiverRefresh).toBe(false); + + const txKnownAfterSenderRefresh = await pollCondition( + async () => { + await sender.refreshWallet(); + return isTransactionKnown(sendResult.txid); + }, + (known) => known === true, + 10_000, + 500, + `donation=false tx ${sendResult.txid} did not reach mempool/chain after sender refresh` + ); + report.phase1.txKnownAfterSenderRefresh = txKnownAfterSenderRefresh; + expect(txKnownAfterSenderRefresh).toBe(true); + + await mine(1); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 20_000, + 1_000 + ); + report.phase1.currentTransferStatus = currentTransfer.status; + report.phase1.currentTransferTxid = currentTransfer.txid ?? null; + report.phase1.txidMatch = currentTransfer.txid === sendResult.txid; + + const receiverBalance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(receiverBalance.settled ?? 0); + report.phase1.receiverSettledAfter = receiverSettledAfter; + + expect(currentTransfer.status).toBe('Settled'); + expect(receiverSettledAfter - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-donation-false.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/expired-invoice.test.ts b/tests/regtest/expired-invoice.test.ts new file mode 100644 index 0000000..1227b93 --- /dev/null +++ b/tests/regtest/expired-invoice.test.ts @@ -0,0 +1,223 @@ +import { proxyRpc, sleep, writeSmokeReport } from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const EXPIRY_SECONDS = 1; + +type ExpiredInvoiceReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + expirationTimestamp?: number | null; + sendError?: string; + sendTxid?: string; + ackAfterSend?: boolean | null; + validatedAfterSend?: boolean; + transferStatusAfterSend?: string; + receiverSettledAfterSend?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `X${Date.now().toString().slice(-5)}`, + name: `Expire${Date.now().toString().slice(-5)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await sender.refreshWallet(); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest expired invoice', () => { + it('does not allow an expired blind invoice to become a normal settled transfer', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: ExpiredInvoiceReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + durationSeconds: EXPIRY_SECONDS, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + report.phase1.expirationTimestamp = invoiceData.expirationTimestamp; + + await sleep((EXPIRY_SECONDS + 1) * 1_000); + + try { + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.sendTxid = sendResult.txid; + } catch (error) { + report.phase1.sendError = String(error); + } + + if (!report.phase1.sendTxid) { + expect(report.phase1.sendError).toBeDefined(); + return; + } + + await mine(1); + await receiver.refreshWallet(); + + const ackAfterSend = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ).catch(() => null); + const consignmentAfterSend = await proxyRpc<{ validated?: boolean }>( + PROXY_HTTP_URL, + 'consignment.get', + { recipient_id: invoiceData.recipientId } + ).catch(() => ({ validated: undefined })); + const transferAfterSend = ( + await receiver.listTransfers(state.assetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + const receiverSettledAfterSend = Number(receiverBalance.settled ?? 0); + + report.phase1.ackAfterSend = ackAfterSend; + report.phase1.validatedAfterSend = consignmentAfterSend.validated; + report.phase1.transferStatusAfterSend = transferAfterSend?.status; + report.phase1.receiverSettledAfterSend = receiverSettledAfterSend; + + const fullSuccess = + ackAfterSend === true && + consignmentAfterSend.validated === true && + transferAfterSend?.status === 'Settled' && + receiverSettledAfterSend >= + state.receiverSettledBefore + TRANSFER_AMOUNT; + expect(fullSuccess).toBe(false); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-expired-invoice.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/expiry-race-near-boundary.test.ts b/tests/regtest/expiry-race-near-boundary.test.ts new file mode 100644 index 0000000..a3fc426 --- /dev/null +++ b/tests/regtest/expiry-race-near-boundary.test.ts @@ -0,0 +1,287 @@ +import { + pollAck, + pollCondition, + pollValidated, + proxyRpc, + sleep, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const EXPIRY_SECONDS = 6; +const SEND_BEFORE_EXPIRY_SECONDS = 1; + +type ExpiryRaceReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + receiverSettledBefore: number; + expirySeconds: number; + sendBeforeExpirySeconds: number; + }; + phase1: { + invoice: string; + recipientId: string; + expirationTimestamp?: number | null; + waitToBoundaryMs?: number; + sendError?: string; + sendTxid?: string; + transferStatusAfterSend?: string; + ackAfterSend?: boolean | null; + validatedAfterSend?: boolean; + receiverSettledAfterSend?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `ER${Date.now().toString().slice(-4)}`, + name: `ExpiryRace${Date.now().toString().slice(-6)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest expiry race near boundary', () => { + it('send near invoice expiry is coherent: either sender-side reject or terminal transfer outcome', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: ExpiryRaceReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + receiverSettledBefore: state.receiverSettledBefore, + expirySeconds: EXPIRY_SECONDS, + sendBeforeExpirySeconds: SEND_BEFORE_EXPIRY_SECONDS, + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + durationSeconds: EXPIRY_SECONDS, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const expirationTimestamp = Number( + (invoiceData as { expirationTimestamp?: number | null }) + .expirationTimestamp ?? 0 + ); + report.phase1.expirationTimestamp = expirationTimestamp || null; + if (expirationTimestamp > 0) { + const targetBoundaryMs = + expirationTimestamp * 1_000 - SEND_BEFORE_EXPIRY_SECONDS * 1_000; + const waitToBoundaryMs = Math.max(targetBoundaryMs - Date.now(), 0); + report.phase1.waitToBoundaryMs = waitToBoundaryMs; + if (waitToBoundaryMs > 0) { + await sleep(waitToBoundaryMs); + } + } else { + console.warn( + 'expirationTimestamp not available — boundary timing skipped, testing early send only' + ); + } + + try { + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.sendTxid = sendResult.txid; + } catch (error) { + report.phase1.sendError = String(error); + } + + if (!report.phase1.sendTxid) { + expect(report.phase1.sendError).toBeDefined(); + return; + } + + await mine(1); + + const currentTransfer = await pollCondition( + async () => { + await receiver.refreshWallet(); + return receiver + .listTransfers(state.assetId) + .then((items) => + items.find((item) => item.recipientId === invoiceData.recipientId) + ); + }, + (transfer) => + transfer?.status === 'Settled' || transfer?.status === 'Failed', + 30_000, + 1_000, + `Transfer for recipient_id=${invoiceData.recipientId} did not reach a terminal status` + ); + report.phase1.transferStatusAfterSend = currentTransfer?.status; + expect(currentTransfer).toBeDefined(); + + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + const receiverSettledAfterSend = Number(receiverBalance.settled ?? 0); + report.phase1.receiverSettledAfterSend = receiverSettledAfterSend; + + if (currentTransfer?.status === 'Settled') { + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + report.phase1.ackAfterSend = ack; + report.phase1.validatedAfterSend = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + expect( + receiverSettledAfterSend - state.receiverSettledBefore + ).toBeGreaterThanOrEqual(TRANSFER_AMOUNT); + } else { + const ack = await proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: invoiceData.recipientId, + }).catch(() => null); + const validated = await proxyRpc<{ validated?: boolean }>( + PROXY_HTTP_URL, + 'consignment.get', + { recipient_id: invoiceData.recipientId } + ) + .then((consignment) => consignment.validated) + .catch(() => undefined); + report.phase1.ackAfterSend = ack; + report.phase1.validatedAfterSend = validated; + expect(receiverSettledAfterSend).toBe(state.receiverSettledBefore); + } + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-expiry-race-near-boundary.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/helpers.ts b/tests/regtest/helpers.ts new file mode 100644 index 0000000..39b41ce --- /dev/null +++ b/tests/regtest/helpers.ts @@ -0,0 +1,396 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { pollCondition } from '../shared/helpers'; + +export const REGTEST_NETWORK = 'regtest'; +export const BTC_FUNDING_AMOUNT = 1; +export const MIN_WALLET_BTC_SAT = 50_000; +export const UTXO_PROFILE = { + num: 5, + size: 2_000, + feeRate: 1, +} as const; + +export type RegtestKeys = { + mnemonic: string; + accountXpubVanilla: string; + accountXpubColored: string; + masterFingerprint: string; +}; + +export type RegtestWallet = { + initialize(): Promise; + dispose(): Promise; + getAddress(): Promise; + getBtcBalance(): Promise<{ + vanilla: { settled: number | string; spendable: number | string }; + }>; + createUtxos(params: { + num?: number; + size?: number; + feeRate?: number; + }): Promise; + issueAssetNia(params: { + ticker: string; + name: string; + amounts: number[]; + precision: number; + }): Promise<{ assetId: string }>; + getAssetBalance( + assetId: string + ): Promise<{ settled?: number | string; spendable?: number | string }>; + refreshWallet(): Promise; + blindReceive(params: { + amount: number; + minConfirmations: number; + durationSeconds?: number; + }): Promise<{ + invoice: string; + recipientId: string; + }>; + witnessReceive(params: { + amount: number; + minConfirmations: number; + durationSeconds?: number; + }): Promise<{ + invoice: string; + recipientId: string; + }>; + send(params: { + invoice: string; + assetId: string; + amount: number; + donation: boolean; + feeRate: number; + minConfirmations: number; + witnessData?: { amountSat: number; blinding?: number | null }; + }): Promise<{ txid: string }>; + sendBatch(params: { + recipientMap: Record< + string, + Array<{ + recipientId: string; + witnessData?: { amountSat: string; blinding?: number | null } | null; + assignment: { Fungible: number }; + transportEndpoints: string[]; + }> + >; + donation: boolean; + feeRate: number; + minConfirmations: number; + }): Promise<{ txid: string }>; + sendBegin(params: { + invoice: string; + assetId: string; + amount: number; + donation: boolean; + feeRate: number; + minConfirmations: number; + witnessData?: { amountSat: number; blinding?: number | null }; + }): Promise; + signPsbt(psbt: string): Promise; + sendEnd(params: { signedPsbt: string }): Promise<{ txid: string }>; + listTransfers( + assetId?: string + ): Promise< + Array<{ recipientId?: string; status?: string; txid?: string | null }> + >; + listUnspents(): Promise< + Array<{ + utxo: { outpoint: { txid: string; vout: number } }; + rgbAllocations: Array<{ assetId?: string; settled: boolean }>; + pendingBlinded: number; + }> + >; +}; + +export type JsonRpcLikeResponse = { + jsonrpc?: string; + id?: string | number; + result?: T; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +export type WalletManagerCtor = new (params: { + xpubVan: string; + xpubCol: string; + mnemonic: string; + masterFingerprint: string; + network: string; + transportEndpoint: string; + indexerUrl: string; + dataDir: string; +}) => RegtestWallet; + +export type GenerateKeysFn = ( + network: string +) => Promise | RegtestKeys; + +export function env(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +export function getRegtestProxyHttpUrl(): string { + return env('REGTEST_PROXY_HTTP_URL'); +} + +export function getRegtestProxyRpcUrl(): string { + return env('REGTEST_PROXY_RPC_URL'); +} + +export function getRegtestIndexerUrl(): string { + return env('REGTEST_INDEXER_URL'); +} + +export function getRegtestBaseDir(): string { + return process.env.REGTEST_DATA_DIR || '/tmp/rgb-e2e'; +} + +export function getRegtestBitcoindContainer(): string | undefined { + return process.env.REGTEST_BITCOIND_CONTAINER; +} + +export function ensureBitcoindAccess(): void { + if (process.env.REGTEST_BITCOIND_URL) { + return; + } + if (process.env.REGTEST_BITCOIND_CONTAINER) { + return; + } + throw new Error( + 'Missing bitcoind access: set REGTEST_BITCOIND_URL or REGTEST_BITCOIND_CONTAINER' + ); +} + +export function ensureSafeBaseDir(baseDir = getRegtestBaseDir()): string { + if (!baseDir || !path.isAbsolute(baseDir) || !baseDir.startsWith('/tmp/')) { + throw new Error( + `REGTEST_DATA_DIR must be an absolute path under /tmp/, got: ${baseDir}` + ); + } + fs.mkdirSync(baseDir, { recursive: true }); + return baseDir; +} + +export function getWalletDataDir( + name: 'sender' | 'receiver', + baseDir = getRegtestBaseDir() +): string { + return path.join(baseDir, name); +} + +export function resetWalletDataDirs(baseDir = getRegtestBaseDir()): void { + ensureSafeBaseDir(baseDir); + fs.rmSync(getWalletDataDir('sender', baseDir), { + recursive: true, + force: true, + }); + fs.rmSync(getWalletDataDir('receiver', baseDir), { + recursive: true, + force: true, + }); +} + +export async function bitcoindRpc( + method: string, + params: unknown[] = [] +): Promise { + const bitcoindUser = env('REGTEST_BITCOIND_USER'); + const bitcoindPass = env('REGTEST_BITCOIND_PASS'); + const bitcoindUrl = process.env.REGTEST_BITCOIND_URL; + + if (!bitcoindUrl) { + const container = env('REGTEST_BITCOIND_CONTAINER'); + const args = [ + 'exec', + container, + 'bitcoin-cli', + '-regtest', + `-rpcuser=${bitcoindUser}`, + `-rpcpassword=${bitcoindPass}`, + method, + ...params.map((param) => String(param)), + ]; + const stdout = execFileSync('docker', args, { + encoding: 'utf8', + }).trim(); + + try { + return JSON.parse(stdout) as T; + } catch { + return stdout as T; + } + } + + const response = await fetch(bitcoindUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${Buffer.from(`${bitcoindUser}:${bitcoindPass}`).toString('base64')}`, + }, + body: JSON.stringify({ + jsonrpc: '1.0', + id: 'regtest-e2e', + method, + params, + }), + }); + + const json = await response.json(); + if (json.error) { + throw new Error(`bitcoind ${method} failed: ${JSON.stringify(json.error)}`); + } + + return json.result as T; +} + +export async function mine(blocks = 1): Promise { + const address = await bitcoindRpc('getnewaddress'); + return bitcoindRpc('generatetoaddress', [blocks, address]); +} + +export async function waitForBtcBalance( + wallet: RegtestWallet, + minSats = MIN_WALLET_BTC_SAT, + timeoutMs = 15_000, + intervalMs = 250 +): Promise<{ + vanilla: { settled: number | string; spendable: number | string }; +}> { + return pollCondition( + async () => { + await wallet.refreshWallet(); + return wallet.getBtcBalance(); + }, + (balance) => Number(balance.vanilla.settled) >= minSats, + timeoutMs, + intervalMs, + `Timed out waiting for BTC balance >= ${minSats}` + ); +} + +export async function fundWallet( + wallet: RegtestWallet +): Promise<{ address: string; createdUtxos: number }> { + const address = await wallet.getAddress(); + await bitcoindRpc('sendtoaddress', [address, BTC_FUNDING_AMOUNT]); + await mine(1); + await waitForBtcBalance(wallet); + + const createdUtxos = await wallet.createUtxos({ ...UTXO_PROFILE }); + await mine(1); + await waitForBtcBalance(wallet); + await wallet.refreshWallet(); + + return { address, createdUtxos }; +} + +export async function createRegtestWallet( + ctor: WalletManagerCtor, + generateKeys: GenerateKeysFn, + name: 'sender' | 'receiver', + baseDir = getRegtestBaseDir() +): Promise<{ wallet: RegtestWallet; keys: RegtestKeys }> { + const keys = await generateKeys(REGTEST_NETWORK); + const wallet = new ctor({ + xpubVan: keys.accountXpubVanilla, + xpubCol: keys.accountXpubColored, + mnemonic: keys.mnemonic, + masterFingerprint: keys.masterFingerprint, + network: REGTEST_NETWORK, + transportEndpoint: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + dataDir: getWalletDataDir(name, baseDir), + }); + + await wallet.initialize(); + + return { wallet, keys }; +} + +export async function createRegtestWalletFromKeys( + ctor: WalletManagerCtor, + keys: RegtestKeys, + name: 'sender' | 'receiver', + baseDir = getRegtestBaseDir() +): Promise { + const wallet = new ctor({ + xpubVan: keys.accountXpubVanilla, + xpubCol: keys.accountXpubColored, + mnemonic: keys.mnemonic, + masterFingerprint: keys.masterFingerprint, + network: REGTEST_NETWORK, + transportEndpoint: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + dataDir: getWalletDataDir(name, baseDir), + }); + + await wallet.initialize(); + + return wallet; +} + +export async function postConsignmentRaw(params: { + proxyHttpUrl?: string; + recipientId: string; + txid?: string; + fileName?: string; + content?: string; + contentBytes?: Uint8Array; +}): Promise> { + const form = new FormData(); + form.append('jsonrpc', '2.0'); + form.append('id', '1'); + form.append('method', 'consignment.post'); + form.append('params[recipient_id]', params.recipientId); + form.append( + 'params[txid]', + params.txid ?? + '0000000000000000000000000000000000000000000000000000000000000000' + ); + form.append( + 'file', + new Blob( + [params.contentBytes ?? params.content ?? 'fake consignment payload'], + { + type: 'application/octet-stream', + } + ), + params.fileName ?? 'bad.rgb' + ); + + const response = await fetch( + params.proxyHttpUrl ?? getRegtestProxyHttpUrl(), + { + method: 'POST', + body: form, + } + ); + + return response.json(); +} + +export async function postFakeConsignment(params: { + proxyHttpUrl?: string; + recipientId: string; + txid?: string; + fileName?: string; + content?: string; +}): Promise { + const json = await postConsignmentRaw(params); + if (json.error) { + throw new Error(`consignment.post failed: ${JSON.stringify(json.error)}`); + } + + return Boolean(json.result); +} diff --git a/tests/regtest/invalid-consignment.test.ts b/tests/regtest/invalid-consignment.test.ts new file mode 100644 index 0000000..f53e583 --- /dev/null +++ b/tests/regtest/invalid-consignment.test.ts @@ -0,0 +1,202 @@ +import { + pollCondition, + proxyRpc, + sleep, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + postFakeConsignment, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); + +type InvalidConsignmentReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + }; + phase1: { + invoice: string; + recipientId: string; + postAccepted?: boolean; + ack?: boolean | null; + validated?: boolean | undefined; + transferStatusAfterRefresh?: string; + }; +}; + +type State = { + receiver: RegtestWallet | null; +}; + +const state: State = { + receiver: null, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + + state.receiver = receiver; + + await fundWallet(receiver); +}); + +afterAll(async () => { + await state.receiver?.dispose(); +}); + +describe('Regtest invalid consignment', () => { + it('R-03: malformed consignment triggers validation path (auto-NACK or relay-only fallback)', async () => { + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: InvalidConsignmentReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: 1, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const postAccepted = await postFakeConsignment({ + recipientId: invoiceData.recipientId, + }); + report.phase1.postAccepted = postAccepted; + expect(postAccepted).toBe(true); + + // Playground validation is asynchronous; this wait covers the expected validation window. + await sleep(12_000); + + const ack = await proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: invoiceData.recipientId, + }); + const consignment = await proxyRpc<{ validated?: boolean }>( + PROXY_HTTP_URL, + 'consignment.get', + { + recipient_id: invoiceData.recipientId, + } + ); + const validated = consignment.validated; + report.phase1.ack = ack; + report.phase1.validated = validated; + + expect( + (ack === false && validated === false) || + (ack === null && validated === undefined) + ).toBe(true); + + await receiver.refreshWallet(); + const transfers = await receiver.listTransfers(); + const currentTransfer = transfers.find( + (item) => item.recipientId === invoiceData.recipientId + ); + + report.phase1.transferStatusAfterRefresh = currentTransfer?.status; + + expect(currentTransfer?.status).not.toBe('Settled'); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-invalid-consignment.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); + + it('manual ACK is preserved against late validation', async () => { + const receiver = state.receiver!; + // Intentional proxy-level case: recipient_id is synthetic (not from SDK invoice generation), + // but unique per run to avoid collisions with persisted proxy state. + const recipientId = `regtest.manual-ack-preserved.${Date.now()}`; + const syntheticTxid = Date.now().toString(16).padStart(64, '0'); + const postAccepted = await postFakeConsignment({ + recipientId, + content: `manual-ack-${Date.now()}`, + txid: syntheticTxid, + }); + + expect(postAccepted).toBe(true); + + const ackPosted = await proxyRpc(PROXY_HTTP_URL, 'ack.post', { + recipient_id: recipientId, + ack: true, + }); + expect(ackPosted).toBe(true); + + // Playground validation is asynchronous; this wait covers the expected validation window. + await sleep(12_000); + + const finalAck = await pollCondition( + async () => + proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: recipientId, + }), + (ack) => ack === true, + 5_000, + 500, + `Manual ACK was not preserved for recipient_id=${recipientId}` + ); + + expect(finalAck).toBe(true); + + const currentTransfer = (await receiver.listTransfers()).find( + (item) => item.recipientId === recipientId + ); + expect(currentTransfer?.status).not.toBe('Settled'); + }); +}); diff --git a/tests/regtest/offline-receiver-delayed-refresh.test.ts b/tests/regtest/offline-receiver-delayed-refresh.test.ts new file mode 100644 index 0000000..4d1a013 --- /dev/null +++ b/tests/regtest/offline-receiver-delayed-refresh.test.ts @@ -0,0 +1,298 @@ +import { + pollAck, + pollCondition, + pollValidated, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const RECEIVER_REFRESH_COUNT = 2; + +type DelayedRefreshReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + senderSpendableBefore: number; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ack?: boolean; + validated?: boolean; + senderTransferStatusBeforeReceiverRefresh?: string; + receiverSettledWhileOffline?: number; + }; + phase2: { + receiverRefreshChecks: Array<{ + cycle: number; + settled: number; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + }>; + receiverSettledAfter?: number; + finalTransferStatus?: string; + finalTransferTxid?: string | null; + txidMatch?: boolean; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + senderSpendableBefore: number; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + senderSpendableBefore: 0, + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + + state.sender = sender; + state.receiver = receiver; + + const senderFunding = await fundWallet(sender); + state.senderAddress = senderFunding.address; + + const assetSuffix = Date.now().toString().slice(-6); + const issuedAsset = await sender.issueAssetNia({ + ticker: `R${assetSuffix.slice(-5)}`, + name: `Delayed ${assetSuffix}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + const senderBalance = await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + state.senderSpendableBefore = Number(senderBalance.spendable ?? 0); + + const receiverFunding = await fundWallet(receiver); + state.receiverAddress = receiverFunding.address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ + settled: 0, + })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest offline receiver delayed refresh', () => { + it('keeps receiver offline until sender settles, then reaches Settled after two receiver refreshes', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: DelayedRefreshReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + senderSpendableBefore: state.senderSpendableBefore, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + phase2: { + receiverRefreshChecks: [], + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + await mine(1); + + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + report.phase1.ack = ack; + report.phase1.validated = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + + const senderTransfer = await pollCondition( + async () => { + await sender.refreshWallet(); + const transfers = await sender.listTransfers(state.assetId); + return transfers.find((item) => item.txid === sendResult.txid); + }, + (transfer) => transfer?.status === 'Settled', + 30_000, + 1_000, + `Sender transfer txid=${sendResult.txid} did not reach Settled before receiver refresh` + ); + report.phase1.senderTransferStatusBeforeReceiverRefresh = + senderTransfer?.status; + + const receiverBalanceWhileOffline = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ + settled: 0, + })); + report.phase1.receiverSettledWhileOffline = Number( + receiverBalanceWhileOffline.settled ?? 0 + ); + expect(report.phase1.receiverSettledWhileOffline).toBe( + state.receiverSettledBefore + ); + + for (let cycle = 1; cycle <= RECEIVER_REFRESH_COUNT; cycle += 1) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(state.assetId); + const transfers = await receiver.listTransfers(state.assetId); + const currentTransfer = transfers.find( + (item) => item.recipientId === invoiceData.recipientId + ); + + report.phase2.receiverRefreshChecks.push({ + cycle, + settled: Number(balance.settled ?? 0), + currentTransferStatus: currentTransfer?.status, + currentTransferTxid: currentTransfer?.txid, + }); + } + + const finalTransfer = await pollCondition( + async () => { + const transfers = await receiver.listTransfers(state.assetId); + return transfers.find( + (item) => item.recipientId === invoiceData.recipientId + ); + }, + (transfer) => transfer?.status === 'Settled', + 10_000, + 500, + `Receiver transfer recipientId=${invoiceData.recipientId} did not reach Settled after ${RECEIVER_REFRESH_COUNT} refreshes` + ); + + const finalBalance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(finalBalance.settled ?? 0); + report.phase2.receiverSettledAfter = receiverSettledAfter; + report.phase2.finalTransferStatus = finalTransfer?.status; + report.phase2.finalTransferTxid = finalTransfer?.txid; + report.phase2.txidMatch = Boolean( + finalTransfer?.txid && finalTransfer.txid === sendResult.txid + ); + + expect(finalTransfer?.status).toBe('Settled'); + expect(finalTransfer?.txid).toBe(sendResult.txid); + expect(receiverSettledAfter - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-offline-receiver-delayed-refresh.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/offline-receiver.test.ts b/tests/regtest/offline-receiver.test.ts new file mode 100644 index 0000000..7b9b6e9 --- /dev/null +++ b/tests/regtest/offline-receiver.test.ts @@ -0,0 +1,273 @@ +import { + pollCondition, + pollAck, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestProxyHttpUrl, + getRegtestIndexerUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const IDEMPOTENT_REFRESH_COUNT = 3; +const SEND_FEE_RATE = 2; + +type OfflineReceiverReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + senderSpendableBefore: number; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ack?: boolean; + validated?: boolean; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfter?: number; + }; + phase2: { + refreshChecks: Array<{ cycle: number; settled: number }>; + receiverSettledFinal?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + senderSpendableBefore: number; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + senderSpendableBefore: 0, + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + + state.sender = sender; + state.receiver = receiver; + + const senderFunding = await fundWallet(sender); + state.senderAddress = senderFunding.address; + + const issuedAsset = await sender.issueAssetNia({ + ticker: `R${Date.now().toString().slice(-5)}`, + name: `Regtest Asset ${Date.now()}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + const senderBalance = await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + state.senderSpendableBefore = Number(senderBalance.spendable ?? 0); + + // fundWallet() mines internally; funding receiver here also gives sender-side + // issuance/createUtxos transactions one more confirmation before the test flow. + const receiverFunding = await fundWallet(receiver); + state.receiverAddress = receiverFunding.address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ + settled: 0, + })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest offline receiver', () => { + it('R-01+R-02: blind receive offline -> auto-ACK -> Settled, refresh is idempotent', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: OfflineReceiverReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + senderSpendableBefore: state.senderSpendableBefore, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + phase2: { + refreshChecks: [], + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + await mine(1); + + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + + report.phase1.ack = ack; + report.phase1.validated = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + report.phase1.currentTransferStatus = currentTransfer.status; + report.phase1.currentTransferTxid = currentTransfer.txid; + report.phase1.txidMatch = Boolean( + currentTransfer.txid && currentTransfer.txid === sendResult.txid + ); + + const balance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(balance.settled ?? 0); + report.phase1.receiverSettledAfter = receiverSettledAfter; + + expect(currentTransfer.status).toBe('Settled'); + expect(receiverSettledAfter - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + + for (let cycle = 1; cycle <= IDEMPOTENT_REFRESH_COUNT; cycle += 1) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(state.assetId); + report.phase2.refreshChecks.push({ + cycle, + settled: Number(balance.settled ?? 0), + }); + } + + const allSettled = report.phase2.refreshChecks.map( + (item) => item.settled + ); + expect(new Set(allSettled).size).toBe(1); + report.phase2.receiverSettledFinal = + report.phase2.refreshChecks.at(-1)?.settled; + expect(report.phase2.receiverSettledFinal).toBe(receiverSettledAfter); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-offline-receiver.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/pre-confirmation-gating.test.ts b/tests/regtest/pre-confirmation-gating.test.ts new file mode 100644 index 0000000..41f7c8b --- /dev/null +++ b/tests/regtest/pre-confirmation-gating.test.ts @@ -0,0 +1,262 @@ +import { + pollAck, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type PreConfirmationReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ackBeforeMine?: boolean | null; + validatedBeforeMine?: boolean; + transferStatusBeforeMine?: string; + receiverSettledBeforeMine?: number; + }; + phase2: { + ackAfterMine?: boolean; + validatedAfterMine?: boolean; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfterMine?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + + const issuedAsset = await sender.issueAssetNia({ + ticker: `P${Date.now().toString().slice(-5)}`, + name: `PreConfirm${Date.now().toString().slice(-5)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await sender.refreshWallet(); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest pre-confirmation gating', () => { + it('does not let the receiver settle before the transfer is mined', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: PreConfirmationReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + phase2: {}, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + const ackBeforeMine = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + const consignmentBeforeMine = await proxyRpc<{ validated?: boolean }>( + PROXY_HTTP_URL, + 'consignment.get', + { recipient_id: invoiceData.recipientId } + ); + report.phase1.ackBeforeMine = ackBeforeMine; + report.phase1.validatedBeforeMine = consignmentBeforeMine.validated; + expect(ackBeforeMine).toBe(true); + expect(consignmentBeforeMine.validated).toBe(true); + + await receiver.refreshWallet(); + const transferBeforeMine = ( + await receiver.listTransfers(state.assetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase1.transferStatusBeforeMine = transferBeforeMine?.status; + expect(transferBeforeMine?.status).toBe('WaitingConfirmations'); + + const balanceBeforeMine = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + const receiverSettledBeforeMine = Number(balanceBeforeMine.settled ?? 0); + report.phase1.receiverSettledBeforeMine = receiverSettledBeforeMine; + expect(receiverSettledBeforeMine).toBe(state.receiverSettledBefore); + + await mine(1); + + const ackAfterMine = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 15_000, + 500 + ); + const validatedAfterMine = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 15_000, + 500 + ); + report.phase2.ackAfterMine = ackAfterMine; + report.phase2.validatedAfterMine = validatedAfterMine; + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 20_000, + 1_000 + ); + report.phase2.currentTransferStatus = currentTransfer.status; + report.phase2.currentTransferTxid = currentTransfer.txid ?? null; + report.phase2.txidMatch = currentTransfer.txid === sendResult.txid; + + const balanceAfterMine = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfterMine = Number(balanceAfterMine.settled ?? 0); + report.phase2.receiverSettledAfterMine = receiverSettledAfterMine; + + expect(ackAfterMine).toBe(true); + expect(validatedAfterMine).toBe(true); + expect(currentTransfer.status).toBe('Settled'); + expect(receiverSettledAfterMine - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-pre-confirmation-gating.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/proxy-down-during-send.test.ts b/tests/regtest/proxy-down-during-send.test.ts new file mode 100644 index 0000000..558c043 --- /dev/null +++ b/tests/regtest/proxy-down-during-send.test.ts @@ -0,0 +1,270 @@ +import { execFileSync } from 'node:child_process'; + +import { jest } from '@jest/globals'; + +import { pollCondition, proxyRpc, writeSmokeReport } from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const SUITE_TIMEOUT_MS = 60_000; + +jest.setTimeout(SUITE_TIMEOUT_MS); + +type ProxyDownReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + composeFile: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + sendError?: string; + sendErrorKind?: 'network' | 'transport' | 'other'; + ackAfterRecovery?: boolean | null; + transferStatusAfterRecovery?: string; + receiverSettledAfterRecovery?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; + composeFile: string; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, + composeFile: '', +}; + +function dockerCompose(args: string[]): string { + return execFileSync('docker', ['compose', '-f', state.composeFile, ...args], { + encoding: 'utf8', + }).trim(); +} + +async function waitForProxyReady(): Promise { + await pollCondition( + async () => { + try { + return await proxyRpc<{ version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + } catch { + return null; + } + }, + (info) => Boolean(info?.version), + 15_000, + 500, + 'Proxy did not become ready in time' + ); +} + +async function stopProxy(): Promise { + dockerCompose(['stop', 'proxy']); +} + +async function startProxy(): Promise { + dockerCompose(['up', '-d', 'proxy']); + await waitForProxyReady(); +} + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + state.composeFile = env('REGTEST_PLAYGROUND_COMPOSE_FILE'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await waitForProxyReady(); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `N${Date.now().toString().slice(-5)}`, + name: `ProxyDown${Date.now().toString().slice(-5)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await sender.refreshWallet(); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + try { + await startProxy(); + } finally { + await state.sender?.dispose(); + await state.receiver?.dispose(); + } +}); + +describe('Regtest proxy down during send', () => { + it('returns a clear send error and leaves the receiver in a non-settled state after recovery', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: ProxyDownReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + composeFile: state.composeFile, + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + await stopProxy(); + + try { + await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + } catch (error) { + const serialized = [ + String(error), + (error as Error)?.message ?? '', + (error as Error)?.stack ?? '', + ] + .filter(Boolean) + .join('\n'); + report.phase1.sendError = serialized; + report.phase1.sendErrorKind = + /fetch failed|ECONNREFUSED|SocketError|connect/i.test(serialized) + ? 'network' + : /InvalidTransportEndpoints|no valid transport endpoints/i.test( + serialized + ) + ? 'transport' + : 'other'; + } finally { + await startProxy(); + } + + expect(report.phase1.sendError).toBeDefined(); + expect(['network', 'transport']).toContain(report.phase1.sendErrorKind); + + await receiver.refreshWallet(); + const ackAfterRecovery = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ).catch(() => null); + report.phase1.ackAfterRecovery = ackAfterRecovery; + + const transferAfterRecovery = await receiver + .listTransfers(state.assetId) + .then((items) => + items.find((item) => item.recipientId === invoiceData.recipientId) + ) + .catch(() => undefined); + report.phase1.transferStatusAfterRecovery = transferAfterRecovery?.status; + + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + const receiverSettledAfterRecovery = Number(receiverBalance.settled ?? 0); + report.phase1.receiverSettledAfterRecovery = receiverSettledAfterRecovery; + + expect(transferAfterRecovery?.status).not.toBe('Settled'); + expect(receiverSettledAfterRecovery).toBe(state.receiverSettledBefore); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-proxy-down-during-send.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/proxy-fixture.ts b/tests/regtest/proxy-fixture.ts new file mode 100644 index 0000000..ee2766a --- /dev/null +++ b/tests/regtest/proxy-fixture.ts @@ -0,0 +1,77 @@ +import { execFileSync } from 'node:child_process'; + +import { pollCondition, proxyRpc } from '../shared/helpers'; + +const RELAY_ONLY_CONTAINER_NAME = 'proxy-no-indexer'; +const PROXY_SERVICE_NAME = 'proxy'; + +function dockerCompose(composeFile: string, args: string[]): string { + return execFileSync('docker', ['compose', '-f', composeFile, ...args], { + encoding: 'utf8', + }).trim(); +} + +function dockerRmForce(containerName: string): void { + try { + execFileSync('docker', ['rm', '-f', containerName], { + encoding: 'utf8', + stdio: 'pipe', + }); + } catch (_e) { + // container may not exist yet + } +} + +export async function waitForProxyReady( + proxyHttpUrl: string, + timeoutMs = 15_000, + intervalMs = 500 +): Promise { + await pollCondition( + async () => { + try { + return await proxyRpc<{ protocol_version: string; version: string }>( + proxyHttpUrl, + 'server.info' + ); + } catch { + return null; + } + }, + (info) => Boolean(info?.version), + timeoutMs, + intervalMs, + 'Proxy did not become ready in time' + ); +} + +export async function switchToRelayOnlyProxy( + composeFile: string, + proxyHttpUrl: string +): Promise { + dockerCompose(composeFile, ['stop', PROXY_SERVICE_NAME]); + dockerRmForce(RELAY_ONLY_CONTAINER_NAME); + dockerCompose(composeFile, [ + 'run', + '-d', + '--name', + RELAY_ONLY_CONTAINER_NAME, + '-e', + 'INDEXER_URL=', + '-e', + 'BITCOIN_NETWORK=regtest', + '-p', + '3000:3000', + PROXY_SERVICE_NAME, + ]); + await waitForProxyReady(proxyHttpUrl); +} + +export async function restoreStandardProxy( + composeFile: string, + proxyHttpUrl: string +): Promise { + dockerRmForce(RELAY_ONLY_CONTAINER_NAME); + dockerCompose(composeFile, ['up', '-d', PROXY_SERVICE_NAME]); + await waitForProxyReady(proxyHttpUrl); +} diff --git a/tests/regtest/real-consignment-roundtrip.test.ts b/tests/regtest/real-consignment-roundtrip.test.ts new file mode 100644 index 0000000..40613bf --- /dev/null +++ b/tests/regtest/real-consignment-roundtrip.test.ts @@ -0,0 +1,230 @@ +import { + pollAck, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + postConsignmentRaw, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type ConsignmentRoundtripReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ack?: boolean; + validated?: boolean; + consignmentTxid?: string; + consignmentValidated?: boolean; + consignmentBytes?: number; + duplicateUploadResult?: boolean; + duplicateConsignmentTxid?: string; + duplicateConsignmentValidated?: boolean; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `C${Date.now().toString().slice(-5)}`, + name: `Roundtrip${Date.now().toString().slice(-5)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await sender.refreshWallet(); + state.receiverAddress = (await fundWallet(receiver)).address; +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest real consignment roundtrip', () => { + it('returns a valid base64 consignment and rejects duplicate re-upload of the same real file', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: ConsignmentRoundtripReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + await mine(1); + + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 15_000, + 500 + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 15_000, + 500 + ); + report.phase1.ack = ack; + report.phase1.validated = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + + const consignment = await proxyRpc<{ + consignment: string; + txid: string; + validated?: boolean; + }>(PROXY_HTTP_URL, 'consignment.get', { + recipient_id: invoiceData.recipientId, + }); + report.phase1.consignmentTxid = consignment.txid; + report.phase1.consignmentValidated = consignment.validated; + + const consignmentBytes = Buffer.from(consignment.consignment, 'base64'); + report.phase1.consignmentBytes = consignmentBytes.length; + + expect(consignment.txid).toBe(sendResult.txid); + expect(consignment.validated).toBe(true); + expect(consignmentBytes.length).toBeGreaterThan(0); + + const duplicateUpload = await postConsignmentRaw({ + proxyHttpUrl: PROXY_HTTP_URL, + recipientId: invoiceData.recipientId, + txid: sendResult.txid, + fileName: 'real-consignment.rgbc', + contentBytes: consignmentBytes, + }); + report.phase1.duplicateUploadResult = Boolean(duplicateUpload.result); + expect(duplicateUpload.error).toBeUndefined(); + expect(duplicateUpload.result).toBe(false); + + const consignmentAfterDuplicate = await proxyRpc<{ + txid: string; + validated?: boolean; + }>(PROXY_HTTP_URL, 'consignment.get', { + recipient_id: invoiceData.recipientId, + }); + report.phase1.duplicateConsignmentTxid = consignmentAfterDuplicate.txid; + report.phase1.duplicateConsignmentValidated = + consignmentAfterDuplicate.validated; + expect(consignmentAfterDuplicate.txid).toBe(sendResult.txid); + expect(consignmentAfterDuplicate.validated).toBe(true); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-real-consignment-roundtrip.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/relay-only-mode.test.ts b/tests/regtest/relay-only-mode.test.ts new file mode 100644 index 0000000..8cf27e2 --- /dev/null +++ b/tests/regtest/relay-only-mode.test.ts @@ -0,0 +1,449 @@ +import { jest } from '@jest/globals'; + +import { + pollCondition, + pollTransferByRecipientId, + proxyRpc, + sleep, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; +import { restoreStandardProxy, switchToRelayOnlyProxy } from './proxy-fixture'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const RELAY_ONLY_SUITE_TIMEOUT_MS = 60_000; +const ASSET_READY_TIMEOUT_MS = 15_000; +const ASSET_READY_INTERVAL_MS = 500; +const RELAY_TRANSFER_TIMEOUT_MS = 20_000; +const RELAY_TRANSFER_INTERVAL_MS = 1_000; + +jest.setTimeout(RELAY_ONLY_SUITE_TIMEOUT_MS); + +type RelayOnlyReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + composeFile: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ackBeforeManual?: boolean | null; + validatedBeforeManual?: boolean; + manualAckPosted?: boolean; + ackAfterManual?: boolean | null; + }; + phase2?: { + assetId: string; + invoice: string; + recipientId: string; + txid?: string; + ackBeforeManual?: boolean | null; + receiverSettledBefore: number; + receiverSettledBeforeManualAck?: number; + transferStatusBeforeManual?: string; + ackAfterReceiverRefresh?: boolean | null; + lateManualAckPosted?: boolean; + ackAfterLateManual?: boolean | null; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfterManualAck?: number; + refreshChecks?: Array<{ cycle: number; settled: number }>; + receiverSettledFinal?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + phase2AssetId: string; + composeFile: string; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + phase2AssetId: '', + composeFile: '', +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + env('REGTEST_PLAYGROUND_COMPOSE_FILE'); + ensureBitcoindAccess(); + + state.composeFile = env('REGTEST_PLAYGROUND_COMPOSE_FILE'); + await switchToRelayOnlyProxy(state.composeFile, PROXY_HTTP_URL); + + resetWalletDataDirs(getRegtestBaseDir()); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + + const issuedAsset = await sender.issueAssetNia({ + ticker: `L${Date.now().toString().slice(-5)}`, + name: `Regtest RelayOnly Asset ${Date.now()}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + ASSET_READY_TIMEOUT_MS, + ASSET_READY_INTERVAL_MS, + `Issued relay-only asset ${state.assetId} did not become spendable in time` + ); + + const phase2Asset = await sender.issueAssetNia({ + ticker: `M${Date.now().toString().slice(-5)}`, + name: `RelayManual${Date.now().toString().slice(-5)}`, + amounts: [10], + precision: 0, + }); + state.phase2AssetId = phase2Asset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.phase2AssetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + ASSET_READY_TIMEOUT_MS, + ASSET_READY_INTERVAL_MS, + `Issued relay-only manual-ack asset ${state.phase2AssetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); + await restoreStandardProxy(state.composeFile, PROXY_HTTP_URL); +}); + +describe('Regtest relay-only mode', () => { + it('no INDEXER_URL keeps ack null and still allows manual ACK on a real blind transfer', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: RelayOnlyReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + composeFile: state.composeFile, + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + }, + phase1: { + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + await mine(1); + await sleep(2_000); + + const ackBeforeManual = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + const consignmentBeforeManual = await proxyRpc<{ validated?: boolean }>( + PROXY_HTTP_URL, + 'consignment.get', + { + recipient_id: invoiceData.recipientId, + } + ); + + report.phase1.ackBeforeManual = ackBeforeManual; + report.phase1.validatedBeforeManual = consignmentBeforeManual.validated; + + expect(ackBeforeManual).toBeNull(); + expect(consignmentBeforeManual.validated).toBeUndefined(); + + const manualAckPosted = await proxyRpc( + PROXY_HTTP_URL, + 'ack.post', + { + recipient_id: invoiceData.recipientId, + ack: true, + } + ); + report.phase1.manualAckPosted = manualAckPosted; + expect(manualAckPosted).toBe(true); + + const ackAfterManual = await pollCondition( + async () => + proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: invoiceData.recipientId, + }), + (ack) => ack === true, + 5_000, + 500, + `Manual ACK did not stick for recipient_id=${invoiceData.recipientId}` + ); + report.phase1.ackAfterManual = ackAfterManual; + expect(ackAfterManual).toBe(true); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-relay-only-mode.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); + + it('receiver refresh imports the transfer and makes late manual ACK a no-op', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const phase2AssetId = state.phase2AssetId; + const startedAt = Date.now(); + + const beforeBalance = await receiver + .getAssetBalance(phase2AssetId) + .catch(() => ({ + settled: 0, + })); + const receiverSettledBefore = Number(beforeBalance.settled ?? 0); + const report: RelayOnlyReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + composeFile: state.composeFile, + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + }, + phase1: { + invoice: '', + recipientId: '', + }, + phase2: { + assetId: phase2AssetId, + invoice: '', + recipientId: '', + receiverSettledBefore, + refreshChecks: [], + }, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase2!.invoice = invoiceData.invoice; + report.phase2!.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: phase2AssetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase2!.txid = sendResult.txid; + + await mine(1); + await sleep(2_000); + + const ackBeforeManual = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + report.phase2!.ackBeforeManual = ackBeforeManual; + expect(ackBeforeManual).toBeNull(); + + await receiver.refreshWallet(); + const balanceBeforeManualAck = + await receiver.getAssetBalance(phase2AssetId); + const receiverSettledBeforeManualAck = Number( + balanceBeforeManualAck.settled ?? 0 + ); + report.phase2!.receiverSettledBeforeManualAck = + receiverSettledBeforeManualAck; + expect(receiverSettledBeforeManualAck).toBe(receiverSettledBefore); + + const transferBeforeManual = ( + await receiver.listTransfers(phase2AssetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase2!.transferStatusBeforeManual = transferBeforeManual?.status; + expect(transferBeforeManual?.status).toBe('WaitingConfirmations'); + + const ackAfterReceiverRefresh = await pollCondition( + async () => + proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: invoiceData.recipientId, + }), + (ack) => ack === true, + 5_000, + 500, + `Receiver refresh did not ACK recipient_id=${invoiceData.recipientId}` + ); + report.phase2!.ackAfterReceiverRefresh = ackAfterReceiverRefresh; + expect(ackAfterReceiverRefresh).toBe(true); + + const lateManualAckPosted = await proxyRpc( + PROXY_HTTP_URL, + 'ack.post', + { + recipient_id: invoiceData.recipientId, + ack: true, + } + ); + report.phase2!.lateManualAckPosted = lateManualAckPosted; + expect(lateManualAckPosted).toBe(false); + + const ackAfterLateManual = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + report.phase2!.ackAfterLateManual = ackAfterLateManual; + expect(ackAfterLateManual).toBe(true); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(phase2AssetId); + }, + invoiceData.recipientId, + sendResult.txid, + RELAY_TRANSFER_TIMEOUT_MS, + RELAY_TRANSFER_INTERVAL_MS + ); + report.phase2!.currentTransferStatus = currentTransfer.status; + report.phase2!.currentTransferTxid = currentTransfer.txid ?? null; + report.phase2!.txidMatch = currentTransfer.txid === sendResult.txid; + + expect(currentTransfer.status).toBe('Settled'); + expect(currentTransfer.txid).toBe(sendResult.txid); + + const finalBalance = await receiver.getAssetBalance(phase2AssetId); + const receiverSettledAfterManualAck = Number(finalBalance.settled ?? 0); + report.phase2!.receiverSettledAfterManualAck = + receiverSettledAfterManualAck; + expect( + receiverSettledAfterManualAck - receiverSettledBefore + ).toBeGreaterThanOrEqual(TRANSFER_AMOUNT); + + for (let cycle = 1; cycle <= 2; cycle += 1) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(phase2AssetId); + const settled = Number(balance.settled ?? 0); + report.phase2!.refreshChecks!.push({ cycle, settled }); + } + + const finalSettledValues = report.phase2!.refreshChecks!.map( + ({ settled }) => settled + ); + expect(new Set(finalSettledValues).size).toBe(1); + report.phase2!.receiverSettledFinal = finalSettledValues.at(-1); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-relay-only-convergence.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/relay-only-witness-mode.test.ts b/tests/regtest/relay-only-witness-mode.test.ts new file mode 100644 index 0000000..09e6e4b --- /dev/null +++ b/tests/regtest/relay-only-witness-mode.test.ts @@ -0,0 +1,309 @@ +import { jest } from '@jest/globals'; + +import { + pollCondition, + pollTransferByRecipientId, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; +import { restoreStandardProxy, switchToRelayOnlyProxy } from './proxy-fixture'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const WITNESS_AMOUNT_SAT = 1_000; +const RELAY_ONLY_WITNESS_TIMEOUT_MS = 60_000; + +jest.setTimeout(RELAY_ONLY_WITNESS_TIMEOUT_MS); + +type RelayOnlyWitnessReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + composeFile: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + receiverSettledBefore: number; + }; + phase1: { + invoiceType: 'witness'; + witnessAmountSat: number; + invoice: string; + recipientId: string; + txid?: string; + ackBeforeRefresh?: boolean | null; + validatedBeforeRefresh?: boolean; + transferStatusAfterRefresh?: string; + ackAfterReceiverRefresh?: boolean | null; + lateManualAckPosted?: boolean; + ackAfterLateManual?: boolean | null; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfter?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + composeFile: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + composeFile: '', + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + state.composeFile = env('REGTEST_PLAYGROUND_COMPOSE_FILE'); + ensureBitcoindAccess(); + + await switchToRelayOnlyProxy(state.composeFile, PROXY_HTTP_URL); + resetWalletDataDirs(getRegtestBaseDir()); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `Q${Date.now().toString().slice(-5)}`, + name: `RelayWit${Date.now().toString().slice(-5)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 15_000, + 500, + `Issued relay-only witness asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); + await restoreStandardProxy(state.composeFile, PROXY_HTTP_URL); +}); + +describe('Regtest relay-only witness mode', () => { + it('receiver refresh imports witness transfer and makes late manual ACK a no-op', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: RelayOnlyWitnessReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + composeFile: state.composeFile, + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoiceType: 'witness', + witnessAmountSat: WITNESS_AMOUNT_SAT, + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.witnessReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + witnessData: { amountSat: WITNESS_AMOUNT_SAT }, + }); + report.phase1.txid = sendResult.txid; + + await mine(1); + await pollCondition( + async () => { + try { + return await proxyRpc<{ validated?: boolean }>( + PROXY_HTTP_URL, + 'consignment.get', + { + recipient_id: invoiceData.recipientId, + } + ); + } catch { + return null; + } + }, + (consignment) => consignment !== null, + 5_000, + 250, + `Consignment did not appear for witness recipient_id=${invoiceData.recipientId}` + ); + + const ackBeforeRefresh = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + const consignmentBeforeRefresh = await proxyRpc<{ validated?: boolean }>( + PROXY_HTTP_URL, + 'consignment.get', + { recipient_id: invoiceData.recipientId } + ); + report.phase1.ackBeforeRefresh = ackBeforeRefresh; + report.phase1.validatedBeforeRefresh = consignmentBeforeRefresh.validated; + + expect(ackBeforeRefresh).toBeNull(); + expect(consignmentBeforeRefresh.validated).toBeUndefined(); + + await receiver.refreshWallet(); + const transferAfterRefresh = ( + await receiver.listTransfers(state.assetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase1.transferStatusAfterRefresh = transferAfterRefresh?.status; + expect(transferAfterRefresh?.status).toBe('WaitingConfirmations'); + + const ackAfterReceiverRefresh = await pollCondition( + async () => + proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: invoiceData.recipientId, + }), + (ack) => ack === true, + 5_000, + 500, + `Receiver refresh did not ACK witness recipient_id=${invoiceData.recipientId}` + ); + report.phase1.ackAfterReceiverRefresh = ackAfterReceiverRefresh; + expect(ackAfterReceiverRefresh).toBe(true); + + const lateManualAckPosted = await proxyRpc( + PROXY_HTTP_URL, + 'ack.post', + { + recipient_id: invoiceData.recipientId, + ack: true, + } + ); + report.phase1.lateManualAckPosted = lateManualAckPosted; + expect(lateManualAckPosted).toBe(false); + + const ackAfterLateManual = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + report.phase1.ackAfterLateManual = ackAfterLateManual; + expect(ackAfterLateManual).toBe(true); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 20_000, + 1_000 + ); + report.phase1.currentTransferStatus = currentTransfer.status; + report.phase1.currentTransferTxid = currentTransfer.txid ?? null; + report.phase1.txidMatch = currentTransfer.txid === sendResult.txid; + + const balance = await receiver.getAssetBalance(state.assetId); + report.phase1.receiverSettledAfter = Number(balance.settled ?? 0); + + expect(currentTransfer.status).toBe('Settled'); + expect(currentTransfer.txid).toBe(sendResult.txid); + expect(report.phase1.receiverSettledAfter).toBe( + state.receiverSettledBefore + TRANSFER_AMOUNT + ); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-relay-only-witness-mode.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/restart-after-ack-before-settled.test.ts b/tests/regtest/restart-after-ack-before-settled.test.ts new file mode 100644 index 0000000..c083c3b --- /dev/null +++ b/tests/regtest/restart-after-ack-before-settled.test.ts @@ -0,0 +1,259 @@ +import { + pollCondition, + pollAck, + pollTransferByRecipientId, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + createRegtestWalletFromKeys, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestKeys, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type RestartAfterAckReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ackBeforeRestart?: boolean; + transferStatusBeforeRestart?: string; + }; + phase2: { + receiverRecreated?: boolean; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfter?: number; + }; +}; + +type State = { + walletManagerCtor: WalletManagerCtor | null; + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + receiverKeys: RegtestKeys | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + walletManagerCtor: null, + sender: null, + receiver: null, + receiverKeys: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +async function restartReceiver(): Promise { + const walletCtor = state.walletManagerCtor!; + const receiverKeys = state.receiverKeys!; + await state.receiver?.dispose(); + const receiver = await createRegtestWalletFromKeys( + walletCtor, + receiverKeys, + 'receiver', + getRegtestBaseDir() + ); + state.receiver = receiver; + return receiver; +} + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + state.walletManagerCtor = WalletManager; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver, keys: receiverKeys } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + state.receiverKeys = receiverKeys; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `RA${Date.now().toString().slice(-4)}`, + name: `RestartAfterAck${Date.now().toString().slice(-6)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest receiver restart after ACK before Settled', () => { + it('preserves convergence after restart from WaitingConfirmations state', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: RestartAfterAckReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + phase2: {}, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + const ackBeforeRestart = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + report.phase1.ackBeforeRestart = ackBeforeRestart; + expect(ackBeforeRestart).toBe(true); + + await receiver.refreshWallet(); + const transferBeforeRestart = ( + await receiver.listTransfers(state.assetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase1.transferStatusBeforeRestart = transferBeforeRestart?.status; + expect(transferBeforeRestart?.status).toBe('WaitingConfirmations'); + + const restartedReceiver = await restartReceiver(); + report.phase2.receiverRecreated = true; + + await mine(1); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await restartedReceiver.refreshWallet(); + return restartedReceiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + report.phase2.currentTransferStatus = currentTransfer.status; + report.phase2.currentTransferTxid = currentTransfer.txid ?? null; + report.phase2.txidMatch = currentTransfer.txid === sendResult.txid; + + const balance = await restartedReceiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(balance.settled ?? 0); + report.phase2.receiverSettledAfter = receiverSettledAfter; + + expect(currentTransfer.status).toBe('Settled'); + expect(currentTransfer.txid).toBe(sendResult.txid); + expect(receiverSettledAfter - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-restart-after-ack-before-settled.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/restart-mid-flow.test.ts b/tests/regtest/restart-mid-flow.test.ts new file mode 100644 index 0000000..54465ea --- /dev/null +++ b/tests/regtest/restart-mid-flow.test.ts @@ -0,0 +1,380 @@ +import { + pollCondition, + pollAck, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + createRegtestWalletFromKeys, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestKeys, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type TransferViewSnapshot = { + unfilteredStatus?: string; + filteredStatus?: string; + filteredError?: string | null; + unfilteredCount: number; + filteredCount?: number; +}; + +type RestartMidFlowReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; + }; + phase1: { + invoice: string; + recipientId: string; + txid?: string; + ackBeforeRestart?: boolean | null; + transferStatusBeforeRestart?: string; + }; + phase2: { + receiverRecreated?: boolean; + transferStatusAfterRestartBeforeRefresh?: string; + ackAfterRefresh?: boolean; + validatedAfterRefresh?: boolean; + transferStatusAfterRefresh?: string; + filteredTransferStatusAfterRefresh?: string; + filteredTransferErrorAfterRefresh?: string | null; + postRefreshSnapshots?: TransferViewSnapshot[]; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfter?: number; + }; +}; + +type State = { + walletManagerCtor: WalletManagerCtor | null; + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + receiverKeys: RegtestKeys | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + walletManagerCtor: null, + sender: null, + receiver: null, + receiverKeys: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +async function restartReceiver(): Promise { + const walletCtor = state.walletManagerCtor!; + const receiverKeys = state.receiverKeys!; + await state.receiver?.dispose(); + const receiver = await createRegtestWalletFromKeys( + walletCtor, + receiverKeys, + 'receiver', + getRegtestBaseDir() + ); + state.receiver = receiver; + return receiver; +} + +async function snapshotTransferViews( + wallet: RegtestWallet, + recipientId: string, + assetId: string +): Promise { + const unfilteredTransfers = await wallet.listTransfers(); + const unfilteredTransfer = unfilteredTransfers.find( + (item) => item.recipientId === recipientId + ); + + try { + const filteredTransfers = await wallet.listTransfers(assetId); + const filteredTransfer = filteredTransfers.find( + (item) => item.recipientId === recipientId + ); + return { + unfilteredStatus: unfilteredTransfer?.status, + filteredStatus: filteredTransfer?.status, + filteredError: null, + unfilteredCount: unfilteredTransfers.length, + filteredCount: filteredTransfers.length, + }; + } catch (error) { + return { + unfilteredStatus: unfilteredTransfer?.status, + filteredStatus: undefined, + filteredError: String(error), + unfilteredCount: unfilteredTransfers.length, + }; + } +} + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + state.walletManagerCtor = WalletManager; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver, keys: receiverKeys } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + state.receiverKeys = receiverKeys; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `RM${Date.now().toString().slice(-4)}`, + name: `RestartMidFlow${Date.now().toString().slice(-6)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest receiver restart mid-flow', () => { + it('recreates receiver before the first refresh and still converges from WaitingCounterparty to Settled', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: RestartMidFlowReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoice: '', + recipientId: '', + }, + phase2: {}, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + const ackBeforeRestart = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + report.phase1.ackBeforeRestart = ackBeforeRestart; + + const transferBeforeRestart = (await receiver.listTransfers()).find( + (item) => item.recipientId === invoiceData.recipientId + ); + report.phase1.transferStatusBeforeRestart = transferBeforeRestart?.status; + expect(transferBeforeRestart?.status).toBe('WaitingCounterparty'); + + const restartedReceiver = await restartReceiver(); + report.phase2.receiverRecreated = true; + + const transferAfterRestartBeforeRefresh = ( + await restartedReceiver.listTransfers() + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase2.transferStatusAfterRestartBeforeRefresh = + transferAfterRestartBeforeRefresh?.status; + expect(transferAfterRestartBeforeRefresh?.status).toBe( + 'WaitingCounterparty' + ); + + await restartedReceiver.refreshWallet(); + + const ackAfterRefresh = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + const validatedAfterRefresh = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + report.phase2.ackAfterRefresh = ackAfterRefresh; + report.phase2.validatedAfterRefresh = validatedAfterRefresh; + expect(ackAfterRefresh).toBe(true); + expect(validatedAfterRefresh).toBe(true); + + const postRefreshSnapshots: TransferViewSnapshot[] = []; + let transferAfterRefresh: TransferViewSnapshot; + try { + transferAfterRefresh = await pollCondition( + async () => { + const snapshot = await snapshotTransferViews( + restartedReceiver, + invoiceData.recipientId, + state.assetId + ); + postRefreshSnapshots.push(snapshot); + if (postRefreshSnapshots.length > 12) { + postRefreshSnapshots.shift(); + } + return snapshot; + }, + (snapshot) => + snapshot.unfilteredStatus === 'WaitingConfirmations' || + snapshot.filteredStatus === 'WaitingConfirmations', + 10_000, + 250, + `Transfer for recipient_id=${invoiceData.recipientId} did not become visible in WaitingConfirmations after recreate+refresh` + ); + } catch (error) { + report.phase2.postRefreshSnapshots = postRefreshSnapshots; + throw new Error( + `After recreate+refresh, transfer for recipient_id=${invoiceData.recipientId} was not visible in WaitingConfirmations.\nSnapshots=${JSON.stringify( + postRefreshSnapshots, + null, + 2 + )}\nOriginal error=${String(error)}` + ); + } + + report.phase2.postRefreshSnapshots = postRefreshSnapshots; + report.phase2.transferStatusAfterRefresh = + transferAfterRefresh.unfilteredStatus; + report.phase2.filteredTransferStatusAfterRefresh = + transferAfterRefresh.filteredStatus; + report.phase2.filteredTransferErrorAfterRefresh = + transferAfterRefresh.filteredError; + expect( + transferAfterRefresh.unfilteredStatus === 'WaitingConfirmations' || + transferAfterRefresh.filteredStatus === 'WaitingConfirmations' + ).toBe(true); + + await mine(1); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await restartedReceiver.refreshWallet(); + return restartedReceiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + report.phase2.currentTransferStatus = currentTransfer.status; + report.phase2.currentTransferTxid = currentTransfer.txid ?? null; + report.phase2.txidMatch = currentTransfer.txid === sendResult.txid; + + const balance = await restartedReceiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(balance.settled ?? 0); + report.phase2.receiverSettledAfter = receiverSettledAfter; + + expect(currentTransfer.status).toBe('Settled'); + expect(currentTransfer.txid).toBe(sendResult.txid); + expect(receiverSettledAfter - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-restart-mid-flow.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/send-batch-same-receiver.test.ts b/tests/regtest/send-batch-same-receiver.test.ts new file mode 100644 index 0000000..18574b8 --- /dev/null +++ b/tests/regtest/send-batch-same-receiver.test.ts @@ -0,0 +1,280 @@ +import { + pollCondition, + pollAck, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const PROXY_RPC_URL = getRegtestProxyRpcUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type SendBatchReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; + }; + phase1: { + invoiceA: string; + recipientIdA: string; + invoiceB: string; + recipientIdB: string; + batchTxid?: string; + ackA?: boolean; + ackB?: boolean; + validatedA?: boolean; + validatedB?: boolean; + transferStatusA?: string; + transferStatusB?: string; + }; + phase2: { + receiverSettledAfter?: number; + receiverDelta?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `SB${Date.now().toString().slice(-4)}`, + name: `SendBatch${Date.now().toString().slice(-6)}`, + amounts: [10, 10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT * 2, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest sendBatch to same receiver', () => { + it('settles two invoices in one batch transaction', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: SendBatchReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: PROXY_RPC_URL, + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoiceA: '', + recipientIdA: '', + invoiceB: '', + recipientIdB: '', + }, + phase2: {}, + }; + + try { + const [invoiceA, invoiceB] = await Promise.all([ + receiver.blindReceive({ amount: TRANSFER_AMOUNT, minConfirmations: 1 }), + receiver.blindReceive({ amount: TRANSFER_AMOUNT, minConfirmations: 1 }), + ]); + report.phase1.invoiceA = invoiceA.invoice; + report.phase1.recipientIdA = invoiceA.recipientId; + report.phase1.invoiceB = invoiceB.invoice; + report.phase1.recipientIdB = invoiceB.recipientId; + expect(invoiceA.recipientId).not.toBe(invoiceB.recipientId); + + const sendResult = await sender.sendBatch({ + recipientMap: { + [state.assetId]: [ + { + recipientId: invoiceA.recipientId, + witnessData: null, + assignment: { Fungible: TRANSFER_AMOUNT }, + transportEndpoints: [PROXY_RPC_URL], + }, + { + recipientId: invoiceB.recipientId, + witnessData: null, + assignment: { Fungible: TRANSFER_AMOUNT }, + transportEndpoints: [PROXY_RPC_URL], + }, + ], + }, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.batchTxid = sendResult.txid; + + await mine(1); + + report.phase1.ackA = await pollAck( + PROXY_HTTP_URL, + invoiceA.recipientId, + 30_000, + 1_000 + ); + report.phase1.ackB = await pollAck( + PROXY_HTTP_URL, + invoiceB.recipientId, + 30_000, + 1_000 + ); + report.phase1.validatedA = await pollValidated( + PROXY_HTTP_URL, + invoiceA.recipientId, + 30_000, + 1_000 + ); + report.phase1.validatedB = await pollValidated( + PROXY_HTTP_URL, + invoiceB.recipientId, + 30_000, + 1_000 + ); + + const transferA = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceA.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + const transferB = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceB.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + report.phase1.transferStatusA = transferA.status; + report.phase1.transferStatusB = transferB.status; + + expect(report.phase1.ackA).toBe(true); + expect(report.phase1.ackB).toBe(true); + expect(report.phase1.validatedA).toBe(true); + expect(report.phase1.validatedB).toBe(true); + expect(report.phase1.transferStatusA).toBe('Settled'); + expect(report.phase1.transferStatusB).toBe('Settled'); + + const receiverBalance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(receiverBalance.settled ?? 0); + const receiverDelta = receiverSettledAfter - state.receiverSettledBefore; + report.phase2.receiverSettledAfter = receiverSettledAfter; + report.phase2.receiverDelta = receiverDelta; + + expect(receiverDelta).toBe(TRANSFER_AMOUNT * 2); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-send-batch-same-receiver.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/sequential-receives-same-wallet.test.ts b/tests/regtest/sequential-receives-same-wallet.test.ts new file mode 100644 index 0000000..2b60eee --- /dev/null +++ b/tests/regtest/sequential-receives-same-wallet.test.ts @@ -0,0 +1,315 @@ +import { + pollCondition, + pollAck, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const SEQUENTIAL_ITERATIONS = 3; + +type SequentialReceivesReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; + iterations: number; + }; + cycles: Array<{ + cycle: number; + invoice: string; + recipientId: string; + senderSpendableBeforeSend?: number; + sendAttempts?: number; + sendError?: string; + txid?: string; + ack?: boolean; + validated?: boolean; + status?: string; + receiverSettledAfterCycle?: number; + senderSpendableAfterCycle?: number; + }>; + phase2: { + finalSettled?: number; + totalDelta?: number; + postRefreshSettled?: number[]; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `SQ${Date.now().toString().slice(-4)}`, + name: `Sequential${Date.now().toString().slice(-6)}`, + amounts: [20], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => + Number(balance.spendable ?? 0) >= SEQUENTIAL_ITERATIONS * TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest multiple sequential receives on same wallet state', () => { + it('processes repeated receives without state drift or slot leakage', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: SequentialReceivesReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + receiverSettledBefore: state.receiverSettledBefore, + iterations: SEQUENTIAL_ITERATIONS, + }, + cycles: [], + phase2: {}, + }; + + try { + let expectedSettledLowerBound = state.receiverSettledBefore; + + for (let cycle = 1; cycle <= SEQUENTIAL_ITERATIONS; cycle += 1) { + const cycleReport: SequentialReceivesReport['cycles'][number] = { + cycle, + invoice: '', + recipientId: '', + }; + report.cycles.push(cycleReport); + + const senderBalanceBeforeSend = await pollCondition( + async () => { + await sender.refreshWallet(); + return sender + .getAssetBalance(state.assetId) + .catch(() => ({ spendable: 0 })); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Sender spendable balance is not ready before cycle=${cycle}` + ); + cycleReport.senderSpendableBeforeSend = Number( + senderBalanceBeforeSend.spendable ?? 0 + ); + + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + cycleReport.invoice = invoiceData.invoice; + cycleReport.recipientId = invoiceData.recipientId; + + cycleReport.sendAttempts = 1; + let sendResult: { txid: string }; + try { + sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + } catch (error) { + const serialized = [String(error), (error as Error)?.message ?? ''] + .filter(Boolean) + .join('\n'); + cycleReport.sendError = serialized; + if (/InsufficientAssignments/i.test(serialized)) { + throw new Error( + `Sequential receive failed at cycle=${cycle} with InsufficientAssignments. This indicates slot leakage or state drift in the sender path.\n${serialized}` + ); + } + throw error; + } + cycleReport.txid = sendResult.txid; + + await mine(1); + + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + cycleReport.ack = ack; + cycleReport.validated = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + + const transfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + cycleReport.status = transfer.status; + expect(transfer.status).toBe('Settled'); + + const balance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfterCycle = Number(balance.settled ?? 0); + cycleReport.receiverSettledAfterCycle = receiverSettledAfterCycle; + expectedSettledLowerBound += TRANSFER_AMOUNT; + expect(receiverSettledAfterCycle).toBe(expectedSettledLowerBound); + + // Last cycle: no next send depends on spendable recovery; capture observed value as-is. + const senderBalanceAfterCycle = await pollCondition( + async () => { + await sender.refreshWallet(); + return sender + .getAssetBalance(state.assetId) + .catch(() => ({ spendable: 0 })); + }, + (senderBalance) => + Number(senderBalance.spendable ?? 0) >= TRANSFER_AMOUNT || + cycle === SEQUENTIAL_ITERATIONS, + 30_000, + 1_000, + `Sender spendable balance did not recover after cycle=${cycle}` + ); + cycleReport.senderSpendableAfterCycle = Number( + senderBalanceAfterCycle.spendable ?? 0 + ); + } + + const finalBalance = await receiver.getAssetBalance(state.assetId); + const finalSettled = Number(finalBalance.settled ?? 0); + const totalDelta = finalSettled - state.receiverSettledBefore; + report.phase2.finalSettled = finalSettled; + report.phase2.totalDelta = totalDelta; + expect(totalDelta).toBe(SEQUENTIAL_ITERATIONS * TRANSFER_AMOUNT); + + const postRefreshSettled: number[] = []; + for (let refreshCycle = 1; refreshCycle <= 2; refreshCycle += 1) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(state.assetId); + postRefreshSettled.push(Number(balance.settled ?? 0)); + } + report.phase2.postRefreshSettled = postRefreshSettled; + expect(new Set(postRefreshSettled).size).toBe(1); + expect(postRefreshSettled[0]).toBe(finalSettled); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-sequential-receives-same-wallet.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/sequential-sends-same-receiver.test.ts b/tests/regtest/sequential-sends-same-receiver.test.ts new file mode 100644 index 0000000..7bebb09 --- /dev/null +++ b/tests/regtest/sequential-sends-same-receiver.test.ts @@ -0,0 +1,275 @@ +import { + pollCondition, + pollAck, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; + +type SequentialSendsReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; + }; + phase1: { + invoiceA: string; + recipientIdA: string; + invoiceB: string; + recipientIdB: string; + sequentialResults: Array<{ + label: 'A' | 'B'; + recipientId: string; + txid?: string; + error?: string; + ack?: boolean; + validated?: boolean; + status?: string; + }>; + }; + phase2: { + receiverSettledAfter?: number; + receiverDelta?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `PS${Date.now().toString().slice(-4)}`, + name: `SequentialSend${Date.now().toString().slice(-6)}`, + amounts: [10, 10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT * 2, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest sequential sends to same receiver', () => { + it('settles two sequential sends to the same receiver wallet', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: SequentialSendsReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoiceA: '', + recipientIdA: '', + invoiceB: '', + recipientIdB: '', + sequentialResults: [], + }, + phase2: {}, + }; + + try { + const [invoiceA, invoiceB] = await Promise.all([ + receiver.blindReceive({ amount: TRANSFER_AMOUNT, minConfirmations: 1 }), + receiver.blindReceive({ amount: TRANSFER_AMOUNT, minConfirmations: 1 }), + ]); + report.phase1.invoiceA = invoiceA.invoice; + report.phase1.recipientIdA = invoiceA.recipientId; + report.phase1.invoiceB = invoiceB.invoice; + report.phase1.recipientIdB = invoiceB.recipientId; + expect(invoiceA.recipientId).not.toBe(invoiceB.recipientId); + + const attemptPlan = [ + { + label: 'A' as const, + invoice: invoiceA.invoice, + recipientId: invoiceA.recipientId, + }, + { + label: 'B' as const, + invoice: invoiceB.invoice, + recipientId: invoiceB.recipientId, + }, + ]; + for (const attempt of attemptPlan) { + const item: SequentialSendsReport['phase1']['sequentialResults'][number] = + { + label: attempt.label, + recipientId: attempt.recipientId, + }; + report.phase1.sequentialResults.push(item); + + try { + const sendResult = await sender.send({ + invoice: attempt.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + item.txid = sendResult.txid; + await mine(1); + item.ack = await pollAck( + PROXY_HTTP_URL, + attempt.recipientId, + 30_000, + 1_000 + ); + item.validated = await pollValidated( + PROXY_HTTP_URL, + attempt.recipientId, + 30_000, + 1_000 + ); + const transfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + attempt.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + item.status = transfer.status; + expect(item.ack).toBe(true); + expect(item.validated).toBe(true); + expect(item.status).toBe('Settled'); + } catch (error) { + const serializedError = [ + String(error), + (error as Error)?.message ?? '', + ] + .filter(Boolean) + .join('\n'); + item.error = serializedError; + throw new Error( + `Sequential send ${attempt.label} failed unexpectedly.\n${serializedError}` + ); + } + } + + expect(report.phase1.sequentialResults.length).toBe(2); + + const receiverBalance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(receiverBalance.settled ?? 0); + const receiverDelta = receiverSettledAfter - state.receiverSettledBefore; + report.phase2.receiverSettledAfter = receiverSettledAfter; + report.phase2.receiverDelta = receiverDelta; + + expect(receiverDelta).toBe(TRANSFER_AMOUNT * 2); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-sequential-sends-same-receiver.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/upload-guard.test.ts b/tests/regtest/upload-guard.test.ts new file mode 100644 index 0000000..782ce17 --- /dev/null +++ b/tests/regtest/upload-guard.test.ts @@ -0,0 +1,192 @@ +import { writeSmokeReport } from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + postConsignmentRaw, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); + +type UploadGuardReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + receiverAddress: string; + }; + duplicateSameFile: { + recipientId?: string; + firstResult?: boolean; + secondResult?: boolean; + }; + changedFile: { + recipientId?: string; + firstResult?: boolean; + errorCode?: number; + errorMessage?: string; + }; +}; + +type State = { + receiver: RegtestWallet | null; + receiverAddress: string; +}; + +const state: State = { + receiver: null, + receiverAddress: '', +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.receiver = receiver; + state.receiverAddress = (await fundWallet(receiver)).address; +}); + +afterAll(async () => { + await state.receiver?.dispose(); +}); + +describe('Regtest upload guard semantics', () => { + // These cases intentionally reuse the same receiver wallet state in a single run. + it('duplicate consignment.post with the same file returns false on second upload', async () => { + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: UploadGuardReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + receiverAddress: state.receiverAddress, + }, + duplicateSameFile: {}, + changedFile: {}, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: 1, + minConfirmations: 1, + }); + report.duplicateSameFile.recipientId = invoiceData.recipientId; + + const first = await postConsignmentRaw({ + recipientId: invoiceData.recipientId, + txid: '0000000000000000000000000000000000000000000000000000000000000011', + fileName: 'same-a.rgbc', + content: 'same-consignment-content', + }); + const second = await postConsignmentRaw({ + recipientId: invoiceData.recipientId, + txid: '0000000000000000000000000000000000000000000000000000000000000011', + fileName: 'same-b.rgbc', + content: 'same-consignment-content', + }); + + report.duplicateSameFile.firstResult = Boolean(first.result); + report.duplicateSameFile.secondResult = Boolean(second.result); + + expect(first.error).toBeUndefined(); + expect(first.result).toBe(true); + expect(second.error).toBeUndefined(); + expect(second.result).toBe(false); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-upload-guard-duplicate.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); + + it('consignment.post with a changed file for the same recipient fails with CannotChangeUploadedFile', async () => { + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: UploadGuardReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + receiverAddress: state.receiverAddress, + }, + duplicateSameFile: {}, + changedFile: {}, + }; + + try { + const invoiceData = await receiver.blindReceive({ + amount: 1, + minConfirmations: 1, + }); + report.changedFile.recipientId = invoiceData.recipientId; + + const first = await postConsignmentRaw({ + recipientId: invoiceData.recipientId, + txid: '0000000000000000000000000000000000000000000000000000000000000022', + fileName: 'change-a.rgbc', + content: 'original-consignment-content', + }); + report.changedFile.firstResult = Boolean(first.result); + + const second = await postConsignmentRaw({ + recipientId: invoiceData.recipientId, + txid: '0000000000000000000000000000000000000000000000000000000000000022', + fileName: 'change-b.rgbc', + content: 'changed-consignment-content', + }); + + report.changedFile.errorCode = second.error?.code; + report.changedFile.errorMessage = second.error?.message; + + expect(first.error).toBeUndefined(); + expect(first.result).toBe(true); + expect(second.result).toBeUndefined(); + expect(second.error?.code).toBe(-101); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-upload-guard-changed-file.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/witness-donation-false.test.ts b/tests/regtest/witness-donation-false.test.ts new file mode 100644 index 0000000..d9edb06 --- /dev/null +++ b/tests/regtest/witness-donation-false.test.ts @@ -0,0 +1,313 @@ +import { + pollCondition, + pollTransferByRecipientId, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + bitcoindRpc, + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const SEND_FEE_RATE = 2; +const WITNESS_AMOUNT_SAT = 1_000; + +type WitnessDonationFalseReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + assetId: string; + senderAddress: string; + receiverAddress: string; + receiverSettledBefore: number; + }; + phase1: { + invoiceType: 'witness'; + witnessAmountSat: number; + invoice: string; + recipientId: string; + unsignedPsbtLength?: number; + txid?: string; + ackBeforeRefresh?: boolean | null; + txKnownBeforeRefresh?: boolean; + transferStatusAfterRefresh?: string; + receiverSettledAfterRefresh?: number; + ackAfterRefresh?: boolean | null; + lateManualAckPosted?: boolean; + txKnownAfterReceiverRefresh?: boolean; + txKnownAfterSenderRefresh?: boolean; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfter?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + receiverSettledBefore: 0, +}; + +async function isTransactionKnown(txid: string): Promise { + try { + await bitcoindRpc('getrawtransaction', [txid]); + return true; + } catch { + return false; + } +} + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + state.sender = sender; + state.receiver = receiver; + + state.senderAddress = (await fundWallet(sender)).address; + const issuedAsset = await sender.issueAssetNia({ + ticker: `WD${Date.now().toString().slice(-4)}`, + name: `WitDonate${Date.now().toString().slice(-6)}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued asset ${state.assetId} did not become spendable in time` + ); + + state.receiverAddress = (await fundWallet(receiver)).address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest witness donation=false flow', () => { + it('broadcasts witness transfer only after receiver ACK path and sender refresh run', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: WitnessDonationFalseReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoiceType: 'witness', + witnessAmountSat: WITNESS_AMOUNT_SAT, + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.witnessReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const unsignedPsbt = await sender.sendBegin({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: false, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + witnessData: { amountSat: WITNESS_AMOUNT_SAT }, + }); + report.phase1.unsignedPsbtLength = unsignedPsbt.length; + expect(unsignedPsbt.length).toBeGreaterThan(0); + + const signedPsbt = await sender.signPsbt(unsignedPsbt); + const sendResult = await sender.sendEnd({ signedPsbt }); + report.phase1.txid = sendResult.txid; + + const ackBeforeRefresh = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ); + report.phase1.ackBeforeRefresh = ackBeforeRefresh; + expect([null, true]).toContain(ackBeforeRefresh); + + const txKnownBeforeRefresh = await isTransactionKnown(sendResult.txid); + report.phase1.txKnownBeforeRefresh = txKnownBeforeRefresh; + expect(txKnownBeforeRefresh).toBe(false); + + await receiver.refreshWallet(); + const transferAfterRefresh = ( + await receiver.listTransfers(state.assetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase1.transferStatusAfterRefresh = transferAfterRefresh?.status; + const receiverBalanceAfterRefresh = await receiver.getAssetBalance( + state.assetId + ); + const receiverSettledAfterRefresh = Number( + receiverBalanceAfterRefresh.settled ?? 0 + ); + report.phase1.receiverSettledAfterRefresh = receiverSettledAfterRefresh; + expect(transferAfterRefresh?.status).toBe('WaitingConfirmations'); + expect(receiverSettledAfterRefresh).toBe(state.receiverSettledBefore); + + const ackAfterRefresh = await pollCondition( + async () => + proxyRpc(PROXY_HTTP_URL, 'ack.get', { + recipient_id: invoiceData.recipientId, + }), + (ack) => ack === true, + 10_000, + 500, + `Receiver-side witness ACK did not appear for recipient_id=${invoiceData.recipientId}` + ); + report.phase1.ackAfterRefresh = ackAfterRefresh; + expect(ackAfterRefresh).toBe(true); + + const lateManualAckPosted = await proxyRpc( + PROXY_HTTP_URL, + 'ack.post', + { + recipient_id: invoiceData.recipientId, + ack: true, + } + ); + report.phase1.lateManualAckPosted = lateManualAckPosted; + expect(lateManualAckPosted).toBe(false); + + const txKnownAfterReceiverRefresh = await isTransactionKnown( + sendResult.txid + ); + report.phase1.txKnownAfterReceiverRefresh = txKnownAfterReceiverRefresh; + expect(txKnownAfterReceiverRefresh).toBe(false); + + const txKnownAfterSenderRefresh = await pollCondition( + async () => { + await sender.refreshWallet(); + return isTransactionKnown(sendResult.txid); + }, + (known) => known === true, + 10_000, + 500, + `witness donation=false tx ${sendResult.txid} did not reach mempool/chain after sender refresh` + ); + report.phase1.txKnownAfterSenderRefresh = txKnownAfterSenderRefresh; + expect(txKnownAfterSenderRefresh).toBe(true); + + await mine(1); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 20_000, + 1_000 + ); + report.phase1.currentTransferStatus = currentTransfer.status; + report.phase1.currentTransferTxid = currentTransfer.txid ?? null; + report.phase1.txidMatch = currentTransfer.txid === sendResult.txid; + + const receiverBalance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(receiverBalance.settled ?? 0); + report.phase1.receiverSettledAfter = receiverSettledAfter; + + expect(currentTransfer.status).toBe('Settled'); + expect(currentTransfer.txid).toBe(sendResult.txid); + expect(receiverSettledAfter - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-witness-donation-false.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/regtest/witness-receiver.test.ts b/tests/regtest/witness-receiver.test.ts new file mode 100644 index 0000000..8c09954 --- /dev/null +++ b/tests/regtest/witness-receiver.test.ts @@ -0,0 +1,390 @@ +import { + pollCondition, + pollAck, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + writeSmokeReport, +} from '../shared/helpers'; +import { + createRegtestWallet, + env, + ensureBitcoindAccess, + fundWallet, + getRegtestBaseDir, + getRegtestIndexerUrl, + getRegtestProxyHttpUrl, + getRegtestProxyRpcUrl, + mine, + resetWalletDataDirs, + type GenerateKeysFn, + type RegtestWallet, + type WalletManagerCtor, +} from './helpers'; + +const PROXY_HTTP_URL = getRegtestProxyHttpUrl(); +const TRANSFER_AMOUNT = 1; +const IDEMPOTENT_REFRESH_COUNT = 3; +const SEND_FEE_RATE = 2; +const WITNESS_AMOUNT_SAT = 1_000; + +type WitnessReceiverReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + senderSpendableBefore: number; + receiverSettledBefore: number; + }; + phase1: { + invoiceType: 'witness'; + witnessAmountSat: number; + invoice: string; + recipientId: string; + txid?: string; + ack?: boolean; + validated?: boolean; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + txidMatch?: boolean; + receiverSettledAfter?: number; + }; + phase2: { + refreshChecks: Array<{ cycle: number; settled: number }>; + receiverSettledFinal?: number; + }; +}; + +type WitnessMissingDataReport = { + timestamp: string; + durationMs: number; + preconditions: { + proxyHttpUrl: string; + proxyRpcUrl: string; + indexerUrl: string; + senderAddress: string; + receiverAddress: string; + assetId: string; + receiverSettledBefore: number; + }; + phase1: { + invoiceType: 'witness'; + invoice: string; + recipientId: string; + sendError?: string; + ackAfterFailure?: boolean | null; + transferStatusAfterFailure?: string; + receiverSettledAfter?: number; + }; +}; + +type State = { + sender: RegtestWallet | null; + receiver: RegtestWallet | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + senderSpendableBefore: number; + receiverSettledBefore: number; +}; + +const state: State = { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + senderSpendableBefore: 0, + receiverSettledBefore: 0, +}; + +beforeAll(async () => { + env('REGTEST_PROXY_HTTP_URL'); + env('REGTEST_PROXY_RPC_URL'); + env('REGTEST_INDEXER_URL'); + env('REGTEST_BITCOIND_USER'); + env('REGTEST_BITCOIND_PASS'); + ensureBitcoindAccess(); + + resetWalletDataDirs(getRegtestBaseDir()); + + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { WalletManager, generateKeys } = + (await import('../../dist/index.mjs')) as { + WalletManager: WalletManagerCtor; + generateKeys: GenerateKeysFn; + }; + + const { wallet: sender } = await createRegtestWallet( + WalletManager, + generateKeys, + 'sender' + ); + const { wallet: receiver } = await createRegtestWallet( + WalletManager, + generateKeys, + 'receiver' + ); + + state.sender = sender; + state.receiver = receiver; + + const senderFunding = await fundWallet(sender); + state.senderAddress = senderFunding.address; + + const issuedAsset = await sender.issueAssetNia({ + ticker: `W${Date.now().toString().slice(-5)}`, + name: `Regtest Witness Asset ${Date.now()}`, + amounts: [10], + precision: 0, + }); + state.assetId = issuedAsset.assetId; + + await mine(1); + const senderBalance = await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(state.assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 30_000, + 1_000, + `Issued witness asset ${state.assetId} did not become spendable in time` + ); + state.senderSpendableBefore = Number(senderBalance.spendable ?? 0); + + const receiverFunding = await fundWallet(receiver); + state.receiverAddress = receiverFunding.address; + await receiver.refreshWallet(); + const receiverBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ + settled: 0, + })); + state.receiverSettledBefore = Number(receiverBalance.settled ?? 0); +}); + +afterAll(async () => { + await state.sender?.dispose(); + await state.receiver?.dispose(); +}); + +describe('Regtest witness receiver', () => { + it('witness receive offline -> auto-ACK -> Settled, refresh is idempotent', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const report: WitnessReceiverReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + senderSpendableBefore: state.senderSpendableBefore, + receiverSettledBefore: state.receiverSettledBefore, + }, + phase1: { + invoiceType: 'witness', + witnessAmountSat: WITNESS_AMOUNT_SAT, + invoice: '', + recipientId: '', + }, + phase2: { + refreshChecks: [], + }, + }; + + try { + const invoiceData = await receiver.witnessReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + witnessData: { amountSat: WITNESS_AMOUNT_SAT }, + }); + report.phase1.txid = sendResult.txid; + + await mine(1); + + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + 30_000, + 1_000 + ); + + report.phase1.ack = ack; + report.phase1.validated = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(state.assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 30_000, + 1_000 + ); + report.phase1.currentTransferStatus = currentTransfer.status; + report.phase1.currentTransferTxid = currentTransfer.txid; + report.phase1.txidMatch = Boolean( + currentTransfer.txid && currentTransfer.txid === sendResult.txid + ); + + const balance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(balance.settled ?? 0); + report.phase1.receiverSettledAfter = receiverSettledAfter; + + expect(currentTransfer.status).toBe('Settled'); + expect(receiverSettledAfter - state.receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + + for (let cycle = 1; cycle <= IDEMPOTENT_REFRESH_COUNT; cycle += 1) { + await receiver.refreshWallet(); + const refreshedBalance = await receiver.getAssetBalance(state.assetId); + report.phase2.refreshChecks.push({ + cycle, + settled: Number(refreshedBalance.settled ?? 0), + }); + } + + const allSettled = report.phase2.refreshChecks.map( + (item) => item.settled + ); + expect(new Set(allSettled).size).toBe(1); + report.phase2.receiverSettledFinal = + report.phase2.refreshChecks.at(-1)?.settled; + expect(report.phase2.receiverSettledFinal).toBe(receiverSettledAfter); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-witness-receiver.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); + + it('witness send without witnessData fails and does not credit receiver', async () => { + const sender = state.sender!; + const receiver = state.receiver!; + const startedAt = Date.now(); + const beforeBalance = await receiver + .getAssetBalance(state.assetId) + .catch(() => ({ settled: 0 })); + const receiverSettledBefore = Number(beforeBalance.settled ?? 0); + const report: WitnessMissingDataReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + proxyHttpUrl: PROXY_HTTP_URL, + proxyRpcUrl: getRegtestProxyRpcUrl(), + indexerUrl: getRegtestIndexerUrl(), + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + assetId: state.assetId, + receiverSettledBefore, + }, + phase1: { + invoiceType: 'witness', + invoice: '', + recipientId: '', + }, + }; + + try { + const invoiceData = await receiver.witnessReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + let sendFailed = false; + try { + await sender.send({ + invoice: invoiceData.invoice, + assetId: state.assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SEND_FEE_RATE, + minConfirmations: 1, + }); + } catch (error) { + sendFailed = true; + report.phase1.sendError = [ + String(error), + (error as Error)?.message ?? '', + ] + .filter(Boolean) + .join('\n'); + } + + expect(sendFailed).toBe(true); + expect(report.phase1.sendError).toBeDefined(); + + await receiver.refreshWallet(); + const transferAfterFailure = ( + await receiver.listTransfers(state.assetId) + ).find((item) => item.recipientId === invoiceData.recipientId); + report.phase1.transferStatusAfterFailure = transferAfterFailure?.status; + expect(transferAfterFailure?.status).not.toBe('Settled'); + + const ackAfterFailure = await proxyRpc( + PROXY_HTTP_URL, + 'ack.get', + { + recipient_id: invoiceData.recipientId, + } + ).catch(() => null); + report.phase1.ackAfterFailure = ackAfterFailure; + expect(ackAfterFailure).not.toBe(true); + + const afterBalance = await receiver.getAssetBalance(state.assetId); + const receiverSettledAfter = Number(afterBalance.settled ?? 0); + report.phase1.receiverSettledAfter = receiverSettledAfter; + expect(receiverSettledAfter).toBe(receiverSettledBefore); + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'regtest-witness-missing-witness-data.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/restore.test.ts b/tests/restore.test.ts index 4a6081b..157125d 100644 --- a/tests/restore.test.ts +++ b/tests/restore.test.ts @@ -12,7 +12,7 @@ import { LAYER1_BACKUP_SUFFIX, UTEXO_BACKUP_SUFFIX, } from '../src/utexo/restore'; -import { ValidationError } from '../src/errors'; +import { ValidationError } from '@utexo/rgb-sdk-core'; describe('restore utilities', () => { describe('getBackupStoreId', () => { diff --git a/tests/shared/helpers.ts b/tests/shared/helpers.ts new file mode 100644 index 0000000..28bfaf7 --- /dev/null +++ b/tests/shared/helpers.ts @@ -0,0 +1,183 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type JsonRpcSuccess = { + jsonrpc: string; + id: string | number; + result: T; +}; + +export type TransferLike = { + recipientId?: string; + status?: string; + txid?: string | null; +}; + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function pollCondition( + fn: () => Promise, + predicate: (value: T) => boolean, + timeoutMs: number, + intervalMs: number, + errorMessage: string +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const value = await fn(); + if (predicate(value)) { + return value; + } + await sleep(intervalMs); + } + + throw new Error(errorMessage); +} + +export async function proxyRpc( + proxyUrl: string, + method: string, + params: Record | null = null +): Promise { + const response = await fetch(proxyUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }), + }); + + const json = await response.json(); + if ('error' in json) { + throw new Error(`${method} failed: ${JSON.stringify(json.error)}`); + } + + return (json as JsonRpcSuccess).result; +} + +export async function pollAck( + proxyUrl: string, + recipientId: string, + timeoutMs = 90_000, + intervalMs = 2_000 +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const ack = await proxyRpc(proxyUrl, 'ack.get', { + recipient_id: recipientId, + }); + if (ack !== null && ack !== undefined) { + return ack; + } + await sleep(intervalMs); + } + + throw new Error(`Timed out waiting for ack on recipient_id=${recipientId}`); +} + +export async function pollValidated( + proxyUrl: string, + recipientId: string, + timeoutMs = 90_000, + intervalMs = 2_000 +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const consignment = await proxyRpc<{ + consignment: string; + txid: string; + validated?: boolean; + }>(proxyUrl, 'consignment.get', { + recipient_id: recipientId, + }); + + if (typeof consignment.validated === 'boolean') { + return consignment.validated; + } + await sleep(intervalMs); + } + + throw new Error( + `Timed out waiting for validated flag on recipient_id=${recipientId}` + ); +} + +export async function pollSettledBalanceDelta( + getAssetBalance: () => Promise<{ settled?: number | string }>, + beforeSettled: number, + expectedDelta: number, + timeoutMs = 180_000, + intervalMs = 5_000 +): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const balance = await getAssetBalance(); + const settled = Number(balance.settled ?? 0); + if (settled >= beforeSettled + expectedDelta) { + return settled; + } + await sleep(intervalMs); + } + + throw new Error( + `Timed out waiting for settled balance delta >= ${expectedDelta} (before=${beforeSettled})` + ); +} + +export async function pollTransferByRecipientId( + listTransfers: () => Promise, + recipientId: string, + sendTxid?: string, + timeoutMs = 120_000, + intervalMs = 5_000 +): Promise { + let lastStatus: string | undefined; + + const transfers = await pollCondition( + async () => listTransfers(), + (items) => { + const transfer = items.find((item) => item.recipientId === recipientId); + lastStatus = transfer?.status; + return transfer?.status === 'Settled'; + }, + timeoutMs, + intervalMs, + `Transfer for recipientId ${recipientId} did not reach Settled within ${timeoutMs}ms (lastStatus=${lastStatus ?? 'missing'})` + ); + + const transfer = transfers.find((item) => item.recipientId === recipientId); + if (!transfer) { + throw new Error( + `Transfer for recipientId ${recipientId} not found after polling` + ); + } + + if (sendTxid && transfer.txid && transfer.txid !== sendTxid) { + console.warn( + `txid mismatch for recipientId ${recipientId}: expected ${sendTxid}, got ${transfer.txid}` + ); + } + + return transfer; +} + +export function writeSmokeReport( + report: unknown, + fileName = 'smoke-report.json' +): string { + const artifactsDir = path.join(process.cwd(), 'artifacts'); + fs.mkdirSync(artifactsDir, { recursive: true }); + + const reportPath = path.join(artifactsDir, fileName); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + return reportPath; +} diff --git a/tests/signer.test.ts b/tests/signer.test.ts index 00e11e8..73df8de 100644 --- a/tests/signer.test.ts +++ b/tests/signer.test.ts @@ -6,9 +6,9 @@ import { signMessage, verifyMessage, ValidationError, + bip39, } from '../dist/index.mjs'; import { estimatePsbt } from '../src/crypto/signer'; -import bip39 from 'bip39'; const expectedKeys = { xpub: 'tpubD6NzVbkrYhZ4XCaTDersU6277zvyyV6uCCeEgx1jfv7bUYMrbTt8Vem1MBt5Gmp7eMwjv4rB54s2kjqNNtTLYpwFsVX7H2H93pJ8SpZFRRi', accountXpubVanilla: @@ -60,7 +60,6 @@ describe('signer', () => { expect(signed).toBeTruthy(); expect(typeof signed).toBe('string'); expect(signed).toMatch(/^cHNidP8/); // PSBT base64 prefix - // Compare with expected signed PSBT expect(signed).toBe(sendSignedPsbt); }); @@ -278,7 +277,13 @@ describe('signer', () => { it('should handle different network types', async () => { // Test with valid network values (string names) - const validNetworks = ['testnet', 'mainnet', 'regtest', 'signet']; + const validNetworks = [ + 'testnet', + 'mainnet', + 'regtest', + 'signet', + 'utexo', + ]; for (const network of validNetworks) { await expect( diff --git a/tests/signet/README.md b/tests/signet/README.md new file mode 100644 index 0000000..f9bd76b --- /dev/null +++ b/tests/signet/README.md @@ -0,0 +1,105 @@ +# UTEXO Tests + +This directory contains the manual UTEXO integration tests for offline receiver flows. + +## Current working configuration + +- Wallets: + - `stage2-sender` + - `stage2-receiver` +- Reusable asset: + - `rgb:VnwMJ~Yh-i9zzuQz-PsocvCb-1j83mQ1-Uv4Zb6w-Ud8dsqk` +- Proxy HTTP endpoint: + - `https://rgb-proxy-utexo.utexo.com/json-rpc` + +Do not use the older wallets (`sender` / `receiver`, `stage-sender` / `stage-receiver`) for this smoke. + +## Local wallet files (not committed) + +`cli/data/stage2-sender.json` and `cli/data/stage2-receiver.json` must stay local-only (they contain secrets and are ignored by git). + +Use the example templates in repo: + +```bash +cd /path/to/rgb-sdk +cp cli/data/stage2-sender.example.json cli/data/stage2-sender.json +cp cli/data/stage2-receiver.example.json cli/data/stage2-receiver.json +``` + +Then replace placeholder values with your real wallet data (or regenerate files with `node cli/generate_keys.mjs utexo`). +## Run + +```bash +cd /path/to/rgb-sdk +SIGNET_PROXY_HTTP_URL="https://rgb-proxy-utexo.utexo.com/json-rpc" \ +MNEMONIC_A="$(node -p "require('./cli/data/stage2-sender.json').mnemonic")" \ +MNEMONIC_B="$(node -p "require('./cli/data/stage2-receiver.json').mnemonic")" \ +ASSET_ID="rgb:VnwMJ~Yh-i9zzuQz-PsocvCb-1j83mQ1-Uv4Zb6w-Ud8dsqk" \ +npm run test:signet +``` + +Run individual tests: + +```bash +cd /path/to/rgb-sdk +SIGNET_PROXY_HTTP_URL="https://rgb-proxy-utexo.utexo.com/json-rpc" \ +MNEMONIC_A="$(node -p "require('./cli/data/stage2-sender.json').mnemonic")" \ +MNEMONIC_B="$(node -p "require('./cli/data/stage2-receiver.json').mnemonic")" \ +ASSET_ID="rgb:VnwMJ~Yh-i9zzuQz-PsocvCb-1j83mQ1-Uv4Zb6w-Ud8dsqk" \ +npm run test:signet:smoke + +cd /path/to/rgb-sdk +SIGNET_PROXY_HTTP_URL="https://rgb-proxy-utexo.utexo.com/json-rpc" \ +MNEMONIC_A="$(node -p "require('./cli/data/stage2-sender.json').mnemonic")" \ +MNEMONIC_B="$(node -p "require('./cli/data/stage2-receiver.json').mnemonic")" \ +ASSET_ID="rgb:VnwMJ~Yh-i9zzuQz-PsocvCb-1j83mQ1-Uv4Zb6w-Ud8dsqk" \ +npm run test:signet:witness + +cd /path/to/rgb-sdk +SIGNET_PROXY_HTTP_URL="https://rgb-proxy-utexo.utexo.com/json-rpc" \ +MNEMONIC_A="$(node -p "require('./cli/data/stage2-sender.json').mnemonic")" \ +MNEMONIC_B="$(node -p "require('./cli/data/stage2-receiver.json').mnemonic")" \ +ASSET_ID="rgb:VnwMJ~Yh-i9zzuQz-PsocvCb-1j83mQ1-Uv4Zb6w-Ud8dsqk" \ +npm run test:signet:convergence + +cd /path/to/rgb-sdk +SIGNET_PROXY_HTTP_URL="https://rgb-proxy-utexo.utexo.com/json-rpc" \ +MNEMONIC_A="$(node -p "require('./cli/data/stage2-sender.json').mnemonic")" \ +MNEMONIC_B="$(node -p "require('./cli/data/stage2-receiver.json').mnemonic")" \ +ASSET_ID="rgb:VnwMJ~Yh-i9zzuQz-PsocvCb-1j83mQ1-Uv4Zb6w-Ud8dsqk" \ +npm run test:signet:sequential +``` + +## Before running sequential test + +Ensure `stage2-receiver` has enough allocation slots for multiple receives in one run: + +```bash +cd /path/to/rgb-sdk +node cli/run.mjs createutxos stage2-receiver --num 10 --size 2000 --feeRate 2 +node cli/run.mjs refresh stage2-receiver +``` + +Minimum recommendation: 2 free receiver slots (one per sequential iteration). + + +## Operational note + +If a UTEXO test fails with `InsufficientAllocationSlots`, create additional UTXOs and refresh the affected wallet. In practice the most common maintenance step is `stage2-receiver`: + +```bash +cd /path/to/rgb-sdk +node cli/run.mjs createutxos stage2-receiver --num 5 --size 2000 --feeRate 2 +node cli/run.mjs refresh stage2-receiver +``` + +If the convergence test still fails after that, top up both wallets: + +```bash +cd /path/to/rgb-sdk +node cli/run.mjs createutxos stage2-sender --num 10 --size 2000 --feeRate 2 +node cli/run.mjs refresh stage2-sender + +node cli/run.mjs createutxos stage2-receiver --num 10 --size 2000 --feeRate 2 +node cli/run.mjs refresh stage2-receiver +``` diff --git a/tests/signet/fixture.ts b/tests/signet/fixture.ts new file mode 100644 index 0000000..2def4df --- /dev/null +++ b/tests/signet/fixture.ts @@ -0,0 +1,511 @@ +import type { AssetBalance } from '../../src/types/wallet-model'; +import { + pollAck, + pollCondition, + pollSettledBalanceDelta, + pollTransferByRecipientId, + pollValidated, + proxyRpc, + type TransferLike, + writeSmokeReport, +} from '../shared/helpers'; + +export const PROXY_HTTP_URL = + process.env.SIGNET_PROXY_HTTP_URL || + 'https://rgb-proxy-utexo.utexo.com/json-rpc'; +export const NETWORK = 'testnet'; +export const TRANSFER_AMOUNT = 1; +export const MIN_RECEIVER_BTC_SAT = 2_000; +export const PRECONDITION_TIMEOUT_MS = 180_000; +export const PRECONDITION_POLL_MS = 5_000; +export const IDEMPOTENT_REFRESH_COUNT = 3; +export const SMOKE_FEE_RATE = 2; + +export type TransferRecord = TransferLike & { + idx?: number; + batchTransferIdx?: number; + createdAt?: number; + updatedAt?: number; + requestedAssignment?: unknown; + assignments?: unknown[]; + kind?: string; + receiveUtxo?: unknown; + changeUtxo?: unknown; + expiration?: number; + transportEndpoints?: unknown[]; + invoiceString?: string; + consignmentPath?: string | null; +}; + +export type InvoiceData = { + invoice: string; + recipientId: string; +}; + +export type SendParams = { + invoice: string; + assetId: string; + amount: number; + donation: boolean; + feeRate: number; + minConfirmations: number; + witnessData?: { amountSat: number; blinding?: number | null }; +}; + +export type UTEXOWalletType = { + initialize(): Promise; + dispose(): Promise; + getAddress(): Promise; + refreshWallet(): Promise; + getAssetBalance(assetId: string): Promise; + getBtcBalance(): Promise<{ vanilla: { spendable: number | string } }>; + blindReceive(params: { + amount: number; + minConfirmations: number; + }): Promise; + witnessReceive(params: { + amount: number; + minConfirmations: number; + }): Promise; + send(params: SendParams): Promise<{ txid: string }>; + listTransfers(assetId?: string): Promise; +}; + +export type UTEXOWalletCtor = new ( + mnemonicOrSeed: string, + options: { network: string } +) => UTEXOWalletType; + +export type PreflightState = { + sender: UTEXOWalletType | null; + receiver: UTEXOWalletType | null; + senderAddress: string; + receiverAddress: string; + assetId: string; + senderBalanceBefore: AssetBalance | null; + receiverBalanceBefore: AssetBalance | null; + skipReason: string | null; +}; + +export function createPreflightState(): PreflightState { + return { + sender: null, + receiver: null, + senderAddress: '', + receiverAddress: '', + assetId: '', + senderBalanceBefore: null, + receiverBalanceBefore: null, + skipReason: null, + }; +} + +export function env(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +function isInsufficientAllocationSlotsError(error: unknown): boolean { + const serialized = [ + String(error), + error instanceof Error ? error.message : '', + error instanceof Error ? error.stack : '', + ] + .filter(Boolean) + .join(' '); + return ( + serialized.includes('InsufficientAllocationSlots') || + serialized.includes('AllocationSlots') + ); +} + +export function maybeSkipOrFail(reason: string): never | void { + if (process.env.CI === 'true') { + throw new Error(reason); + } + + const pendingFn = (globalThis as { pending?: (message?: string) => void }) + .pending; + if (typeof pendingFn === 'function') { + console.warn(`SKIPPED: ${reason}`); + pendingFn(reason); + return; + } + + throw new Error(`SKIPPED: ${reason}`); +} + +export async function setupSignetPreflight( + state: PreflightState +): Promise { + env('MNEMONIC_A'); + env('MNEMONIC_B'); + env('ASSET_ID'); + + await proxyRpc<{ protocol_version: string; version: string }>( + PROXY_HTTP_URL, + 'server.info' + ); + + const { UTEXOWallet } = (await import('../../dist/index.mjs')) as { + UTEXOWallet: UTEXOWalletCtor; + }; + const sender = new UTEXOWallet(env('MNEMONIC_A'), { network: NETWORK }); + const receiver = new UTEXOWallet(env('MNEMONIC_B'), { network: NETWORK }); + + await sender.initialize(); + await receiver.initialize(); + + state.sender = sender; + state.receiver = receiver; + state.senderAddress = await sender.getAddress(); + state.receiverAddress = await receiver.getAddress(); + + const assetId = env('ASSET_ID'); + state.assetId = assetId; + state.senderBalanceBefore = await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(assetId); + }, + (balance) => Number(balance.spendable) >= TRANSFER_AMOUNT, + PRECONDITION_TIMEOUT_MS, + PRECONDITION_POLL_MS, + `Sender asset did not become spendable within ${PRECONDITION_TIMEOUT_MS}ms for ${assetId}` + ).catch((error) => { + state.skipReason = error instanceof Error ? error.message : String(error); + return sender.getAssetBalance(assetId); + }); + + state.receiverBalanceBefore = await receiver + .getAssetBalance(assetId) + .catch(() => ({ + settled: 0, + future: 0, + spendable: 0, + offchainOutbound: 0, + offchainInbound: 0, + })); + + const receiverBtcBalance = await pollCondition( + async () => { + await receiver.refreshWallet(); + return receiver.getBtcBalance(); + }, + (balance) => Number(balance.vanilla.spendable) >= MIN_RECEIVER_BTC_SAT, + PRECONDITION_TIMEOUT_MS, + PRECONDITION_POLL_MS, + `Receiver BTC balance did not reach ${MIN_RECEIVER_BTC_SAT} sats within ${PRECONDITION_TIMEOUT_MS}ms` + ).catch((error) => { + state.skipReason = error instanceof Error ? error.message : String(error); + return receiver.getBtcBalance(); + }); + + if ( + !state.skipReason && + Number(state.senderBalanceBefore.spendable) < TRANSFER_AMOUNT + ) { + state.skipReason = `Sender asset is not spendable enough: need ${TRANSFER_AMOUNT}, got ${state.senderBalanceBefore.spendable}`; + } + + if ( + !state.skipReason && + Number(receiverBtcBalance.vanilla.spendable) < MIN_RECEIVER_BTC_SAT + ) { + state.skipReason = `Receiver BTC balance is insufficient for receive flow: need ${MIN_RECEIVER_BTC_SAT}, got ${receiverBtcBalance.vanilla.spendable}`; + } +} + +export async function disposeSignetPreflight( + state: PreflightState +): Promise { + await state.sender?.dispose(); + await state.receiver?.dispose(); +} + +export function createBaseReport(state: PreflightState) { + const receiverSettledBefore = Number( + state.receiverBalanceBefore?.settled ?? 0 + ); + + return { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + assetId: state.assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + senderSpendableBefore: Number(state.senderBalanceBefore?.spendable ?? 0), + receiverSettledBefore, + receiverMinBtcSat: MIN_RECEIVER_BTC_SAT, + }, + phase1: { + invoiceType: undefined as 'witness' | 'blind' | undefined, + witnessAmountSat: undefined as number | undefined, + invoice: '', + recipientId: '', + txid: undefined as string | undefined, + ack: undefined as boolean | null | undefined, + validated: undefined as boolean | null | undefined, + senderTransferStatusBeforeReceiverRefresh: undefined as + | string + | undefined, + receiverSettledWhileOffline: undefined as number | undefined, + receiverSettledAfter: undefined as number | undefined, + currentTransferStatus: undefined as string | undefined, + currentTransferTxid: undefined as string | null | undefined, + txidMatch: undefined as boolean | undefined, + warning: undefined as string | undefined, + pollAckMs: undefined as number | undefined, + pollSettledMs: undefined as number | undefined, + pollTransferMs: undefined as number | undefined, + }, + phase2: { + refreshChecks: [] as Array<{ cycle: number; settled: number }>, + receiverSettledFinal: undefined as number | undefined, + listTransfersSnapshot: undefined as unknown, + }, + note: undefined as string | undefined, + }; +} + +export async function finalizeTransferSnapshot( + receiver: UTEXOWalletType, + assetId: string, + recipientId: string, + sendTxid: string, + report: ReturnType +): Promise { + try { + report.phase2.listTransfersSnapshot = await receiver.listTransfers(assetId); + + const currentTransferFromSnapshot = Array.isArray( + report.phase2.listTransfersSnapshot + ) + ? report.phase2.listTransfersSnapshot.find( + (item) => item.recipientId === recipientId + ) + : undefined; + + if (currentTransferFromSnapshot) { + report.phase1.currentTransferStatus = currentTransferFromSnapshot.status; + report.phase1.currentTransferTxid = currentTransferFromSnapshot.txid; + report.phase1.txidMatch = Boolean( + currentTransferFromSnapshot.txid && + currentTransferFromSnapshot.txid === sendTxid + ); + + if (currentTransferFromSnapshot.status === 'Settled') { + report.phase1.warning = undefined; + } + } + } catch (error) { + report.phase2.listTransfersSnapshot = { + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function runSignetReceiveSmoke(params: { + state: PreflightState; + reportFileName: string; + receiveInvoice: (receiver: UTEXOWalletType) => Promise; + buildSendParams: (args: { invoice: string; assetId: string }) => SendParams; + strictMode?: { + exactDelta?: boolean; + strictTransferCheck?: boolean; + senderSettlesBeforeReceiverRefresh?: boolean; + }; + phase1Metadata?: { + invoiceType?: 'witness' | 'blind'; + witnessAmountSat?: number; + }; +}): Promise { + const { + state, + reportFileName, + receiveInvoice, + buildSendParams, + strictMode, + phase1Metadata, + } = params; + + if (state.skipReason) { + maybeSkipOrFail(state.skipReason); + return; + } + + const sender = state.sender!; + const receiver = state.receiver!; + const assetId = state.assetId; + const receiverSettledBefore = Number( + state.receiverBalanceBefore?.settled ?? 0 + ); + const startedAt = Date.now(); + const report = createBaseReport(state); + if (phase1Metadata) { + Object.assign(report.phase1, phase1Metadata); + } + + try { + const invoiceData = await receiveInvoice(receiver); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + let sendResult; + try { + sendResult = await sender.send( + buildSendParams({ invoice: invoiceData.invoice, assetId }) + ); + } catch (error) { + if (isInsufficientAllocationSlotsError(error)) { + throw new Error( + 'Insufficient allocation slots during send. Top up both wallets and refresh before rerunning: `node cli/run.mjs createutxos stage2-sender --num 10 --size 2000 --feeRate 2 && node cli/run.mjs refresh stage2-sender && node cli/run.mjs createutxos stage2-receiver --num 10 --size 2000 --feeRate 2 && node cli/run.mjs refresh stage2-receiver`.' + ); + } + throw error; + } + report.phase1.txid = sendResult.txid; + + const ackStartedAt = Date.now(); + const ack = await pollAck(PROXY_HTTP_URL, invoiceData.recipientId); + report.phase1.pollAckMs = Date.now() - ackStartedAt; + + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId + ); + report.phase1.ack = ack; + report.phase1.validated = validated; + + expect(ack).toBe(true); + expect(validated).toBe(true); + + if (strictMode?.senderSettlesBeforeReceiverRefresh) { + const senderTransfer = await pollCondition( + async () => { + await sender.refreshWallet(); + const transfers = await sender.listTransfers(assetId); + return transfers.find((item) => item.txid === sendResult.txid); + }, + (transfer) => transfer?.status === 'Settled', + 120_000, + 5_000, + `Sender transfer txid=${sendResult.txid} did not reach Settled before receiver refresh` + ); + report.phase1.senderTransferStatusBeforeReceiverRefresh = + senderTransfer?.status; + expect(senderTransfer?.status).toBe('Settled'); + } + + const offlineBalance = await receiver + .getAssetBalance(assetId) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('AssetNotFound')) { + return { settled: receiverSettledBefore }; + } + throw error; + }); + const receiverSettledWhileOffline = Number(offlineBalance.settled ?? 0); + report.phase1.receiverSettledWhileOffline = receiverSettledWhileOffline; + expect(receiverSettledWhileOffline).toBe(receiverSettledBefore); + + const settledStartedAt = Date.now(); + const receiverSettledAfter = await pollSettledBalanceDelta( + async () => { + await receiver.refreshWallet(); + return receiver.getAssetBalance(assetId); + }, + receiverSettledBefore, + TRANSFER_AMOUNT + ); + report.phase1.pollSettledMs = Date.now() - settledStartedAt; + report.phase1.receiverSettledAfter = receiverSettledAfter; + + if (strictMode?.exactDelta) { + expect(receiverSettledAfter - receiverSettledBefore).toBe( + TRANSFER_AMOUNT + ); + } else { + expect( + receiverSettledAfter - receiverSettledBefore + ).toBeGreaterThanOrEqual(TRANSFER_AMOUNT); + } + + const transferStartedAt = Date.now(); + try { + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers( + strictMode?.strictTransferCheck ? assetId : undefined + ); + }, + invoiceData.recipientId, + sendResult.txid, + 30_000, + 5_000 + ); + report.phase1.pollTransferMs = Date.now() - transferStartedAt; + report.phase1.currentTransferStatus = currentTransfer.status; + report.phase1.currentTransferTxid = currentTransfer.txid; + report.phase1.txidMatch = Boolean( + currentTransfer.txid && currentTransfer.txid === sendResult.txid + ); + + if (strictMode?.strictTransferCheck) { + expect(currentTransfer.status).toBe('Settled'); + expect(currentTransfer.txid).toBe(sendResult.txid); + } else if ( + currentTransfer.txid && + currentTransfer.txid !== sendResult.txid + ) { + report.phase1.warning = `Balance delta was observed, but current transfer txid mismatched: expected ${sendResult.txid}, got ${currentTransfer.txid}`; + } + } catch (error) { + report.phase1.pollTransferMs = Date.now() - transferStartedAt; + if (strictMode?.strictTransferCheck) { + throw error; + } + report.phase1.warning = + error instanceof Error ? error.message : String(error); + } + + for (let cycle = 1; cycle <= IDEMPOTENT_REFRESH_COUNT; cycle += 1) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(assetId); + report.phase2.refreshChecks.push({ + cycle, + settled: Number(balance.settled), + }); + } + + const allSettled = report.phase2.refreshChecks.map((item) => item.settled); + expect(new Set(allSettled).size).toBe(1); + + const receiverSettledFinal = + report.phase2.refreshChecks.at(-1)?.settled ?? receiverSettledAfter; + report.phase2.receiverSettledFinal = receiverSettledFinal; + expect(receiverSettledFinal).toBe(receiverSettledAfter); + + await finalizeTransferSnapshot( + receiver, + assetId, + invoiceData.recipientId, + sendResult.txid, + report + ); + } catch (error) { + report.note = error instanceof Error ? error.message : String(error); + throw error; + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport(report, reportFileName); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } +} diff --git a/tests/signet/offline-receiver-refresh-convergence.test.ts b/tests/signet/offline-receiver-refresh-convergence.test.ts new file mode 100644 index 0000000..63f732b --- /dev/null +++ b/tests/signet/offline-receiver-refresh-convergence.test.ts @@ -0,0 +1,193 @@ +import { + createBaseReport, + createPreflightState, + disposeSignetPreflight, + finalizeTransferSnapshot, + maybeSkipOrFail, + PROXY_HTTP_URL, + setupSignetPreflight, + SMOKE_FEE_RATE, + TRANSFER_AMOUNT, + type TransferRecord, +} from './fixture'; +import { + pollAck, + pollTransferByRecipientId, + pollValidated, + writeSmokeReport, +} from '../shared/helpers'; + +type ConvergenceReport = ReturnType & { + phase1: ReturnType['phase1'] & { + invoiceType?: 'witness'; + witnessAmountSat?: number; + preRefreshTransferStatus?: string; + }; + phase2: ReturnType['phase2'] & { + delayedRefreshChecks: Array<{ + cycle: number; + settled: number; + currentTransferStatus?: string; + }>; + }; +}; + +const state = createPreflightState(); + +beforeAll(async () => { + await setupSignetPreflight(state); +}); + +afterAll(async () => { + await disposeSignetPreflight(state); +}); + +describe('Signet offline receiver refresh convergence', () => { + it('receiver catches up to Settled after delayed refreshes', async () => { + if (state.skipReason) { + maybeSkipOrFail(state.skipReason); + return; + } + + const sender = state.sender!; + const receiver = state.receiver!; + const assetId = state.assetId; + const receiverSettledBefore = Number( + state.receiverBalanceBefore?.settled ?? 0 + ); + const startedAt = Date.now(); + const report = createBaseReport(state) as ConvergenceReport; + report.phase2.delayedRefreshChecks = []; + + try { + const invoiceData = await receiver.witnessReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + report.phase1.invoiceType = 'witness'; + report.phase1.witnessAmountSat = 1000; + + let sendResult; + try { + sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SMOKE_FEE_RATE, + minConfirmations: 1, + witnessData: { amountSat: 1000 }, + }); + } catch (error) { + const serialized = [ + String(error), + error instanceof Error ? error.message : '', + error instanceof Error && error.stack ? error.stack : '', + ].join(' '); + if ( + serialized.includes('InsufficientAllocationSlots') || + serialized.includes('AllocationSlots') + ) { + throw new Error( + 'Receiver has no free allocation slots for witness receive. Run `node cli/run.mjs createutxos stage2-receiver --num 5 --size 2000 --feeRate 2` and then `node cli/run.mjs refresh stage2-receiver` before rerunning this convergence test.' + ); + } + throw error; + } + report.phase1.txid = sendResult.txid; + + const ackStartedAt = Date.now(); + const ack = await pollAck(PROXY_HTTP_URL, invoiceData.recipientId); + report.phase1.pollAckMs = Date.now() - ackStartedAt; + + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId + ); + report.phase1.ack = ack; + report.phase1.validated = validated; + + expect(ack).toBe(true); + expect(validated).toBe(true); + + try { + const preRefreshTransfers = await receiver.listTransfers(assetId); + const preRefreshTransfer = preRefreshTransfers.find( + (item) => item.recipientId === invoiceData.recipientId + ); + report.phase1.preRefreshTransferStatus = preRefreshTransfer?.status; + } catch (error) { + report.phase1.preRefreshTransferStatus = `error: ${error instanceof Error ? error.message : String(error)}`; + } + + for (let cycle = 1; cycle <= 2; cycle += 1) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(assetId); + let currentTransferStatus: string | undefined; + try { + const transfers = await receiver.listTransfers(assetId); + currentTransferStatus = transfers.find( + (item) => item.recipientId === invoiceData.recipientId + )?.status; + } catch { + currentTransferStatus = undefined; + } + report.phase2.delayedRefreshChecks.push({ + cycle, + settled: Number(balance.settled ?? 0), + currentTransferStatus, + }); + } + + const transferStartedAt = Date.now(); + const currentTransfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(assetId); + }, + invoiceData.recipientId, + sendResult.txid, + 60_000, + 5_000 + ); + report.phase1.pollTransferMs = Date.now() - transferStartedAt; + report.phase1.currentTransferStatus = currentTransfer.status; + report.phase1.currentTransferTxid = currentTransfer.txid; + report.phase1.txidMatch = Boolean( + currentTransfer.txid && currentTransfer.txid === sendResult.txid + ); + + const settledStartedAt = Date.now(); + const finalBalance = await receiver.getAssetBalance(assetId); + const receiverSettledAfter = Number(finalBalance.settled ?? 0); + report.phase1.pollSettledMs = Date.now() - settledStartedAt; + report.phase1.receiverSettledAfter = receiverSettledAfter; + + expect(currentTransfer.status).toBe('Settled'); + expect( + receiverSettledAfter - receiverSettledBefore + ).toBeGreaterThanOrEqual(TRANSFER_AMOUNT); + + await finalizeTransferSnapshot( + receiver, + assetId, + invoiceData.recipientId, + sendResult.txid, + report + ); + } catch (error) { + report.note = error instanceof Error ? error.message : String(error); + throw error; + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'signet-offline-receiver-refresh-convergence.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/signet/offline-receiver-smoke.test.ts b/tests/signet/offline-receiver-smoke.test.ts new file mode 100644 index 0000000..2716735 --- /dev/null +++ b/tests/signet/offline-receiver-smoke.test.ts @@ -0,0 +1,57 @@ +import { + createPreflightState, + disposeSignetPreflight, + setupSignetPreflight, + runSignetReceiveSmoke, + TRANSFER_AMOUNT, + SMOKE_FEE_RATE, +} from './fixture'; + +const state = createPreflightState(); + +beforeAll(async () => { + await setupSignetPreflight(state); +}); + +afterAll(async () => { + await disposeSignetPreflight(state); +}); + +describe('Signet offline receiver smoke', () => { + it('auto-ACKs offline receive, settles, and refresh is idempotent', async () => { + await runSignetReceiveSmoke({ + state, + reportFileName: 'signet-offline-receiver-smoke.json', + receiveInvoice: async (receiver) => { + try { + return await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + if (message.includes('InsufficientAllocationSlots')) { + throw new Error( + 'Receiver has no free allocation slots for blindReceive. Run `node cli/run.mjs createutxos stage2-receiver --num 5 --size 2000 --feeRate 2` and then `node cli/run.mjs refresh stage2-receiver` before rerunning this smoke.' + ); + } + throw error; + } + }, + buildSendParams: ({ invoice, assetId }) => ({ + invoice, + assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SMOKE_FEE_RATE, + minConfirmations: 1, + }), + strictMode: { + exactDelta: true, + strictTransferCheck: true, + senderSettlesBeforeReceiverRefresh: true, + }, + }); + }); +}); diff --git a/tests/signet/offline-receiver-two-refresh-convergence.test.ts b/tests/signet/offline-receiver-two-refresh-convergence.test.ts new file mode 100644 index 0000000..9eef96b --- /dev/null +++ b/tests/signet/offline-receiver-two-refresh-convergence.test.ts @@ -0,0 +1,193 @@ +import { + createBaseReport, + createPreflightState, + disposeSignetPreflight, + finalizeTransferSnapshot, + maybeSkipOrFail, + PROXY_HTTP_URL, + setupSignetPreflight, + SMOKE_FEE_RATE, + TRANSFER_AMOUNT, +} from './fixture'; +import { + pollAck, + pollCondition, + pollValidated, + writeSmokeReport, +} from '../shared/helpers'; + +type TwoRefreshReport = ReturnType & { + phase1: ReturnType['phase1'] & { + senderTransferStatusBeforeReceiverRefresh?: string; + receiverTransferStatusBeforeRefresh?: string; + receiverSettledWhileOffline?: number; + }; + phase2: ReturnType['phase2'] & { + delayedRefreshChecks: Array<{ + cycle: number; + settled: number; + currentTransferStatus?: string; + currentTransferTxid?: string | null; + }>; + }; +}; + +const state = createPreflightState(); + +beforeAll(async () => { + await setupSignetPreflight(state); +}); + +afterAll(async () => { + await disposeSignetPreflight(state); +}); + +describe('Signet offline receiver two-refresh convergence', () => { + it('keeps receiver offline until sender settles, then reaches WaitingConfirmations and Settled in two refreshes', async () => { + if (state.skipReason) { + maybeSkipOrFail(state.skipReason); + return; + } + + const sender = state.sender!; + const receiver = state.receiver!; + const assetId = state.assetId; + const receiverSettledBefore = Number( + state.receiverBalanceBefore?.settled ?? 0 + ); + const startedAt = Date.now(); + const report = createBaseReport(state) as TwoRefreshReport; + report.phase2.delayedRefreshChecks = []; + + try { + const invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + report.phase1.invoice = invoiceData.invoice; + report.phase1.recipientId = invoiceData.recipientId; + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SMOKE_FEE_RATE, + minConfirmations: 1, + }); + report.phase1.txid = sendResult.txid; + + const ackStartedAt = Date.now(); + const ack = await pollAck(PROXY_HTTP_URL, invoiceData.recipientId); + report.phase1.pollAckMs = Date.now() - ackStartedAt; + + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId + ); + report.phase1.ack = ack; + report.phase1.validated = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + + const senderTransfer = await pollCondition( + async () => { + await sender.refreshWallet(); + const transfers = await sender.listTransfers(assetId); + return transfers.find((item) => item.txid === sendResult.txid); + }, + (transfer) => transfer?.status === 'Settled', + 180_000, + 5_000, + `Sender transfer txid=${sendResult.txid} did not reach Settled before receiver refresh` + ); + report.phase1.senderTransferStatusBeforeReceiverRefresh = + senderTransfer?.status; + expect(senderTransfer?.status).toBe('Settled'); + + const receiverBalanceWhileOffline = await receiver + .getAssetBalance(assetId) + .catch((error) => { + const message = + error instanceof Error ? error.message : String(error); + if (message.includes('AssetNotFound')) { + return { settled: receiverSettledBefore }; + } + throw error; + }); + report.phase1.receiverSettledWhileOffline = Number( + receiverBalanceWhileOffline.settled ?? 0 + ); + expect(report.phase1.receiverSettledWhileOffline).toBe( + receiverSettledBefore + ); + + try { + const preRefreshTransfers = await receiver.listTransfers(assetId); + const preRefreshTransfer = preRefreshTransfers.find( + (item) => item.recipientId === invoiceData.recipientId + ); + report.phase1.receiverTransferStatusBeforeRefresh = + preRefreshTransfer?.status; + } catch (error) { + report.phase1.receiverTransferStatusBeforeRefresh = `error: ${error instanceof Error ? error.message : String(error)}`; + } + + for (let cycle = 1; cycle <= 2; cycle += 1) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(assetId); + const transfers = await receiver.listTransfers(assetId); + const currentTransfer = transfers.find( + (item) => item.recipientId === invoiceData.recipientId + ); + + report.phase2.delayedRefreshChecks.push({ + cycle, + settled: Number(balance.settled ?? 0), + currentTransferStatus: currentTransfer?.status, + currentTransferTxid: currentTransfer?.txid, + }); + } + + const firstRefresh = report.phase2.delayedRefreshChecks[0]; + const secondRefresh = report.phase2.delayedRefreshChecks[1]; + + expect(firstRefresh?.currentTransferStatus).toBe('WaitingConfirmations'); + expect(firstRefresh?.settled).toBe(receiverSettledBefore); + expect(secondRefresh?.currentTransferStatus).toBe('Settled'); + expect(secondRefresh?.currentTransferTxid).toBe(sendResult.txid); + expect(secondRefresh?.settled).toBe( + receiverSettledBefore + TRANSFER_AMOUNT + ); + + report.phase1.currentTransferStatus = + secondRefresh?.currentTransferStatus; + report.phase1.currentTransferTxid = secondRefresh?.currentTransferTxid; + report.phase1.txidMatch = Boolean( + secondRefresh?.currentTransferTxid && + secondRefresh.currentTransferTxid === sendResult.txid + ); + report.phase1.receiverSettledAfter = secondRefresh?.settled; + report.phase1.pollSettledMs = Date.now() - ackStartedAt; + + await finalizeTransferSnapshot( + receiver, + assetId, + invoiceData.recipientId, + sendResult.txid, + report + ); + } catch (error) { + report.note = error instanceof Error ? error.message : String(error); + throw error; + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'signet-offline-receiver-two-refresh-convergence.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/signet/sequential-receives-smoke.test.ts b/tests/signet/sequential-receives-smoke.test.ts new file mode 100644 index 0000000..61cf7dd --- /dev/null +++ b/tests/signet/sequential-receives-smoke.test.ts @@ -0,0 +1,239 @@ +import { + createPreflightState, + disposeSignetPreflight, + maybeSkipOrFail, + PROXY_HTTP_URL, + setupSignetPreflight, + SMOKE_FEE_RATE, + TRANSFER_AMOUNT, +} from './fixture'; +import { + pollAck, + pollCondition, + pollTransferByRecipientId, + pollValidated, + writeSmokeReport, +} from '../shared/helpers'; + +const SEQUENTIAL_ITERATIONS = 2; +const ACK_TIMEOUT_MS = 60_000; +const ACK_INTERVAL_MS = 2_000; +const TRANSFER_TIMEOUT_MS = 120_000; +const TRANSFER_INTERVAL_MS = 5_000; +const IDEMPOTENT_REFRESH_COUNT = 2; + +type SequentialCycle = { + cycle: number; + recipientId: string; + invoice: string; + txid?: string; + ack?: boolean; + validated?: boolean; + status?: string; + receiverSettledAfterCycle?: number; + senderSpendableAfterCycle?: number; +}; + +type SequentialReport = { + timestamp: string; + durationMs: number; + preconditions: { + assetId: string; + senderAddress: string; + receiverAddress: string; + senderSpendableBefore: number; + receiverSettledBefore: number; + iterations: number; + }; + cycles: SequentialCycle[]; + phase2: { + totalDelta?: number; + postRefreshSettled?: number[]; + }; + note?: string; +}; + +const state = createPreflightState(); + +beforeAll(async () => { + await setupSignetPreflight(state); +}); + +afterAll(async () => { + await disposeSignetPreflight(state); +}); + +describe('Signet sequential receives smoke', () => { + it('sequential blind receives on same wallet state converge without slot leakage', async () => { + if (state.skipReason) { + maybeSkipOrFail(state.skipReason); + return; + } + + const sender = state.sender!; + const receiver = state.receiver!; + const assetId = state.assetId; + const receiverSettledBefore = Number( + state.receiverBalanceBefore?.settled ?? 0 + ); + const startedAt = Date.now(); + const report: SequentialReport = { + timestamp: new Date().toISOString(), + durationMs: 0, + preconditions: { + assetId, + senderAddress: state.senderAddress, + receiverAddress: state.receiverAddress, + senderSpendableBefore: Number( + state.senderBalanceBefore?.spendable ?? 0 + ), + receiverSettledBefore, + iterations: SEQUENTIAL_ITERATIONS, + }, + cycles: [], + phase2: {}, + }; + + try { + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(assetId); + }, + (balance) => + Number(balance.spendable ?? 0) >= + SEQUENTIAL_ITERATIONS * TRANSFER_AMOUNT, + 180_000, + 5_000, + `Sender asset did not reach spendable >= ${SEQUENTIAL_ITERATIONS * TRANSFER_AMOUNT} before sequential test` + ); + + let expectedSettledLowerBound = receiverSettledBefore; + for (let cycle = 1; cycle <= SEQUENTIAL_ITERATIONS; cycle += 1) { + await pollCondition( + async () => { + await sender.refreshWallet(); + return sender.getAssetBalance(assetId); + }, + (balance) => Number(balance.spendable ?? 0) >= TRANSFER_AMOUNT, + 120_000, + 5_000, + `Sender spendable did not recover before cycle=${cycle}` + ); + + let invoiceData; + try { + invoiceData = await receiver.blindReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + if (message.includes('InsufficientAllocationSlots')) { + throw new Error( + `Receiver has no free allocation slots for cycle=${cycle}. Run \`node cli/run.mjs createutxos stage2-receiver --num 10 --size 2000 --feeRate 2\` and then \`node cli/run.mjs refresh stage2-receiver\` before rerunning this sequential smoke.` + ); + } + throw error; + } + + const cycleReport: SequentialCycle = { + cycle, + recipientId: invoiceData.recipientId, + invoice: invoiceData.invoice, + }; + report.cycles.push(cycleReport); + + const sendResult = await sender.send({ + invoice: invoiceData.invoice, + assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SMOKE_FEE_RATE, + minConfirmations: 1, + }); + cycleReport.txid = sendResult.txid; + + const ack = await pollAck( + PROXY_HTTP_URL, + invoiceData.recipientId, + ACK_TIMEOUT_MS, + ACK_INTERVAL_MS + ); + const validated = await pollValidated( + PROXY_HTTP_URL, + invoiceData.recipientId, + ACK_TIMEOUT_MS, + ACK_INTERVAL_MS + ); + cycleReport.ack = ack; + cycleReport.validated = validated; + expect(ack).toBe(true); + expect(validated).toBe(true); + + const transfer = await pollTransferByRecipientId( + async () => { + await receiver.refreshWallet(); + return receiver.listTransfers(assetId); + }, + invoiceData.recipientId, + sendResult.txid, + TRANSFER_TIMEOUT_MS, + TRANSFER_INTERVAL_MS + ); + cycleReport.status = transfer.status; + expect(transfer.status).toBe('Settled'); + + const receiverBalanceAfterCycle = + await receiver.getAssetBalance(assetId); + const receiverSettledAfterCycle = Number( + receiverBalanceAfterCycle.settled ?? 0 + ); + cycleReport.receiverSettledAfterCycle = receiverSettledAfterCycle; + expectedSettledLowerBound += TRANSFER_AMOUNT; + expect(receiverSettledAfterCycle).toBeGreaterThanOrEqual( + expectedSettledLowerBound + ); + + await sender.refreshWallet(); + const senderBalanceAfterCycle = await sender.getAssetBalance(assetId); + cycleReport.senderSpendableAfterCycle = Number( + senderBalanceAfterCycle.spendable ?? 0 + ); + } + + const finalReceiverBalance = await receiver.getAssetBalance(assetId); + const finalSettled = Number(finalReceiverBalance.settled ?? 0); + const totalDelta = finalSettled - receiverSettledBefore; + report.phase2.totalDelta = totalDelta; + expect(totalDelta).toBeGreaterThanOrEqual( + SEQUENTIAL_ITERATIONS * TRANSFER_AMOUNT + ); + + const postRefreshSettled: number[] = []; + for ( + let refreshCycle = 1; + refreshCycle <= IDEMPOTENT_REFRESH_COUNT; + refreshCycle += 1 + ) { + await receiver.refreshWallet(); + const balance = await receiver.getAssetBalance(assetId); + postRefreshSettled.push(Number(balance.settled ?? 0)); + } + report.phase2.postRefreshSettled = postRefreshSettled; + expect(new Set(postRefreshSettled).size).toBe(1); + } catch (error) { + report.note = error instanceof Error ? error.message : String(error); + throw error; + } finally { + report.durationMs = Date.now() - startedAt; + const reportPath = writeSmokeReport( + report, + 'signet-sequential-receives.json' + ); + console.log(`smoke report: ${reportPath}`); + console.log(JSON.stringify(report, null, 2)); + } + }); +}); diff --git a/tests/signet/witness-receiver-smoke.test.ts b/tests/signet/witness-receiver-smoke.test.ts new file mode 100644 index 0000000..5b44935 --- /dev/null +++ b/tests/signet/witness-receiver-smoke.test.ts @@ -0,0 +1,61 @@ +import { + createPreflightState, + disposeSignetPreflight, + setupSignetPreflight, + runSignetReceiveSmoke, + TRANSFER_AMOUNT, + SMOKE_FEE_RATE, +} from './fixture'; + +const state = createPreflightState(); + +beforeAll(async () => { + await setupSignetPreflight(state); +}); + +afterAll(async () => { + await disposeSignetPreflight(state); +}); + +describe('Signet witness receiver smoke', () => { + it('auto-ACKs witness receive, settles, and refresh is idempotent', async () => { + await runSignetReceiveSmoke({ + state, + reportFileName: 'signet-witness-receiver-smoke.json', + phase1Metadata: { + invoiceType: 'witness', + witnessAmountSat: 1000, + }, + receiveInvoice: async (receiver) => { + try { + return await receiver.witnessReceive({ + amount: TRANSFER_AMOUNT, + minConfirmations: 1, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + if (message.includes('InsufficientAllocationSlots')) { + throw new Error( + 'Receiver has no free allocation slots for witnessReceive. Run `node cli/run.mjs createutxos stage2-receiver --num 5 --size 2000 --feeRate 2` and then `node cli/run.mjs refresh stage2-receiver` before rerunning this smoke.' + ); + } + throw error; + } + }, + buildSendParams: ({ invoice, assetId }) => ({ + invoice, + assetId, + amount: TRANSFER_AMOUNT, + donation: true, + feeRate: SMOKE_FEE_RATE, + minConfirmations: 1, + witnessData: { amountSat: 1000 }, + }), + strictMode: { + exactDelta: true, + strictTransferCheck: true, + }, + }); + }); +}); diff --git a/tests/utexo-mocked.test.ts b/tests/utexo-mocked.test.ts index aa7d661..2d159c0 100644 --- a/tests/utexo-mocked.test.ts +++ b/tests/utexo-mocked.test.ts @@ -7,11 +7,11 @@ import path from 'path'; import fs from 'fs'; import os from 'os'; -await jest.unstable_mockModule('../src/client/rgb-lib-client', () => ({ +await jest.unstable_mockModule('../src/binding/NodeRgbLibBinding', () => ({ restoreWallet: () => undefined, restoreFromVss: () => {}, generateKeys: () => {}, - RGBLibClient: () => {}, + NodeRgbLibBinding: () => {}, })); const { @@ -71,7 +71,7 @@ describe('restoreUtxoWalletFromBackup with mocked restoreWallet', () => { networkPreset: 'testnet', }); - // testnet preset: layer1=testnet, utexo=signet + // testnet preset: layer1=testnet, utexo=utexo expect(result.layer1Path).toContain(fp); expect(result.utexoPath).toContain(fp); expect(result.layer1Path).not.toBe(result.utexoPath); diff --git a/tsconfig.json b/tsconfig.json index c466b3b..b522173 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "outDir": "dist", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "isolatedModules": true }, "include": ["src"] } \ No newline at end of file