diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 692cb19460..93fffa30f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,10 +205,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Setup node 22 + - name: Setup node from .nvmrc uses: actions/setup-node@v6 with: - node-version: 22 + node-version-file: .nvmrc - name: restore lerna dependencies id: lerna-cache @@ -217,7 +217,7 @@ jobs: path: | node_modules modules/*/node_modules - key: ${{ runner.os }}-node22-${{ hashFiles('yarn.lock') }}-${{ hashFiles('tsconfig.packages.json') }}-${{ hashFiles('**/package.json') }} + key: ${{ runner.os }}-node${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }}-${{ hashFiles('tsconfig.packages.json') }}-${{ hashFiles('**/package.json') }} - name: Install Packages if: steps.lerna-cache.outputs.cache-hit != 'true' || contains( github.event.pull_request.labels.*.name, 'SKIP_CACHE') @@ -262,10 +262,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Setup node 22 + - name: Setup node from .nvmrc uses: actions/setup-node@v6 with: - node-version: 22 # this just needs to pass our lock file requirement for compilation + node-version-file: .nvmrc - name: Build Info run: | @@ -458,10 +458,10 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Setup node 22 + - name: Setup node from .nvmrc uses: actions/setup-node@v6 with: - node-version: 22 + node-version-file: .nvmrc - name: restore lerna dependencies id: lerna-cache @@ -470,7 +470,7 @@ jobs: path: | node_modules modules/*/node_modules - key: ${{ runner.os }}-node22-${{ hashFiles('yarn.lock') }}-${{ hashFiles('tsconfig.packages.json')}}-${{ hashFiles('**/package.json') }} + key: ${{ runner.os }}-node${{ hashFiles('.nvmrc') }}-${{ hashFiles('yarn.lock') }}-${{ hashFiles('tsconfig.packages.json')}}-${{ hashFiles('**/package.json') }} - name: Install Packages if: steps.lerna-cache.outputs.cache-hit != 'true' || contains( github.event.pull_request.labels.*.name, 'SKIP_CACHE') diff --git a/.github/workflows/npmjs-release.yml b/.github/workflows/npmjs-release.yml index d95b5fc04e..0e27423d3c 100644 --- a/.github/workflows/npmjs-release.yml +++ b/.github/workflows/npmjs-release.yml @@ -308,12 +308,13 @@ jobs: NPM_CONFIG_PROVENANCE: true # WCN-2091: fail the release BEFORE bitgo publishes if the shrinkwrap it - # would ship pins any transitive that violates our declared engines (Node - # >=20). Runs after pass 1 because the shrinkwrap generator resolves - # newly-published siblings from the registry. Pack + install here, not - # --package-lock-only, so `engine-strict=true` actually validates every - # frozen entry's engines. If this fails, siblings are already on npm but - # bitgo isn't — fix the shrinkwrap issue and re-run in recovery-mode. + # would ship pins any transitive that is incompatible with the Node.js + # version pinned in .nvmrc. Runs after pass 1 because the shrinkwrap + # generator resolves newly-published siblings from the registry. Pack + + # install here, not --package-lock-only, so `engine-strict=true` actually + # validates every frozen entry's engines. If this fails, siblings are + # already on npm but bitgo isn't — fix the shrinkwrap issue and re-run in + # recovery-mode. - name: Pre-publish shrinkwrap check — pack bitgo tarball if: inputs.dry-run == false env: @@ -325,13 +326,13 @@ jobs: echo "PREPUB_TARBALL=$tarball" >> "$GITHUB_ENV" echo "Packed: $tarball" - - name: Pre-publish shrinkwrap check — setup Node 20 + - name: Pre-publish shrinkwrap check — setup Node.js from .nvmrc if: inputs.dry-run == false uses: actions/setup-node@v6 with: - node-version: '20.x' + node-version-file: '.nvmrc' - - name: Pre-publish shrinkwrap check — install tarball on Node 20 with engine-strict + - name: Pre-publish shrinkwrap check — install tarball on repository Node.js with engine-strict if: inputs.dry-run == false run: | workdir="$(mktemp -d)" @@ -340,18 +341,12 @@ jobs: npm init -y >/dev/null echo "Verifying $PREPUB_TARBALL installs on $(node --version) with engine-strict=true" if ! npm install "$PREPUB_TARBALL" --no-audit --no-fund --ignore-scripts 2>install.log; then - echo "::error::Pre-publish shrinkwrap check FAILED — bitgo tarball cannot be installed on Node 20 with engine-strict. Fix before publishing." + echo "::error::Pre-publish shrinkwrap check FAILED — bitgo tarball cannot be installed on the repository Node.js version with engine-strict. Fix before publishing." cat install.log exit 1 fi echo "✅ bitgo tarball installs cleanly on $(node --version) with engine-strict." - - name: Pre-publish shrinkwrap check — restore release Node version - if: inputs.dry-run == false - uses: actions/setup-node@v6 - with: - node-version-file: ".nvmrc" - - name: Publish bitgo (pass 2) if: inputs.dry-run == false run: | diff --git a/examples/ts/defi-vault-wrap.ts b/examples/ts/defi-vault-wrap.ts new file mode 100644 index 0000000000..b926794a60 --- /dev/null +++ b/examples/ts/defi-vault-wrap.ts @@ -0,0 +1,73 @@ +/** + * Wrap native ETH into WETH (and unwrap it back) on staging. + * + * Wrap issues a single WETH9 `deposit()` call; unwrap issues `withdraw(uint256)`. + * The wallet-platform builds the calldata and resolves the WETH9 address from the + * vault binding — the SDK only forwards vaultId and amount. + * + * Set DEFI_WRAP_DIRECTION=unwrap to run the reverse direction. + * + * Wrap does not need to be awaited before depositing: the client is free to call + * depositToVault() without waiting for the wrap to confirm. + * + * Usage: + * STAGING_ACCESS_TOKEN= \ + * STAGING_WALLET_ID= \ + * STAGING_WALLET_PASSPHRASE= \ + * DEFI_VAULT_ID= \ + * DEFI_WRAP_AMOUNT= \ + * DEFI_WRAP_DIRECTION= \ + * npx ts-node examples/ts/defi-vault-wrap.ts + * + * Copyright 2026, BitGo, Inc. All Rights Reserved. + */ +import { BitGo } from 'bitgo'; + +require('dotenv').config({ path: '../../.env' }); + +const config = { + accessToken: '', + env: 'staging', + walletId: '', + vaultId: 'tbaseeth-weth-test', + amount: '1000000000000000000', // 1 ETH — 18dp base units, kept as a string + direction: 'wrap' as 'wrap' | 'unwrap', + passphrase: '', + coin: 'tbaseeth', + otp: '000000', +}; + +const bitgoTest = new BitGo({ + env: 'staging', +}); + +async function main() { + console.log('Connecting to staging...'); + bitgoTest.authenticateWithAccessToken({ accessToken: config.accessToken }); + //await bitgoTest.unlock({ otp: config.otp, duration: 3600 }); + const wallet = await bitgoTest.coin(config.coin).wallets().get({ id: config.walletId }); + console.log('Wallet ID :', wallet.id()); + console.log('Vault ID :', config.vaultId); + console.log('Direction :', config.direction); + console.log('Amount :', config.amount, config.direction === 'wrap' ? '(ETH base units)' : '(WETH base units)'); + + const params = { + vaultId: config.vaultId, + amount: config.amount, + ...(config.passphrase ? { walletPassphrase: config.passphrase } : {}), + }; + + console.log(`\nStarting ${config.direction}...`); + const result = config.direction === 'wrap' ? await wallet.defi.wrap(params) : await wallet.defi.unwrap(params); + + console.log(`\n${config.direction} submitted:`); + console.log(' txRequestId :', result.txRequestId); + // operationId is reserved for milestone M5 and is undefined today. + console.log('\nFull result:', JSON.stringify(result, null, 2)); +} + +main().catch((e) => { + console.error('Error:', e.message); + if (e.stack) console.error(e.stack); + process.exit(1); +}); diff --git a/modules/abstract-utxo/package.json b/modules/abstract-utxo/package.json index 94fbd044a6..9bffc228e5 100644 --- a/modules/abstract-utxo/package.json +++ b/modules/abstract-utxo/package.json @@ -66,7 +66,7 @@ "@bitgo/utxo-core": "^1.41.3", "@bitgo/utxo-descriptors": "^1.5.3", "@bitgo/utxo-ord": "^1.34.3", - "@bitgo/wasm-utxo": "^5.0.0", + "@bitgo/wasm-utxo": "^5.1.0", "@types/lodash": "^4.14.121", "@types/superagent": "4.1.15", "bignumber.js": "^9.0.2", diff --git a/modules/abstract-utxo/src/impl/zec/address.ts b/modules/abstract-utxo/src/impl/zec/address.ts new file mode 100644 index 0000000000..a025b7b13e --- /dev/null +++ b/modules/abstract-utxo/src/impl/zec/address.ts @@ -0,0 +1,55 @@ +import { address as wasmAddress, fixedScriptWallet, isCoinName } from '@bitgo/wasm-utxo'; + +export type ZcashAddressKind = 'transparent' | 'shielded'; + +/** + * Whether `address` is a well-formed ZIP-316 Unified Address for `network` + * with an Orchard receiver. BitGo only supports Orchard, so a UA without one + * (e.g. Sapling- or transparent-only) is not considered valid here. + */ +export function isShieldedZcashAddress(address: string, network: fixedScriptWallet.ZcashNetworkName): boolean { + try { + return fixedScriptWallet.ZcashUnifiedAddress.parse(address, network).hasOrchardReceiver; + } catch { + return false; + } +} + +/** + * Classify a Zcash address string as transparent or shielded, validating it in + * the process. Returns undefined if the address is neither a valid transparent + * address nor a well-formed ZIP-316 Unified Address for `network`. + */ +export function getZcashAddressKind( + address: string, + network: fixedScriptWallet.ZcashNetworkName +): ZcashAddressKind | undefined { + // ZcashNetworkName also permits 'zcash'/'zcashTest', which toOutputScriptWithCoin + // doesn't accept (it takes a CoinName, i.e. 'zec'/'tzec'). Skip straight to the + // shielded check for those rather than relying on an unsafe cast + caught throw. + if (isCoinName(network)) { + try { + wasmAddress.toOutputScriptWithCoin(address, network); + return 'transparent'; + } catch { + // not a valid transparent address; fall through to shielded check + } + } + return isShieldedZcashAddress(address, network) ? 'shielded' : undefined; +} + +/** + * Standalone counterpart to `Zec.isValidAddress`, parameterized by `network` + * instead of requiring a coin instance. Accepts transparent addresses and + * shielded ZIP-316 Unified Addresses. + * + * Not structurally identical to `Zec.isValidAddress`: the base class also + * round-trips the parsed script through each known encoding format (see + * `AbstractUtxoCoin.isValidAddress`), whereas this only calls + * `toOutputScriptWithCoin` once via `getZcashAddressKind`. They agree in + * practice since zec/tzec have no alternate transparent-address encoding to + * round-trip against, but that's not guaranteed to remain true. + */ +export function isValidZcashAddress(address: string, network: fixedScriptWallet.ZcashNetworkName): boolean { + return getZcashAddressKind(address, network) !== undefined; +} diff --git a/modules/abstract-utxo/src/impl/zec/index.ts b/modules/abstract-utxo/src/impl/zec/index.ts index 707e753101..3e05c1b7b9 100644 --- a/modules/abstract-utxo/src/impl/zec/index.ts +++ b/modules/abstract-utxo/src/impl/zec/index.ts @@ -1,2 +1,3 @@ export * from './zec'; export * from './tzec'; +export * from './address'; diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index 0ee1df3081..8aed41fd92 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -2,10 +2,13 @@ * @prettier */ import { BitGoBase } from '@bitgo/sdk-core'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; import { UtxoCoinName } from '../../names'; +import { isShieldedZcashAddress } from './address'; + export class Zec extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'zec'; @@ -16,4 +19,11 @@ export class Zec extends AbstractUtxoCoin { static createInstance(bitgo: BitGoBase): Zec { return new Zec(bitgo); } + + isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { + if (super.isValidAddress(address, param)) { + return true; + } + return isShieldedZcashAddress(address, this.name as fixedScriptWallet.ZcashNetworkName); + } } diff --git a/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts new file mode 100644 index 0000000000..d32423bf2a --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/zec/unit/address.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; + +import { BitGoAPI } from '@bitgo/sdk-api'; + +import { + Zec, + Tzec, + getZcashAddressKind, + isShieldedZcashAddress, + isValidZcashAddress, +} from '../../../../../src/impl/zec'; + +// ZIP-316 unified-address test vectors, copied from +// BitGoWASM/packages/wasm-utxo/test/fixtures/zcash/unified_address.json so +// both repos test against the same known-good data. +const zip316Mainnet = { + unified: + 'u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx', +}; +const testnetWallet = { + unified: + 'utest1w5m0qcnp8egl8qa296n70n8nvj0tqnzk90p7f48v7mjhhdrdqs8vgqydslg5plmzefawefnpmgmlm6hcy38m972erwxs04s02cq2prhguz8kqly75m6zjy56m08d5jnycgtpqtjeprte576gkmrxyszepgx76yzuwhh7m4lfz9jaq7unjk0x5ant46juxz73hsc6q4v3dqtzww00vps', + transparentAddress: 'tmM4DvLVJKXZt5ydn1tqYTHvahpKSwgjuRk', +}; + +describe('Zcash address validation', function () { + let bitgo: BitGoAPI; + let zec; + let tzec; + + before(function () { + bitgo = new BitGoAPI({ env: 'mock' }); + bitgo.register('zec', Zec.createInstance); + bitgo.register('tzec', Tzec.createInstance); + zec = bitgo.coin('zec'); + tzec = bitgo.coin('tzec'); + }); + + it('recognizes a mainnet unified address as shielded', function () { + assert.strictEqual(zec.isValidAddress(zip316Mainnet.unified), true); + assert.strictEqual(getZcashAddressKind(zip316Mainnet.unified, 'zec'), 'shielded'); + assert.strictEqual(isShieldedZcashAddress(zip316Mainnet.unified, 'zec'), true); + assert.strictEqual(isValidZcashAddress(zip316Mainnet.unified, 'zec'), true); + }); + + it('recognizes a testnet unified address as shielded', function () { + assert.strictEqual(tzec.isValidAddress(testnetWallet.unified), true); + assert.strictEqual(getZcashAddressKind(testnetWallet.unified, 'tzec'), 'shielded'); + assert.strictEqual(isShieldedZcashAddress(testnetWallet.unified, 'tzec'), true); + assert.strictEqual(isValidZcashAddress(testnetWallet.unified, 'tzec'), true); + }); + + it('recognizes a testnet transparent address as transparent', function () { + assert.strictEqual(tzec.isValidAddress(testnetWallet.transparentAddress), true); + assert.strictEqual(getZcashAddressKind(testnetWallet.transparentAddress, 'tzec'), 'transparent'); + assert.strictEqual(isValidZcashAddress(testnetWallet.transparentAddress, 'tzec'), true); + }); + + it('recognizes a mainnet transparent (P2PKH) address as transparent', function () { + const address = 't1cN2ZVWzWcVRrnfeQzmkpLhzQ4dYRv8yRY'; + assert.strictEqual(zec.isValidAddress(address), true); + assert.strictEqual(getZcashAddressKind(address, 'zec'), 'transparent'); + assert.strictEqual(isValidZcashAddress(address, 'zec'), true); + }); + + it('rejects a garbage string', function () { + const garbage = 'not-a-real-address'; + assert.strictEqual(zec.isValidAddress(garbage), false); + assert.strictEqual(getZcashAddressKind(garbage, 'zec'), undefined); + assert.strictEqual(isValidZcashAddress(garbage, 'zec'), false); + }); + + it('rejects a unified address checked against the wrong network', function () { + assert.strictEqual(tzec.isValidAddress(zip316Mainnet.unified), false); + assert.strictEqual(getZcashAddressKind(zip316Mainnet.unified, 'tzec'), undefined); + assert.strictEqual(isShieldedZcashAddress(zip316Mainnet.unified, 'tzec'), false); + assert.strictEqual(isValidZcashAddress(zip316Mainnet.unified, 'tzec'), false); + }); +}); diff --git a/modules/bitgo/test/v2/unit/keychains.ts b/modules/bitgo/test/v2/unit/keychains.ts index dd336d2035..1847d0a43e 100644 --- a/modules/bitgo/test/v2/unit/keychains.ts +++ b/modules/bitgo/test/v2/unit/keychains.ts @@ -43,25 +43,25 @@ describe('V2 Keychains', function () { scope.done(); }); - it('should add a safe child keychain with derivedFromParentWithHardenedPath', async function () { + it('should add a safe child keychain with derivedFromParentWithPath', async function () { const scope = nock(bgUrl) .post('/api/v2/tltc/key', function (body) { body.pub.should.equal('pub'); body.parent.should.equal('parent-key-id'); body.safeId.should.equal('safe-id'); - body.derivedFromParentWithHardenedPath.should.equal("m/7'"); + body.derivedFromParentWithPath.should.equal("m/7'"); should.equal(body.path, undefined); should.equal(body.derivedFromParentWithSeed, undefined); return true; }) - .reply(200, { id: 'child-key-id', derivedFromParentWithHardenedPath: "m/7'", path: '/0/0' }); + .reply(200, { id: 'child-key-id', derivedFromParentWithPath: "m/7'", path: '/0/0' }); const result = await keychains.add({ pub: 'pub', parent: 'parent-key-id', safeId: 'safe-id', - derivedFromParentWithHardenedPath: "m/7'", + derivedFromParentWithPath: "m/7'", }); - result.derivedFromParentWithHardenedPath.should.equal("m/7'"); + result.derivedFromParentWithPath.should.equal("m/7'"); scope.done(); }); }); diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 1a38b9f973..d26e581755 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -3883,6 +3883,29 @@ describe('V2 Wallet:', function () { intent.feeOptions!.should.not.have.property('feeToken'); }); + ['wrap-native', 'unwrap-native'].forEach(function (intentType) { + it(`populate intent should return a valid ${intentType} intent without recipients`, async function () { + const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth')); + + // Two independent sites in populateIntent must know about this intentType: + // the recipients-required exemption list, and the EVM intent-shape switch. + // Missing the first makes this call throw on the recipients assertion + // before the switch is ever reached. + const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), { + reqId, + intentType, + defiParams: { vaultId: 'hteth-weth-test', amount: '1000000000000000000' }, + }); + + intent.intentType.should.equal(intentType); + intent.should.have.property('recipients', undefined); + intent.vaultId!.should.equal('hteth-weth-test'); + // A plain `amount`, not the `shareTokenAmount` defi-withdraw uses for shares. + intent.amount!.should.equal('1000000000000000000'); + intent.should.not.have.property('shareTokenAmount'); + }); + }); + it('populate intent should return valid coredao acceleration intent', async function () { const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('coredao')); diff --git a/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts b/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts index 2597094c57..3877a74cc4 100644 --- a/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts +++ b/modules/sdk-coin-sol/src/lib/closeAtaBuilder.ts @@ -14,7 +14,12 @@ const MIX_API_ERROR_MESSAGE = export class CloseAtaBuilder extends TransactionBuilder { // Unified storage for all close entries (single or bulk) - protected _closeAtaEntries: { accountAddress: string; destinationAddress: string; authorityAddress: string }[] = []; + protected _closeAtaEntries: { + accountAddress: string; + destinationAddress: string; + authorityAddress: string; + programId?: string; + }[] = []; // Which API has been used on this builder instance. Locks in on first call so we can // reject attempts to mix the legacy single-ATA setters with the bulk addCloseAtaInstruction(). @@ -71,6 +76,16 @@ export class CloseAtaBuilder extends TransactionBuilder { return this; } + /** Sets the SPL token program used by the close instruction. */ + programId(programId: string): this { + this._assertSingleAtaApiUsable(); + validateAddress(programId, 'programId'); + this._apiMode = 'single'; + this._ensureSingleEntry(); + this._closeAtaEntries[0].programId = programId; + return this; + } + /** * Throws if the bulk-ATA API has already been used on this builder. */ @@ -93,11 +108,17 @@ export class CloseAtaBuilder extends TransactionBuilder { * Add an ATA to close in this transaction (for bulk closure). * Cannot be mixed with the single-ATA API (accountAddress/destinationAddress/authorityAddress). * - * @param {string} accountAddress - the ATA address to close - * @param {string} destinationAddress - where rent SOL goes (root wallet address) - * @param {string} authorityAddress - ATA owner who must sign + * @param accountAddress - the ATA address to close + * @param destinationAddress - where rent SOL goes (root wallet address) + * @param authorityAddress - ATA owner who must sign + * @param programId - SPL token program owning the ATA; omitted for legacy SPL */ - addCloseAtaInstruction(accountAddress: string, destinationAddress: string, authorityAddress: string): this { + addCloseAtaInstruction( + accountAddress: string, + destinationAddress: string, + authorityAddress: string, + programId?: string + ): this { if (this._apiMode === 'single') { throw new BuildTransactionError(MIX_API_ERROR_MESSAGE); } @@ -105,6 +126,9 @@ export class CloseAtaBuilder extends TransactionBuilder { validateAddress(accountAddress, 'accountAddress'); validateAddress(destinationAddress, 'destinationAddress'); validateAddress(authorityAddress, 'authorityAddress'); + if (programId) { + validateAddress(programId, 'programId'); + } if (accountAddress === destinationAddress) { throw new BuildTransactionError('Account address to close cannot be the same as the destination address'); @@ -115,7 +139,7 @@ export class CloseAtaBuilder extends TransactionBuilder { } this._apiMode = 'bulk'; - this._closeAtaEntries.push({ accountAddress, destinationAddress, authorityAddress }); + this._closeAtaEntries.push({ accountAddress, destinationAddress, authorityAddress, programId }); return this; } @@ -129,6 +153,7 @@ export class CloseAtaBuilder extends TransactionBuilder { accountAddress: ataCloseInstruction.params.accountAddress, destinationAddress: ataCloseInstruction.params.destinationAddress, authorityAddress: ataCloseInstruction.params.authorityAddress, + programId: ataCloseInstruction.params.programId, }); } } @@ -158,6 +183,7 @@ export class CloseAtaBuilder extends TransactionBuilder { accountAddress: entry.accountAddress, destinationAddress: entry.destinationAddress, authorityAddress: entry.authorityAddress, + ...(entry.programId ? { programId: entry.programId } : {}), }, }) ); diff --git a/modules/sdk-coin-sol/src/lib/iface.ts b/modules/sdk-coin-sol/src/lib/iface.ts index a26aebd2a4..40955739c8 100644 --- a/modules/sdk-coin-sol/src/lib/iface.ts +++ b/modules/sdk-coin-sol/src/lib/iface.ts @@ -289,7 +289,13 @@ export interface AtaInit { export interface AtaClose { type: InstructionBuilderTypes.CloseAssociatedTokenAccount; - params: { accountAddress: string; destinationAddress: string; authorityAddress: string }; + params: { + accountAddress: string; + destinationAddress: string; + authorityAddress: string; + /** SPL token program owning the ATA; omitted for legacy Token Program. */ + programId?: string; + }; } export interface AtaRecoverNested { diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index ea7a7343e8..d1b0543f73 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -24,7 +24,6 @@ export { MessageBuilderFactory } from './messages'; export { explainSolTransaction, ExplainTransactionWasmOptions } from './explainTransactionWasm'; export { MintExtensionReadResult, - assertExtensionCompatibility, extensionTypeNames, mapModeledExtensions, parseMintExtensions, diff --git a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts index 6b6aa0c06a..aab5c20b5c 100644 --- a/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts +++ b/modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts @@ -314,6 +314,9 @@ function parseSendInstructions( accountAddress, destinationAddress, authorityAddress, + ...(instruction.programId.equals(TOKEN_2022_PROGRAM_ID) + ? { programId: instruction.programId.toString() } + : {}), }, }; instructionData.push(ataClose); @@ -1201,6 +1204,9 @@ function parseAtaCloseInstructions(instructions: TransactionInstruction[]): Arra accountAddress: instruction.keys[ataCloseInstructionKeysIndexes.AccountAddress].pubkey.toString(), destinationAddress: instruction.keys[ataCloseInstructionKeysIndexes.DestinationAddress].pubkey.toString(), authorityAddress: instruction.keys[ataCloseInstructionKeysIndexes.AuthorityAddress].pubkey.toString(), + ...(instruction.programId.equals(TOKEN_2022_PROGRAM_ID) + ? { programId: instruction.programId.toString() } + : {}), }, }; instructionData.push(ataClose); diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index 3f3dd3f0c1..6a398e1d10 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -9,6 +9,7 @@ import { createTransferCheckedInstruction, createTransferCheckedWithFeeInstruction, TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, createApproveInstruction, } from '@solana/spl-token'; import { struct, u8, s8, blob } from '@solana/buffer-layout'; @@ -608,16 +609,19 @@ function createATAInstruction(data: AtaInit): TransactionInstruction[] { */ function closeATAInstruction(data: AtaClose): TransactionInstruction[] { const { - params: { accountAddress, destinationAddress, authorityAddress }, + params: { accountAddress, destinationAddress, authorityAddress, programId }, } = data; assert(accountAddress, 'Missing accountAddress param'); assert(destinationAddress, 'Missing destinationAddress param'); assert(authorityAddress, 'Missing authorityAddress param'); + const tokenProgramId = programId ? new PublicKey(programId) : TOKEN_PROGRAM_ID; const closeAssociatedTokenAccountInstruction = createCloseAccountInstruction( new PublicKey(accountAddress), new PublicKey(destinationAddress), - new PublicKey(authorityAddress) + new PublicKey(authorityAddress), + [], + tokenProgramId ); return [closeAssociatedTokenAccountInstruction]; } diff --git a/modules/sdk-coin-sol/src/lib/tokenExtensions.ts b/modules/sdk-coin-sol/src/lib/tokenExtensions.ts index eb1e1f1955..424896e6d3 100644 --- a/modules/sdk-coin-sol/src/lib/tokenExtensions.ts +++ b/modules/sdk-coin-sol/src/lib/tokenExtensions.ts @@ -36,8 +36,6 @@ const EXTENSION_NAME_MAP: Readonly> = { ScaledUiAmountConfig: SolTokenExtensionType.ScaledUiAmount, }; -const CONFIDENTIAL_TRANSFER_NAMES = ['ConfidentialTransferMint', 'ConfidentialTransferFeeConfig']; - /** Human-readable names of every extension type present on the mint. */ export function extensionTypeNames(mintInfo: Mint): string[] { if (mintInfo.tlvData.length === 0) { @@ -58,17 +56,13 @@ export function mapModeledExtensions(detectedTypeNames: readonly string[]): SolT return modeled; } -/** - * Enforce the protocol-level incompatibility: Transfer Hook and Confidential - * Transfer cannot coexist on the same mint. Pure — unit-testable without chain data. - */ -export function assertExtensionCompatibility(detectedTypeNames: readonly string[]): void { - const hasHook = detectedTypeNames.includes('TransferHook'); - const hasConfidential = detectedTypeNames.some((n) => CONFIDENTIAL_TRANSFER_NAMES.includes(n)); - if (hasHook && hasConfidential) { - throw new Error('Mint declares both Transfer Hook and Confidential Transfer, which cannot coexist'); - } -} +// No SDK-level extension-combination assert: the on-chain program already +// enforces its own invalid-combination rules at extension init +// (`check_for_invalid_mint_extension_combinations` in token-2022), and it does +// NOT forbid TransferHook + ConfidentialTransferMint. A previous assert here +// rejected that legal pair and blocked onboarding of real mints. Custody +// policy on which combinations BitGo will serve belongs to consumers +// (statics `getUnsupportedSolTokenExtensions`, the AMS onboarding gate). function toBase58(key: PublicKey | null): string | undefined { return key ? key.toBase58() : undefined; @@ -80,7 +74,6 @@ function toBase58(key: PublicKey | null): string | undefined { */ export function parseMintExtensions(mintInfo: Mint): MintExtensionReadResult { const detectedTypeNames = extensionTypeNames(mintInfo); - assertExtensionCompatibility(detectedTypeNames); const extensions: SolTokenExtensions = { detected: mapModeledExtensions(detectedTypeNames) }; const authorities: NonNullable = { diff --git a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts index 72d9ff5b30..e3732428dd 100644 --- a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts @@ -39,6 +39,20 @@ describe('Instruction Builder Tests: ', function () { ]); }); + it('Close ATA uses Token-2022 program when specified', () => { + const result = solInstructionFactory({ + type: InstructionBuilderTypes.CloseAssociatedTokenAccount, + params: { + accountAddress: testData.authAccount.pub, + destinationAddress: testData.authAccount2.pub, + authorityAddress: testData.authAccount.pub, + programId: TOKEN_2022_PROGRAM_ID.toString(), + }, + }); + + result[0].programId.equals(TOKEN_2022_PROGRAM_ID).should.be.true(); + }); + it('Transfer', () => { const fromAddress = testData.authAccount.pub; const toAddress = testData.nonceAccount.pub; diff --git a/modules/sdk-coin-sol/test/unit/tokenExtensions.ts b/modules/sdk-coin-sol/test/unit/tokenExtensions.ts index 93d9b3df73..c045d2ac8b 100644 --- a/modules/sdk-coin-sol/test/unit/tokenExtensions.ts +++ b/modules/sdk-coin-sol/test/unit/tokenExtensions.ts @@ -1,6 +1,8 @@ import 'should'; import { SolTokenExtensionType } from '@bitgo/statics'; -import { assertExtensionCompatibility, mapModeledExtensions } from '../../src/lib/tokenExtensions'; +import { ExtensionType, type Mint } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import { mapModeledExtensions, parseMintExtensions } from '../../src/lib/tokenExtensions'; describe('Sol Token-2022 mint extension parsing', function () { describe('mapModeledExtensions', function () { @@ -22,13 +24,38 @@ describe('Sol Token-2022 mint extension parsing', function () { }); }); - describe('assertExtensionCompatibility', function () { - it('throws when Transfer Hook and Confidential Transfer coexist', function () { - (() => assertExtensionCompatibility(['TransferHook', 'ConfidentialTransferMint'])).should.throw(/cannot coexist/); - }); + describe('parseMintExtensions', function () { + function tlvEntry(type: ExtensionType, value: Buffer): Buffer { + const head = Buffer.alloc(4); + head.writeUInt16LE(type, 0); + head.writeUInt16LE(value.length, 2); + return Buffer.concat([head, value]); + } + + function fakeMint(tlvData: Buffer): Mint { + return { + address: PublicKey.default, + mintAuthority: null, + supply: BigInt(0), + decimals: 6, + isInitialized: true, + freezeAuthority: null, + tlvData, + }; + } - it('allows Transfer Hook without Confidential Transfer', function () { - (() => assertExtensionCompatibility(['TransferHook', 'TransferFeeConfig'])).should.not.throw(); + it('parses a mint declaring both Transfer Hook and Confidential Transfer', function () { + // Legal on-chain: token-2022's check_for_invalid_mint_extension_combinations + // does not forbid this pair. Regression test — an SDK-level assert used to + // reject it and block onboarding of real mints. + const tlvData = Buffer.concat([ + tlvEntry(ExtensionType.ConfidentialTransferMint, Buffer.alloc(0)), + tlvEntry(ExtensionType.TransferHook, Buffer.alloc(64)), // authority (32) + programId (32) + ]); + const result = parseMintExtensions(fakeMint(tlvData)); + result.detectedTypeNames.should.eql(['ConfidentialTransferMint', 'TransferHook']); + result.extensions.detected.should.eql([SolTokenExtensionType.TransferHook]); + result.extensions.transferHookProgramId?.should.equal('11111111111111111111111111111111'); }); }); }); diff --git a/modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts b/modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts index 8630c82ad5..5c2326e747 100644 --- a/modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts +++ b/modules/sdk-coin-sol/test/unit/transactionBuilder/closeAtaBuilder.ts @@ -220,6 +220,23 @@ describe('Sol Close ATA Builder', () => { instruction.params.destinationAddress.should.equal(destinationAddress); } }); + + it('builds mixed legacy SPL and Token-2022 close instructions', async () => { + const txBuilder = closeAtaBuilder(); + txBuilder.addCloseAtaInstruction(ataAddress1, destinationAddress, account.pub); + txBuilder.addCloseAtaInstruction( + ataAddress2, + destinationAddress, + account.pub, + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' + ); + + const tx = await txBuilder.build(); + const instructions = tx.toJson().instructionsData; + instructions.length.should.equal(2); + should.not.exist(instructions[0].params.programId); + instructions[1].params.programId.should.equal('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'); + }); }); describe('Fail', () => { diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 313cab37df..74be73ca27 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -8,6 +8,7 @@ import { IPendingApprovals } from '../pendingApproval'; import { InitiateRecoveryOptions } from '../recovery'; import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa'; import EddsaUtils, { EddsaMPCv2Utils, PrebuildTransactionWithIntentOptions, TxRequest } from '../utils/tss/eddsa'; +import { RedpallasMPCv2Utils } from '../utils/tss/redpallas'; import { CreateAddressFormat, CustomSigningFunction, IWallet, IWallets, Memo, Wallet, WalletData } from '../wallet'; import { TokenEnablement } from '@bitgo/public-types'; @@ -364,7 +365,7 @@ export interface ExtraPrebuildParamsOptions { export interface PresignTransactionOptions { txPrebuild?: TransactionPrebuild; walletData: WalletData; - tssUtils: EcdsaUtils | EcdsaMPCv2Utils | EddsaUtils | EddsaMPCv2Utils | undefined; + tssUtils: EcdsaUtils | EcdsaMPCv2Utils | EddsaUtils | EddsaMPCv2Utils | RedpallasMPCv2Utils | undefined; [index: string]: unknown; } @@ -628,11 +629,14 @@ export interface MessagePrep { } /** - * 'redpallas' is a DKG-only MPC algorithm (no signing support in this SDK) used for the - * Zcash Orchard shielded pool. It is additive: existing coins never return it from - * `getMPCAlgorithm()` unless explicitly implemented to do so, so this does not change - * behavior for any existing ECDSA/EdDSA coin or for ZEC's existing transparent - * (secp256k1) multisig/TSS flows. + * 'redpallas' is the MPC algorithm used for the Zcash Orchard shielded pool (RedPallas / + * "Ironwood"). Today it only supports DKG (key generation) via MPCv2 in this SDK - there is no + * online self-custody DSG (transaction signing) entrypoint yet, though the underlying + * signature-share primitives (`bitgo/tss/redpallas`) and the `sendSignatureShareV2` MPCv2 + * request type exist as groundwork for a future custodial/cold (SMC/OVC) DSG signing flow. + * It is additive: existing coins never return it from `getMPCAlgorithm()` unless explicitly + * implemented to do so, so this does not change behavior for any existing ECDSA/EdDSA coin or + * for ZEC's existing transparent (secp256k1) multisig/TSS flows. */ export type MPCAlgorithm = 'ecdsa' | 'eddsa' | 'redpallas'; diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts index 2b733d85aa..6357962c08 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -2,6 +2,7 @@ * @prettier */ import * as t from 'io-ts'; +import { CoinFeature } from '@bitgo/statics'; import { GetVaultResponse, VaultProtocol, VaultProtocolType } from '@bitgo/public-types'; import { ConcreteDepositResult, @@ -17,6 +18,8 @@ import { ResumeDepositOptions, WithdrawFromVaultOptions, WithdrawResult, + WrapOptions, + WrapResult, } from './iDefiVault'; import { IWallet } from '../wallet'; import { BitGoBase } from '../bitgoBase'; @@ -323,8 +326,78 @@ export class DefiVault implements IDefiVault { return { operationId, txRequestId }; } + /** + * Wrap native currency into its canonical wrapped-native ERC-20 + * (ETH → WETH via the WETH9 `deposit()` call). + * + * A thin orchestrator over a single sendMany, like {@link withdrawFromVault}. + * WP builds the calldata and resolves the WETH9 address server-side from the + * vault binding; the SDK only forwards vaultId and amount. + * + * @param params.vaultId - DeFi-service vault identifier. Required in v1: binding + * the wrap to a vault is what supplies the per-enterprise authorization gate + * and the address-whitelist path server-side (TDD §3.6). M7 makes it optional, + * which is backward-compatible. + * @param params.amount - amount in base units of the native coin (18dp for ETH) + * @param params.walletPassphrase - required for hot wallets, omit for custody + */ + async wrap(params: WrapOptions): Promise { + return this.sendWrapIntent('wrapNative', params); + } + + /** + * Unwrap the canonical wrapped-native ERC-20 back to native currency + * (WETH → ETH via the WETH9 `withdraw(uint256)` call). + * + * @param params.vaultId - DeFi-service vault identifier (see {@link wrap}) + * @param params.amount - amount in base units of the wrapped token (18dp for WETH) + * @param params.walletPassphrase - required for hot wallets, omit for custody + */ + async unwrap(params: WrapOptions): Promise { + return this.sendWrapIntent('unwrapNative', params); + } + // ── Internal helpers ──────────────────────────────────────────────── + /** + * Shared body of {@link wrap} and {@link unwrap} — the two differ only in the + * sendMany type they issue. + * + * Deliberately does not call {@link extractOperationId}: no operation is minted + * for wrap/unwrap in v1, so it would only ever return undefined. Operation + * tracking arrives in milestone M5. + */ + private async sendWrapIntent(type: 'wrapNative' | 'unwrapNative', params: WrapOptions): Promise { + const vaultId = params.vaultId?.trim(); + if (!vaultId) { + throw new Error('vaultId is required'); + } + // The downstream BigIntFromString codec (wallet.ts) accepts anything JS's BigInt() constructor + // does - negative amounts, hex strings like '0xabc', and zero - and this amount forwards + // straight into a value-moving WETH9 deposit()/withdraw() call. Require a positive unsigned + // decimal integer string here; zero is deliberately rejected too, since a zero-amount wrap/ + // unwrap has no on-chain effect but would still spend gas. + if (!params.amount || !/^\d+$/.test(params.amount) || BigInt(params.amount) === 0n) { + throw new Error('amount must be a positive unsigned decimal integer string'); + } + // Wrapped-native vaults (WETH9 deposit()/withdraw()) only exist on EVM chains. Fail fast here + // instead of letting an unsupported coin reach wallet-platform and return an opaque prebuild error. + if (!this.wallet.baseCoin.getConfig().features.includes(CoinFeature.EVM_COIN)) { + throw new Error(`wrap/unwrap is not supported for ${this.wallet.baseCoin.getFamily()} wallets`); + } + + const result = await this.wallet.sendMany({ + type, + defiParams: { + vaultId, + amount: params.amount, + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + }); + + return { txRequestId: this.extractTxRequestId(result) }; + } + /** * Extract txRequestId from a sendMany result. * sendMany returns different shapes depending on wallet type: diff --git a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts index 123c9ccd3b..4cc4e7bc8f 100644 --- a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts @@ -80,6 +80,21 @@ export interface WithdrawResult { txRequestId: string; } +export interface WrapOptions { + /** DeFi-service vault identifier — required in v1, see note below */ + vaultId: string; + /** Amount in base units (18dp for ETH/WETH) */ + amount: string; + /** Wallet passphrase — required for hot wallets, omit for custody */ + walletPassphrase?: string; +} + +export interface WrapResult { + txRequestId: string; + /** Reserved — populated from milestone M5 onward, absent in v1 */ + operationId?: string; +} + export interface IDefiVault { depositToVault(params: DepositToVaultOptions): Promise; resumeDeposit(params: ResumeDepositOptions): Promise; @@ -88,4 +103,6 @@ export interface IDefiVault { getVaultConfig(params: GetVaultConfigOptions): Promise; getVaultProtocol(params: GetVaultConfigOptions): Promise; withdrawFromVault(params: WithdrawFromVaultOptions): Promise; + wrap(params: WrapOptions): Promise; + unwrap(params: WrapOptions): Promise; } diff --git a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts index aabb3088db..bfc7038cc5 100644 --- a/modules/sdk-core/src/bitgo/keychain/iKeychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/iKeychains.ts @@ -50,7 +50,7 @@ export interface Keychain { derivationPath?: string; derivedFromParentWithSeed?: string; /** Hardened path from the safe parent (`m/'`). @experimental */ - derivedFromParentWithHardenedPath?: string; + derivedFromParentWithPath?: string; /** Safe root key id this child key was derived from (WCN-1172). */ parent?: string; commonPub?: string; @@ -149,7 +149,7 @@ export interface AddKeychainOptions { enterprise?: string; derivedFromParentWithSeed?: string; /** Hardened path from the safe parent (`m/'`). @experimental */ - derivedFromParentWithHardenedPath?: string; + derivedFromParentWithPath?: string; /** Safe user-root key id this child was derived from. @experimental */ parent?: string; disableKRSEmail?: boolean; diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index 26b5177fb0..ca78a8c88a 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -267,7 +267,7 @@ export class Keychains implements IKeychains { 'originalPasscodeEncryptionCode', 'enterprise', 'derivedFromParentWithSeed', - 'derivedFromParentWithHardenedPath', + 'derivedFromParentWithPath', 'parent', 'safeId', ] @@ -297,7 +297,7 @@ export class Keychains implements IKeychains { originalPasscodeEncryptionCode: params.originalPasscodeEncryptionCode, enterprise: params.enterprise, derivedFromParentWithSeed: params.derivedFromParentWithSeed, - derivedFromParentWithHardenedPath: params.derivedFromParentWithHardenedPath, + derivedFromParentWithPath: params.derivedFromParentWithPath, parent: params.parent, disableKRSEmail: params.disableKRSEmail, krsSpecific: params.krsSpecific, diff --git a/modules/sdk-core/src/bitgo/safe/safe.ts b/modules/sdk-core/src/bitgo/safe/safe.ts index 409922aad6..a1b198c7df 100644 --- a/modules/sdk-core/src/bitgo/safe/safe.ts +++ b/modules/sdk-core/src/bitgo/safe/safe.ts @@ -157,7 +157,7 @@ export class Safe implements ISafe { keyType: 'independent', parent: userRootId, safeId: this.id(), - derivedFromParentWithHardenedPath, + derivedFromParentWithPath: derivedFromParentWithHardenedPath, }); const childId = child.id; if (childId.length === 0) { diff --git a/modules/sdk-core/src/bitgo/tss/common.ts b/modules/sdk-core/src/bitgo/tss/common.ts index 97bb251f8d..cd338823ee 100644 --- a/modules/sdk-core/src/bitgo/tss/common.ts +++ b/modules/sdk-core/src/bitgo/tss/common.ts @@ -158,6 +158,8 @@ export async function sendSignatureShareV2( type = 'ecdsaMpcV2'; } else if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'eddsa') { type = 'eddsaMpcV2'; + } else if (multisigTypeVersion === 'MPCv2' && mpcAlgorithm === 'redpallas') { + type = 'redpallasMpcV2'; } else if (multisigTypeVersion === undefined && mpcAlgorithm === 'eddsa') { type = 'eddsaMpcV1'; } diff --git a/modules/sdk-core/src/bitgo/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/src/bitgo/tss/redpallas/redpallasMPCv2.ts new file mode 100644 index 0000000000..769f832d8e --- /dev/null +++ b/modules/sdk-core/src/bitgo/tss/redpallas/redpallasMPCv2.ts @@ -0,0 +1,149 @@ +import * as openpgp from 'openpgp'; +import { RedPallasMPSComms, RedPallasMPSTypes } from '@bitgo/sdk-lib-mpc'; +import { + RedpallasMPCv2SignatureShareRound1Input, + RedpallasMPCv2SignatureShareRound1Output, + RedpallasMPCv2SignatureShareRound2Input, + RedpallasMPCv2SignatureShareRound2Output, + RedpallasMPCv2SignatureShareRound3Input, + RedpallasMPCv2SignatureShareRound3Output, +} from '@bitgo/public-types'; +import { SignatureShareRecord, SignatureShareType } from '../../utils/tss/baseTypes'; +import { MPCv2PartiesEnum } from '../../utils/tss/ecdsa/typesMPCv2'; + +type SignerPartyId = MPCv2PartiesEnum.USER | MPCv2PartiesEnum.BACKUP; + +function partyIdToSignatureShareType(partyId: MPCv2PartiesEnum): SignatureShareType { + switch (partyId) { + case MPCv2PartiesEnum.USER: + return SignatureShareType.USER; + case MPCv2PartiesEnum.BACKUP: + return SignatureShareType.BACKUP; + case MPCv2PartiesEnum.BITGO: + return SignatureShareType.BITGO; + } +} + +/** + * RedPallas MPS DSG signature-share helpers. + * + * Groundwork for a future custodial/cold (SMC/OVC) DSG signing flow - not yet wired up to any + * caller in this SDK. Mirrors `../eddsa/eddsaMPCv2.ts` (same 3-round shape, same PGP-signed- + * message envelope), but kept as an independent copy - built on `RedPallasMPSComms` - so + * RedPallas MPS never depends on the EdDSA MPS module, and vice versa. + */ + +/** + * Builds the round-1 signature share record. + * + * PGP-signs the WASM round-0 broadcast message with the signer's ephemeral key and + * wraps it into a SignatureShareRecord ready for `sendSignatureShareV2`. + */ +export async function getSignatureShareRoundOne( + userMsg1: RedPallasMPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: SignerPartyId = MPCv2PartiesEnum.USER, + otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const signedMsg1 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg1.payload), userGpgPrivKey); + const share: RedpallasMPCv2SignatureShareRound1Input = { + type: 'round1Input', + data: { msg1: signedMsg1 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} + +/** + * Verifies the peer's round-1 PGP signature and returns the raw deserialized + * message ready for `RedPallasDSG.handleIncomingMessages`. + */ +export async function verifyPeerMessageRoundOne( + parsedRound1Output: RedpallasMPCv2SignatureShareRound1Output, + peerGpgKey: openpgp.Key, + peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound1Output.data.msg1, peerGpgKey); + return { + from: peerPartyId, + payload: new Uint8Array(rawBytes), + }; +} + +/** + * Builds the round-2 signature share record. + */ +export async function getSignatureShareRoundTwo( + userMsg2: RedPallasMPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: SignerPartyId = MPCv2PartiesEnum.USER, + otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const signedMsg2 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg2.payload), userGpgPrivKey); + const share: RedpallasMPCv2SignatureShareRound2Input = { + type: 'round2Input', + data: { msg2: signedMsg2 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} + +/** + * Verifies the peer's round-2 PGP signature and returns the raw deserialized + * message ready for `RedPallasDSG.handleIncomingMessages`. + */ +export async function verifyPeerMessageRoundTwo( + parsedRound2Output: RedpallasMPCv2SignatureShareRound2Output, + peerGpgKey: openpgp.Key, + peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound2Output.data.msg2, peerGpgKey); + return { + from: peerPartyId, + payload: new Uint8Array(rawBytes), + }; +} + +/** + * Verifies the peer's round-3 PGP signature and returns the raw deserialized + * message ready for `RedPallasDSG.handleIncomingMessages`. + */ +export async function verifyPeerMessageRoundThree( + parsedRound3Output: RedpallasMPCv2SignatureShareRound3Output, + peerGpgKey: openpgp.Key, + peerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsedRound3Output.data.msg3, peerGpgKey); + return { from: peerPartyId, payload: new Uint8Array(rawBytes) }; +} + +/** + * Builds the round-3 signature share record (final signer message). + * + * There is no corresponding `verifyBitGoMessageRoundThree` because Wallet Platform + * finalises the signing server-side after receiving round 3; the client obtains the + * signed transaction via `sendTxRequest`. + */ +export async function getSignatureShareRoundThree( + userMsg3: RedPallasMPSTypes.DeserializedMessage, + userGpgPrivKey: openpgp.PrivateKey, + partyId: SignerPartyId = MPCv2PartiesEnum.USER, + otherSignerPartyId: MPCv2PartiesEnum = MPCv2PartiesEnum.BITGO +): Promise { + const signedMsg3 = await RedPallasMPSComms.detachSignMpsMessage(Buffer.from(userMsg3.payload), userGpgPrivKey); + const share: RedpallasMPCv2SignatureShareRound3Input = { + type: 'round3Input', + data: { msg3: signedMsg3 }, + }; + return { + from: partyIdToSignatureShareType(partyId), + to: partyIdToSignatureShareType(otherSignerPartyId), + share: JSON.stringify(share), + }; +} diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index 62e98b90ff..98bad4da2e 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -222,6 +222,8 @@ export abstract class MpcUtils { 'defi-approve', 'defi-deposit', 'defi-withdraw', + 'wrap-native', + 'unwrap-native', 'wrapApprove', 'wrap', ].includes(params.intentType) @@ -336,6 +338,18 @@ export abstract class MpcUtils { shareTokenAmount: params.defiParams.amount, }; } + case 'wrap-native': + case 'unwrap-native': { + assert(params.defiParams, `'defiParams' is required for ${params.intentType} intent`); + // WrapNativeIntent / UnwrapNativeIntent carry a plain `amount` (base units + // of the native coin when wrapping, of the wrapped token when unwrapping), + // not the `shareTokenAmount` that defi-withdraw uses for vault shares. + return { + ...baseIntent, + vaultId: params.defiParams.vaultId, + amount: params.defiParams.amount, + }; + } case 'wrapApprove': case 'wrap': { assert(params.wrapParams, `'wrapParams' is required for ${params.intentType} intent`); diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 3bf02e52c2..f2bc902732 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -381,7 +381,7 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase feeToken?: string; /** Canton-specific params for the cantonCommand intent. */ cantonCommandParams?: CantonCommandParams; - /** DeFi vault intent fields for defi-approve / defi-deposit intents. */ + /** DeFi vault intent fields for defi-* and wrap-native / unwrap-native intents. */ defiParams?: DefiIntentParams; /** ERC-7984 wrap / wrapApprove fields flattened onto the WP intent. */ wrapParams?: WrapIntentParams; diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index d9169cb436..96fb2afe78 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -31,6 +31,18 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'defiApprove', 'defiDeposit', 'defiWithdraw', + // Native wrap/unwrap (WETH9 deposit()/withdraw()) — calldata and the WETH9 + // address are resolved server-side from the vault binding, so no recipients. + // Registered in BOTH spellings on purpose: this set is matched against + // txParams.type, which is buildParams.type (camelCase, from wallet.sendMany), + // AND against intent.intentType (kebab-case, as WP persists it). Signing paths + // that carry no txParams — notably pendingApproval.approve() → + // recreateTxRequest() → signTxRequest() with no txParams — only ever see the + // kebab-case spelling. + 'wrapNative', + 'wrap-native', + 'unwrapNative', + 'unwrap-native', // ERC-7984 shielding: approve/wrap calldata is built server-side from the wrap intent 'wrapApprove', 'wrap', diff --git a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts index 3a10536ca0..13017d0649 100644 --- a/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts +++ b/modules/sdk-core/src/bitgo/wallet/safeKeychain.ts @@ -5,6 +5,7 @@ import { BitGoBase } from '../bitgoBase'; import { decryptKeychainPrivateKey, IKeychains, Keychain, KeychainWithEncryptedPrv } from '../keychain'; import { deriveSafeChildHardenedFromXprv, parseDerivedFromParentWithHardenedPath } from '../safe/safeDerivation'; import { IncorrectPasswordError } from '../errors'; +import type { DecryptedKeychainData } from './iWallet'; export class InvalidRootKeychainSourceError extends Error { constructor(id: string, source: string | undefined) { @@ -35,6 +36,14 @@ export class SafeOwnerSigningNotImplementedError extends Error { } } +/** Thrown when wallet sharing is not implemented for this safe slot (TSS, ed25519 multisig, …). */ +export class SafeShareNotImplementedError extends Error { + constructor(walletId: string, detail: string) { + super(`Safe wallet ${walletId}: ${detail}`); + this.name = 'SafeShareNotImplementedError'; + } +} + /** ed25519 onchain multisig (slot ④). Needs SLIP-0010, not secp256k1 BIP32. */ const ED25519_ONCHAIN_FAMILIES = new Set(['algo', 'xlm', 'hbar']); @@ -70,7 +79,11 @@ export async function fetchRootKeychainForSafeChild( return root as KeychainWithEncryptedPrv; } -export interface ResolveSafeOwnerSigningPrvParams { +/** + * Shared params for resolving safe-owner key material (owner signing and wallet sharing). + * Both resolvers extend this so the precondition/derivation path cannot diverge. + */ +export interface SafeKeyMaterialBaseParams { bitgo: BitGoBase; keychains: IKeychains; walletId: string; @@ -83,31 +96,41 @@ export interface ResolveSafeOwnerSigningPrvParams { rootKeychain?: KeychainWithEncryptedPrv; } +export type ResolveSafeOwnerSigningPrvParams = SafeKeyMaterialBaseParams; + +type SafeKeyMaterialSlot = 'tss' | 'ed25519'; + +type ResolveSafeKeyMaterialParams = SafeKeyMaterialBaseParams & { + /** Constructs the not-implemented error for the current resolver (signing vs sharing). */ + makeNotImplementedError: (slot: SafeKeyMaterialSlot, walletId: string) => Error; +}; + /** - * Resolve signing material for a safe owner (child key has no encryptedPrv). + * Shared core that resolves safe key material for a pub-only safe child. Returns the CHILD + * `{prv, pub}` only — never the root — so the root can never leak into a share document. * - * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithHardenedPath` - * (`m/'`), and verify the registered pub. - * TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve. - * - * Do not use for wallet sharing — that must not receive root key material. - * Call only when `isSafeChildPublicOnlyKeychain` is true. + * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithPath` + * (`m/'`), and verify the registered pub. TSS and ed25519 onchain throw via + * `makeNotImplementedError` — the caller constructs its own error class + message, so the + * guard set stays shared while signing/sharing report their own errors. */ -export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigningPrvParams): Promise { - const { bitgo, keychains, walletId, multisigType, coinFamily, childKeychain, walletPassphrase } = params; +async function resolveSafeKeyMaterial(params: ResolveSafeKeyMaterialParams): Promise<{ prv: string; pub: string }> { + const { + bitgo, + keychains, + walletId, + multisigType, + coinFamily, + childKeychain, + walletPassphrase, + makeNotImplementedError, + } = params; if (multisigType !== 'onchain') { - throw new SafeOwnerSigningNotImplementedError( - walletId, - 'TSS owner signing from the root keyshare is not implemented. ' + - 'Returning the root private key would expose material that can derive every child in this slot.' - ); + throw makeNotImplementedError('tss', walletId); } if (ED25519_ONCHAIN_FAMILIES.has(coinFamily)) { - throw new SafeOwnerSigningNotImplementedError( - walletId, - `ed25519 multisig owner derivation (${coinFamily}) is not implemented; BIP32 would produce the wrong child key.` - ); + throw makeNotImplementedError('ed25519', walletId); } const rootKeychain = params.rootKeychain ?? (await fetchRootKeychainForSafeChild(keychains, childKeychain)); @@ -119,16 +142,81 @@ export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigning if (!childKeychain.pub) { throw new Error(`Safe wallet ${walletId}: child keychain is missing pub for pre-sign verification`); } - if (childKeychain.derivedFromParentWithHardenedPath === undefined) { - throw new Error(`Safe wallet ${walletId}: child keychain is missing derivedFromParentWithHardenedPath`); + if (childKeychain.derivedFromParentWithPath === undefined) { + throw new Error(`Safe wallet ${walletId}: child keychain is missing derivedFromParentWithPath`); } const derived = deriveSafeChildHardenedFromXprv( rootPrv, - parseDerivedFromParentWithHardenedPath(childKeychain.derivedFromParentWithHardenedPath) + parseDerivedFromParentWithHardenedPath(childKeychain.derivedFromParentWithPath) ); if (derived.pub !== childKeychain.pub) { throw new SafeDerivedPublicKeyMismatchError(walletId, childKeychain.pub, derived.pub); } - return derived.prv; + + if (derived.pub === rootKeychain.pub) { + throw new Error(`Safe wallet ${walletId}: derived child pub unexpectedly equals the root pub`); + } + return { prv: derived.prv, pub: derived.pub }; +} + +/** + * Resolve signing material for a safe owner (child key has no encryptedPrv). + * + * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithPath` + * (`m/'`), and verify the registered pub. + * TSS and ed25519 onchain: throw — do not return root material or BIP32-derive the wrong curve. + * + * Do not use for wallet sharing — that must not receive root key material. + * Call only when `isSafeChildPublicOnlyKeychain` is true. + */ +export async function resolveSafeOwnerSigningPrv(params: ResolveSafeOwnerSigningPrvParams): Promise { + const { prv } = await resolveSafeKeyMaterial({ + ...params, + makeNotImplementedError: (slot, walletId) => + slot === 'ed25519' + ? new SafeOwnerSigningNotImplementedError( + walletId, + `ed25519 multisig owner derivation (${params.coinFamily}) is not implemented; BIP32 would produce the wrong child key.` + ) + : new SafeOwnerSigningNotImplementedError( + walletId, + 'TSS owner signing from the root keyshare is not implemented. ' + + 'Returning the root private key would expose material that can derive every child in this slot.' + ), + }); + return prv; +} + +export interface ResolveSafeChildPrvForSharingParams extends SafeKeyMaterialBaseParams { + mpcAlgorithm?: 'ecdsa' | 'eddsa'; +} + +/** Slot-named not-implemented detail for wallet sharing. */ +function safeShareSlotDetail(slot: SafeKeyMaterialSlot, params: ResolveSafeChildPrvForSharingParams): string { + if (slot === 'ed25519') { + return `ed25519 multisig safe sharing (${params.coinFamily}) is not implemented; BIP32 would derive the wrong child.`; + } + return params.mpcAlgorithm === 'eddsa' + ? 'eddsaMpc safe sharing is not implemented (needs the EdDSA derive ceremony).' + : 'ecdsaMpc safe sharing is not implemented (needs the DKLS derive ceremony).'; +} + +/** + * Resolve sharing material for a safe owner (child key has no encryptedPrv). + * + * Onchain secp256k1: decrypt root, hardened-derive at `derivedFromParentWithPath` + * (`m/'`), verify the registered pub, and return the CHILD `{prv, pub}` — never the root. + * TSS and ed25519 onchain: throw `SafeShareNotImplementedError` naming the slot + blocker. + * + * Call only when `isSafeChildPublicOnlyKeychain` is true. + */ +export async function resolveSafeChildPrvForSharing( + params: ResolveSafeChildPrvForSharingParams +): Promise { + return resolveSafeKeyMaterial({ + ...params, + makeNotImplementedError: (slot, walletId) => + new SafeShareNotImplementedError(walletId, safeShareSlotDetail(slot, params)), + }); } diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 9e2ad82d55..ba8e7f971e 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -56,11 +56,13 @@ import { decodeWithCodec } from '../utils/codecs'; import { postWithCodec } from '../utils/postWithCodec'; import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa'; import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa'; +import { RedpallasMPCv2Utils } from '../utils/tss/redpallas'; import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest'; import { buildParamKeys, BuildParams } from './BuildParams'; import { fetchRootKeychainForSafeChild, isSafeChildPublicOnlyKeychain, + resolveSafeChildPrvForSharing, resolveSafeOwnerSigningPrv, } from './safeKeychain'; import { @@ -238,7 +240,13 @@ export class Wallet implements IWallet { public readonly baseCoin: IBaseCoin; public _wallet: WalletData; private _defi?: DefiVault; - private readonly tssUtils: EcdsaUtils | EcdsaMPCv2Utils | EddsaUtils | EddsaMPCv2Utils | undefined; + private readonly tssUtils: + | EcdsaUtils + | EcdsaMPCv2Utils + | EddsaUtils + | EddsaMPCv2Utils + | RedpallasMPCv2Utils + | undefined; private readonly _permissions?: string[]; /** Root keychain from passphrase preflight; consumed by getUserPrv to avoid a second GET. */ private validatedSafeRootKeychain?: KeychainWithEncryptedPrv; @@ -268,6 +276,10 @@ export class Wallet implements IWallet { this.tssUtils = new EddsaUtils(bitgo, baseCoin, this); } break; + case 'redpallas': + // RedPallas (Zcash Orchard shielded pool) is MPCv2-only; there is no MPCv1 variant. + this.tssUtils = new RedpallasMPCv2Utils(bitgo, baseCoin, this); + break; default: this.tssUtils = undefined; } @@ -1760,6 +1772,18 @@ export class Wallet implements IWallet { return tryKeyChain(0); } + private async getSafeOwnerChildKeychain(): Promise<(Keychain & { parent: string }) | undefined> { + if (!this.safeId()) { + return undefined; + } + const userKeyId = this._wallet.keys?.[KeyIndices.USER]; + if (!userKeyId) { + return undefined; + } + const keychain = await this.baseCoin.keychains().get({ id: userKeyId }); + return isSafeChildPublicOnlyKeychain(this.safeId(), keychain) ? keychain : undefined; + } + /** * Gets the unencrypted private key for this wallet (be careful!) * Requires wallet passphrase @@ -1866,11 +1890,8 @@ export class Wallet implements IWallet { try { decryptedKeychain = await this.getDecryptedKeychainForSharing(params.walletPassphrase); } catch (e) { - if (e instanceof MissingEncryptedKeychainError) { - decryptedKeychain = undefined; - } else { - throw e; - } + this.rethrowUnlessColdWalletShare(e); + decryptedKeychain = undefined; } } @@ -1961,6 +1982,28 @@ export class Wallet implements IWallet { async getDecryptedKeychainForSharing( walletPassphrase: string | undefined ): Promise { + /** + * For Safe owners: detect child safes first and derive the child private key from the root keychain if present. + * Skip `lnbtc` as it uses the user auth key instead + */ + if (this.baseCoin.getFamily() !== 'lnbtc') { + const safeChildKeychain = await this.getSafeOwnerChildKeychain(); + if (safeChildKeychain) { + if (!walletPassphrase) { + throw new Error('Missing walletPassphrase argument'); + } + return resolveSafeChildPrvForSharing({ + bitgo: this.bitgo, + keychains: this.baseCoin.keychains(), + walletId: this._wallet.id, + multisigType: this._wallet.multisigType, + coinFamily: this.baseCoin.getFamily(), + childKeychain: safeChildKeychain, + walletPassphrase, + }); + } + } + const keychain = await this.getEncryptedWalletKeychainForWalletSharing(); if (!keychain.encryptedPrv) { @@ -2031,6 +2074,18 @@ export class Wallet implements IWallet { return keychain; } + private rethrowUnlessColdWalletShare(e: unknown): void { + if (!(e instanceof MissingEncryptedKeychainError)) { + throw e; + } + if (this.safeId()) { + throw new MissingEncryptedKeychainError( + `Safe wallet ${this._wallet.id}: no keychain with an encryptedPrv and the safe child ` + + `could not be resolved; refusing to create a spend share without key material.` + ); + } + } + /** * Prepares a keychain for sharing with another user. * Fetches the wallet keychain, decrypts it, and encrypts it for the recipient. @@ -2056,11 +2111,9 @@ export class Wallet implements IWallet { } return await this.encryptPrvForUser(keychain.prv, keychain.pub, pubkey, path, encryptionVersion); } catch (e) { - if (e instanceof MissingEncryptedKeychainError) { - // ignore this error because this looks like a cold wallet - return {}; - } - throw e; + this.rethrowUnlessColdWalletShare(e); + // ignore this error because this looks like a cold wallet + return {}; } } @@ -4829,6 +4882,26 @@ export class Wallet implements IWallet { ); break; } + case 'wrapNative': + case 'unwrapNative': { + // WETH9 amounts are 18dp and exceed Number.MAX_SAFE_INTEGER, so amount is + // decoded as a numeric string and handed on as a string, never a number. + const wrapNativeParams = decodeWithCodec( + t.type({ vaultId: t.string, amount: BigIntFromString }), + params.defiParams, + `${params.type}.defiParams` + ); + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: params.type === 'wrapNative' ? 'wrap-native' : 'unwrap-native', + defiParams: { ...wrapNativeParams, amount: wrapNativeParams.amount.toString() }, + }, + apiVersion, + params.preview + ); + break; + } default: throw new Error(`transaction type not supported: ${params.type}`); } diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index 3338928958..9e9cf0e0e4 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -1,6 +1,7 @@ import sinon from 'sinon'; import assert from 'assert'; import 'should'; +import { CoinFeature } from '@bitgo/statics'; import { VaultProtocol } from '@bitgo/public-types'; import { ActiveOperationExistsError, DefiVault, Wallet } from '../../../../src'; @@ -73,6 +74,7 @@ describe('DefiVault', function () { mockBaseCoin = { getFamily: sinon.stub().returns('eth'), + getConfig: sinon.stub().returns({ features: [CoinFeature.EVM_COIN] }), url: sinon.stub(), keychains: sinon.stub(), supportsTss: sinon.stub().returns(true), @@ -717,6 +719,216 @@ describe('DefiVault', function () { }); }); + // wrap and unwrap are the same orchestrator with a different sendMany type, so + // the suite is generated over both to keep the two from drifting apart. + ( + [ + ['wrap', 'wrapNative'], + ['unwrap', 'unwrapNative'], + ] as const + ).forEach(function ([method, sendManyType]) { + describe(method, function () { + it(`should call sendMany once with ${sendManyType} type and return txRequestId`, async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: `txreq-${method}-1` } }); + + const result = await defiVault[method]({ + vaultId: 'vlt-galaxy-weth', + amount: '1000000000000000000', + }); + + result.txRequestId.should.equal(`txreq-${method}-1`); + + sendManyStub.calledOnce.should.be.true(); + const args: any = sendManyStub.firstCall.args[0]; + args.type.should.equal(sendManyType); + args.defiParams.should.deepEqual({ + vaultId: 'vlt-galaxy-weth', + amount: '1000000000000000000', + }); + }); + + it('should extract txRequestId from the lite sendMany response shape', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequestId: `txreq-${method}-lite` }); + + const result = await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }); + + result.txRequestId.should.equal(`txreq-${method}-lite`); + }); + + it('should throw when txRequestId is absent from the sendMany response', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: {} }); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }), { + message: 'txRequestId not found in sendMany response', + }); + }); + + it('should leave operationId undefined in v1', async function () { + // No operation is minted for wrap/unwrap until M5. Assert it stays absent + // even when the response happens to carry one, so nobody "fixes" this by + // wiring up extractOperationId. + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: `txreq-${method}-noop`, + transactions: [{ unsignedTx: { coinSpecific: { operationId: 'op-should-be-ignored' } } }], + }, + }); + + const result = await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }); + + assert.strictEqual(result.operationId, undefined); + }); + + it('should forward walletPassphrase when provided', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: `txreq-${method}-hot` } }); + + await defiVault[method]({ + vaultId: 'vlt-galaxy-weth', + amount: '5000', + walletPassphrase: 'test-passphrase', + }); + + const args: any = sendManyStub.firstCall.args[0]; + args.walletPassphrase.should.equal('test-passphrase'); + }); + + it('should omit walletPassphrase entirely when absent (custody path)', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: `txreq-${method}-custody` } }); + + await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }); + + const args: any = sendManyStub.firstCall.args[0]; + args.should.not.have.property('walletPassphrase'); + }); + + it('should throw if vaultId is missing, without any network call', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: '', amount: '5000' }), { + message: 'vaultId is required', + }); + sendManyStub.called.should.be.false(); + }); + + it('should throw if amount is missing, without any network call', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '' }), { + message: 'amount must be a positive unsigned decimal integer string', + }); + sendManyStub.called.should.be.false(); + }); + + describe('amount boundary validation', function () { + // The downstream BigIntFromString codec accepts all of these via JS's BigInt() constructor, + // so the client-side guard is the only thing standing between a malformed/malicious amount + // and a value-moving WETH9 deposit()/withdraw() call. + const rejectedAmounts = [ + ['-1', 'negative'], + ['0', 'zero'], + ['0xabc', 'hexadecimal'], + ['1.5', 'decimal point'], + ['1e18', 'scientific notation'], + ['+100', 'explicit sign'], + [' 100', 'leading whitespace'], + ['100 ', 'trailing whitespace'], + ['abc', 'non-numeric'], + ] as const; + rejectedAmounts.forEach(([amount, why]) => { + it(`should reject a ${why} amount (${JSON.stringify(amount)}), without any network call`, async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount }), { + message: 'amount must be a positive unsigned decimal integer string', + }); + sendManyStub.called.should.be.false(); + }); + }); + + ['1', '1000000000000000000', '007'].forEach((amount) => { + it(`should accept amount ${JSON.stringify(amount)} and forward it verbatim`, async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: 'txreq-boundary' } }); + + await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount }); + + const args: any = sendManyStub.firstCall.args[0]; + args.defiParams.amount.should.equal(amount); + }); + }); + }); + + describe('vaultId trimming', function () { + it('should throw for a whitespace-only vaultId, without any network call', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: ' ', amount: '5000' }), { + message: 'vaultId is required', + }); + sendManyStub.called.should.be.false(); + }); + + it('should trim surrounding whitespace from vaultId before calling sendMany', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: 'txreq-trim' } }); + + await defiVault[method]({ vaultId: ' vlt-galaxy-weth ', amount: '5000' }); + + const args: any = sendManyStub.firstCall.args[0]; + args.defiParams.vaultId.should.equal('vlt-galaxy-weth'); + }); + }); + + it('should throw a clear client-side error for a non-EVM coin, without any network call', async function () { + mockBaseCoin.getFamily.returns('btc'); + mockBaseCoin.getConfig.returns({ features: [] }); + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }), { + message: 'wrap/unwrap is not supported for btc wallets', + }); + sendManyStub.called.should.be.false(); + }); + + describe(`prebuildTransactionTxRequests ${sendManyType} defiParams validation`, function () { + it('should throw when defiParams is missing', async function () { + await assert.rejects( + () => (wallet as any).prebuildTransactionTxRequests({ type: sendManyType }), + new RegExp(`${sendManyType}\\.defiParams`) + ); + }); + + it('should throw when vaultId is not a string', async function () { + await assert.rejects( + () => + (wallet as any).prebuildTransactionTxRequests({ + type: sendManyType, + defiParams: { vaultId: 123, amount: '5000' }, + }), + new RegExp(`${sendManyType}\\.defiParams`) + ); + }); + + it('should throw when amount is not a numeric string', async function () { + await assert.rejects( + () => + (wallet as any).prebuildTransactionTxRequests({ + type: sendManyType, + defiParams: { vaultId: 'vlt-1', amount: 5000 }, + }), + new RegExp(`${sendManyType}\\.defiParams`) + ); + }); + }); + }); + }); + describe('wallet.defi getter', function () { it('should return a DefiVault instance', function () { const defi = wallet.defi; diff --git a/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts b/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts index 120ba51def..7b4f7959c4 100644 --- a/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/test/unit/bitgo/keychain/keychains.ts @@ -43,6 +43,25 @@ describe('Keychains.createBackup', function () { keychains = buildKeychains(); }); + describe('safe child key registration', function () { + it('serializes the hardened derivation path', async function () { + await keychains.add({ + pub: XPUB, + source: 'user', + keyType: 'independent', + parent: 'user-root-id', + safeId: SAFE_ID, + derivedFromParentWithPath: "m/7'", + }); + + const derivedPath = sentBody().derivedFromParentWithPath; + if (typeof derivedPath !== 'string') { + throw new Error('expected derivedFromParentWithPath to be serialized'); + } + derivedPath.should.equal("m/7'"); + }); + }); + describe('safe ed25519Multisig root (slot ④)', function () { it('posts a 108-char composite pub built from the generated key', async function () { await keychains.createBackup({ passphrase: 'pw', safeId: SAFE_ID }); diff --git a/modules/sdk-core/test/unit/bitgo/safe/safe.ts b/modules/sdk-core/test/unit/bitgo/safe/safe.ts index bf7ca46cd6..94aedde4c3 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safe.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safe.ts @@ -192,7 +192,7 @@ describe('Safe', function () { keyType: 'independent', parent: 'user-root-id', safeId: 'test-safe-id', - derivedFromParentWithHardenedPath: "m/0'", + derivedFromParentWithPath: "m/0'", }); addArgs.should.not.have.property('encryptedPrv'); addArgs.should.not.have.property('derivedFromParentWithSeed'); @@ -214,7 +214,7 @@ describe('Safe', function () { mintedSafeId.should.equal('test-safe-id'); }); - it('registers the child with derivedFromParentWithHardenedPath at a non-zero mint index', async function () { + it('registers the child with derivedFromParentWithPath at a non-zero mint index', async function () { derivationQuery.returns({ result: sinon.stub().resolves({ slot: 'secp256k1Multisig', index: 7 }), }); @@ -224,7 +224,7 @@ describe('Safe', function () { const addArgs = keychainsAdd.firstCall.args[0]; addArgs.pub.should.equal(childAt7.pub); - addArgs.derivedFromParentWithHardenedPath.should.equal("m/7'"); + addArgs.derivedFromParentWithPath.should.equal("m/7'"); addArgs.should.not.have.property('path'); addArgs.should.not.have.property('derivedFromParentWithSeed'); }); diff --git a/modules/sdk-core/test/unit/bitgo/tss/common.ts b/modules/sdk-core/test/unit/bitgo/tss/common.ts new file mode 100644 index 0000000000..169523f269 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/tss/common.ts @@ -0,0 +1,63 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { sendSignatureShareV2 } from '../../../../src/bitgo/tss/common'; +import { BitGoBase, RequestType, TxRequest, MPCAlgorithm } from '../../../../src'; + +describe('sendSignatureShareV2 request type dispatch', function () { + afterEach(function () { + sinon.restore(); + }); + + async function captureRequestType( + mpcAlgorithm: MPCAlgorithm, + multisigTypeVersion: 'MPCv2' | undefined + ): Promise { + let capturedBody: { type: string } | undefined; + const send = sinon.stub().callsFake((body) => { + capturedBody = body; + return { result: sinon.stub().resolves({} as TxRequest) }; + }); + const mockBitGo = { + url: sinon.stub().returns('/mock/url'), + post: sinon.stub().returns({ send }), + setRequestTracer: sinon.stub(), + } as unknown as BitGoBase; + + await sendSignatureShareV2( + mockBitGo, + 'walletId', + 'txRequestId', + [], + RequestType.tx, + mpcAlgorithm, + 'signerGpgPublicKey', + undefined, + multisigTypeVersion + ); + + if (!capturedBody) { + throw new Error('request body should have been captured'); + } + return capturedBody.type; + } + + it('resolves ecdsaMpcV2 for MPCv2 + ecdsa', async function () { + assert.strictEqual(await captureRequestType('ecdsa', 'MPCv2'), 'ecdsaMpcV2'); + }); + + it('resolves eddsaMpcV2 for MPCv2 + eddsa', async function () { + assert.strictEqual(await captureRequestType('eddsa', 'MPCv2'), 'eddsaMpcV2'); + }); + + it('resolves redpallasMpcV2 for MPCv2 + redpallas', async function () { + assert.strictEqual(await captureRequestType('redpallas', 'MPCv2'), 'redpallasMpcV2'); + }); + + it('resolves eddsaMpcV1 for undefined multisigTypeVersion + eddsa', async function () { + assert.strictEqual(await captureRequestType('eddsa', undefined), 'eddsaMpcV1'); + }); + + it('resolves an empty type for redpallas without MPCv2 (no MPCv1 variant exists)', async function () { + assert.strictEqual(await captureRequestType('redpallas', undefined), ''); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/tss/redpallas/redpallasMPCv2.ts b/modules/sdk-core/test/unit/bitgo/tss/redpallas/redpallasMPCv2.ts new file mode 100644 index 0000000000..6e4fdc69c2 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/tss/redpallas/redpallasMPCv2.ts @@ -0,0 +1,229 @@ +import * as assert from 'assert'; +import * as pgp from 'openpgp'; +import { RedPallasMPSComms, RedPallasMPSTypes } from '@bitgo/sdk-lib-mpc'; +import { + RedpallasMPCv2SignatureShareRound1Input, + RedpallasMPCv2SignatureShareRound1Output, + RedpallasMPCv2SignatureShareRound2Input, + RedpallasMPCv2SignatureShareRound2Output, + RedpallasMPCv2SignatureShareRound3Input, + RedpallasMPCv2SignatureShareRound3Output, +} from '@bitgo/public-types'; +import { SignatureShareRecord, SignatureShareType } from '../../../../../src'; +import { + getSignatureShareRoundOne, + getSignatureShareRoundTwo, + getSignatureShareRoundThree, + verifyPeerMessageRoundOne, + verifyPeerMessageRoundTwo, + verifyPeerMessageRoundThree, +} from '../../../../../src/bitgo/tss/redpallas/redpallasMPCv2'; +import { decodeWithCodec } from '../../../../../src/bitgo/utils/codecs'; +import { generateGPGKeyPair } from '../../../../../src/bitgo/utils/opengpgUtils'; +import { MPCv2PartiesEnum } from '../../../../../src/bitgo/utils/tss/ecdsa/typesMPCv2'; + +describe('RedPallas MPS DSG helper functions', function () { + let userGpgPrivKey: pgp.PrivateKey; + let backupGpgPrivKey: pgp.PrivateKey; + let bitgoGpgPrivKey: pgp.PrivateKey; + let bitgoGpgPubKey: pgp.Key; + + const userPayload = (round: number): RedPallasMPSTypes.DeserializedMessage => ({ + from: MPCv2PartiesEnum.USER, + payload: new Uint8Array(Buffer.from(`user-round-${round}-payload`)), + }); + const backupPayload = (round: number): RedPallasMPSTypes.DeserializedMessage => ({ + from: MPCv2PartiesEnum.BACKUP, + payload: new Uint8Array(Buffer.from(`backup-round-${round}-payload`)), + }); + const bitgoPayload = (round: number): RedPallasMPSTypes.DeserializedMessage => ({ + from: MPCv2PartiesEnum.BITGO, + payload: new Uint8Array(Buffer.from(`bitgo-round-${round}-payload`)), + }); + + before('generate GPG key pairs', async function () { + const userGpgKeyPair = await generateGPGKeyPair('ed25519'); + const backupGpgKeyPair = await generateGPGKeyPair('ed25519'); + const bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + + userGpgPrivKey = await pgp.readPrivateKey({ armoredKey: userGpgKeyPair.privateKey }); + backupGpgPrivKey = await pgp.readPrivateKey({ armoredKey: backupGpgKeyPair.privateKey }); + bitgoGpgPrivKey = await pgp.readPrivateKey({ armoredKey: bitgoGpgKeyPair.privateKey }); + bitgoGpgPubKey = await pgp.readKey({ armoredKey: bitgoGpgKeyPair.publicKey }); + }); + + // ── Round 1 ───────────────────────────────────────────────────────────────── + + it('getSignatureShareRoundOne should build a valid round-1 share for the user', async function () { + const share: SignatureShareRecord = await getSignatureShareRoundOne(userPayload(1), userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + RedpallasMPCv2SignatureShareRound1Input, + JSON.parse(share.share), + 'RedpallasMPCv2SignatureShareRound1Input' + ); + assert.strictEqual(parsed.type, 'round1Input'); + assert.ok(parsed.data.msg1.message, 'msg1.message should be set'); + assert.ok(parsed.data.msg1.signature, 'msg1.signature should be set'); + }); + + it('getSignatureShareRoundOne should build a valid round-1 share for the backup', async function () { + const share: SignatureShareRecord = await getSignatureShareRoundOne( + backupPayload(1), + backupGpgPrivKey, + MPCv2PartiesEnum.BACKUP + ); + + assert.strictEqual(share.from, SignatureShareType.BACKUP); + assert.strictEqual(share.to, SignatureShareType.BITGO); + }); + + it('verifyPeerMessageRoundOne should verify a valid BitGo round-1 message', async function () { + const bitgoSignedMsg1 = await RedPallasMPSComms.detachSignMpsMessage( + Buffer.from(bitgoPayload(1).payload), + bitgoGpgPrivKey + ); + const round1Output: RedpallasMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { msg1: bitgoSignedMsg1 }, + }; + + const result = await verifyPeerMessageRoundOne(round1Output, bitgoGpgPubKey); + + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(1).payload)); + }); + + it('verifyPeerMessageRoundOne should throw on a tampered round-1 message', async function () { + const round1Output: RedpallasMPCv2SignatureShareRound1Output = { + type: 'round1Output', + data: { + msg1: { + message: Buffer.from('tampered').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', + }, + }, + }; + + await assert.rejects(verifyPeerMessageRoundOne(round1Output, bitgoGpgPubKey)); + }); + + // ── Round 2 ───────────────────────────────────────────────────────────────── + + it('getSignatureShareRoundTwo should build a valid round-2 share for the user', async function () { + const share: SignatureShareRecord = await getSignatureShareRoundTwo(userPayload(2), userGpgPrivKey); + + assert.strictEqual(share.from, SignatureShareType.USER); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + RedpallasMPCv2SignatureShareRound2Input, + JSON.parse(share.share), + 'RedpallasMPCv2SignatureShareRound2Input' + ); + assert.strictEqual(parsed.type, 'round2Input'); + assert.ok(parsed.data.msg2.message, 'msg2.message should be set'); + assert.ok(parsed.data.msg2.signature, 'msg2.signature should be set'); + }); + + it('verifyPeerMessageRoundTwo should verify a valid BitGo round-2 message', async function () { + const bitgoSignedMsg2 = await RedPallasMPSComms.detachSignMpsMessage( + Buffer.from(bitgoPayload(2).payload), + bitgoGpgPrivKey + ); + const round2Output: RedpallasMPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { msg2: bitgoSignedMsg2 }, + }; + + const result = await verifyPeerMessageRoundTwo(round2Output, bitgoGpgPubKey); + + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(2).payload)); + }); + + it('verifyPeerMessageRoundTwo should throw on a tampered round-2 message', async function () { + const round2Output: RedpallasMPCv2SignatureShareRound2Output = { + type: 'round2Output', + data: { + msg2: { + message: Buffer.from('tampered').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', + }, + }, + }; + + await assert.rejects(verifyPeerMessageRoundTwo(round2Output, bitgoGpgPubKey)); + }); + + // ── Round 3 ───────────────────────────────────────────────────────────────── + + it('getSignatureShareRoundThree should build a valid round-3 share for the backup', async function () { + const share: SignatureShareRecord = await getSignatureShareRoundThree( + backupPayload(3), + backupGpgPrivKey, + MPCv2PartiesEnum.BACKUP + ); + + assert.strictEqual(share.from, SignatureShareType.BACKUP); + assert.strictEqual(share.to, SignatureShareType.BITGO); + + const parsed = decodeWithCodec( + RedpallasMPCv2SignatureShareRound3Input, + JSON.parse(share.share), + 'RedpallasMPCv2SignatureShareRound3Input' + ); + assert.strictEqual(parsed.type, 'round3Input'); + assert.ok(parsed.data.msg3.message, 'msg3.message should be set'); + assert.ok(parsed.data.msg3.signature, 'msg3.signature should be set'); + }); + + it('verifyPeerMessageRoundThree should verify a valid BitGo round-3 message', async function () { + const bitgoSignedMsg3 = await RedPallasMPSComms.detachSignMpsMessage( + Buffer.from(bitgoPayload(3).payload), + bitgoGpgPrivKey + ); + const round3Output: RedpallasMPCv2SignatureShareRound3Output = { + type: 'round3Output', + data: { msg3: bitgoSignedMsg3 }, + }; + + const result = await verifyPeerMessageRoundThree(round3Output, bitgoGpgPubKey); + + assert.strictEqual(result.from, MPCv2PartiesEnum.BITGO); + assert.deepStrictEqual(Buffer.from(result.payload), Buffer.from(bitgoPayload(3).payload)); + }); + + it('verifyPeerMessageRoundThree should throw on a tampered round-3 message', async function () { + const round3Output: RedpallasMPCv2SignatureShareRound3Output = { + type: 'round3Output', + data: { + msg3: { + message: Buffer.from('tampered').toString('base64'), + signature: '-----BEGIN PGP SIGNATURE-----\n\nINVALID\n-----END PGP SIGNATURE-----\n', + }, + }, + }; + + await assert.rejects(verifyPeerMessageRoundThree(round3Output, bitgoGpgPubKey)); + }); + + // ── Envelope round-trip via the independent RedPallas MPS comms layer ─────── + + it('a share built by getSignatureShareRoundOne verifies against RedPallasMPSComms directly', async function () { + const share = await getSignatureShareRoundOne(userPayload(1), userGpgPrivKey); + const parsed = decodeWithCodec( + RedpallasMPCv2SignatureShareRound1Input, + JSON.parse(share.share), + 'RedpallasMPCv2SignatureShareRound1Input' + ); + + const userGpgPubKey = userGpgPrivKey.toPublic(); + const rawBytes = await RedPallasMPSComms.verifyMpsMessage(parsed.data.msg1, userGpgPubKey); + + assert.deepStrictEqual(rawBytes, Buffer.from(userPayload(1).payload)); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index cca0a733a4..4068a06452 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -748,6 +748,86 @@ describe('ECDSA MPC v2', async () => { ); }); + // Regression test for CGD-1815 / DEFI-661: resolveEffectiveTxParams() special-cases wrap-native + // and unwrap-native so the no-recipients guard in verifyTssTransaction doesn't reject them. That + // bypass is security-sensitive (it's what lets a DeFi vault wrap/unwrap tx skip recipient + // verification), so pin it through the real signRequestBase() -> resolveEffectiveTxParams() -> + // verifyTransaction() wiring instead of only unit-testing resolveEffectiveTxParams() in isolation. + // pendingApproval.approve() -> recreateTxRequest() -> signTxRequest() carries no txParams at all, + // so intentType is the ONLY source of the type here — exactly the path that only ever sees the + // kebab-case spelling ('wrap-native' / 'unwrap-native'), never the camelCase 'wrapNative'/'unwrapNative'. + ['wrap-native', 'unwrap-native'].forEach((intentType) => { + it(`signRequestBase should verify a no-txParams ${intentType} intent without requiring recipients`, async () => { + const serializedTxHex = 'f86c808504a817c80082520894' + '00'.repeat(20) + '80808080'; + const signableHex = serializedTxHex; + const derivationPath = 'm/0'; + + const mockBgWithPost = {} as BitGoBase; + mockBgWithPost.getEnv = sinon.stub().returns('test'); + mockBgWithPost.setRequestTracer = sinon.stub(); + mockBgWithPost.encrypt = sinon.stub().resolves('encrypted'); + mockBgWithPost.decrypt = sinon.stub().resolves('decrypted'); + mockBgWithPost.post = sinon.stub().returns({ + send: sinon.stub().returnsThis(), + set: sinon.stub().returnsThis(), + result: sinon.stub().rejects(new Error('mock: HTTP not available')), + }); + + const verifyTransactionSpy = sinon.stub().resolves(true); + const mockCoinForWrap = { + getHashFunction: sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash), + verifyTransaction: verifyTransactionSpy, + getMPCAlgorithm: sinon.stub().returns('ecdsa'), + getConfig: sinon.stub().returns({ family: 'hteth' }), + } as unknown as IBaseCoin; + + const mockWallet = { + id: sinon.stub().returns(walletID), + multisigType: sinon.stub().returns('tss'), + multisigTypeVersion: sinon.stub().returns('MPCv2'), + }; + + const wrapUtils = new EcdsaMPCv2Utils(mockBgWithPost, mockCoinForWrap, mockWallet as any); + sinon.stub(wrapUtils as any, 'pickBitgoPubGpgKeyForSigning').resolves(bitgoGpgKey.public); + + // No recipients anywhere (neither txParams.recipients nor intent.recipients) — the wrap-native/ + // unwrap-native calldata is built server-side from defiParams, per DefiVault.sendWrapIntent. + const txRequest = { + txRequestId: `wrap-native-test-${intentType}`, + apiVersion: 'full', + walletId: walletID, + intent: { intentType, defiParams: { vaultId: 'vlt-galaxy-weth', amount: '1000000000000000000' } }, + transactions: [ + { + unsignedTx: { derivationPath, signableHex, serializedTxHex }, + signatureShares: [], + }, + ], + } as unknown as TxRequest; + + try { + await wrapUtils.signTxRequest({ + txRequest, + // No txParams at all — mirrors pendingApproval.approve() -> recreateTxRequest() -> + // signTxRequest(), the one signing path that only ever sees the kebab-case intentType. + txParams: undefined, + prv: userShare.toString('base64'), + reqId: { inc: sinon.stub(), toString: sinon.stub().returns('test-req') } as any, + }); + } catch (e) {} + + assert.strictEqual( + verifyTransactionSpy.callCount, + 1, + 'verifyTransaction must be reached: resolveEffectiveTxParams must not throw for a no-recipients ' + + `${intentType} intent` + ); + const verifyCallArgs = verifyTransactionSpy.firstCall.args[0]; + assert.strictEqual(verifyCallArgs.txParams.type, intentType); + assert.strictEqual(verifyCallArgs.txParams.recipients, undefined); + }); + }); + it('should still apply keccak256 for regular FLR EVM transactions', async () => { // Regular EVM transaction on FLR (e.g. token transfer, not cross-chain). // serializedTxHex starts with 'f8' (RLP prefix), NOT '0000'. diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts index 7625c2f72c..d0e91ac858 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -30,6 +30,11 @@ describe('recipientUtils', function () { 'defiApprove', 'defiDeposit', 'defiWithdraw', + // Native wrap/unwrap — registered in both spellings on purpose + 'wrapNative', + 'wrap-native', + 'unwrapNative', + 'unwrap-native', 'wrapApprove', 'wrap', 'contractCall', @@ -157,6 +162,36 @@ describe('recipientUtils', function () { } }); + describe('native wrap/unwrap intents', function () { + // Regression test for the camelCase/kebab-case asymmetry. This set is matched + // against BOTH txParams.type (buildParams.type — camelCase, from wallet.sendMany) + // and intent.intentType (kebab-case, as WP persists it). Signing paths that carry + // no txParams — pendingApproval.approve() → recreateTxRequest() → signTxRequest() + // — only ever see the kebab-case spelling, so both must be registered. + it('does not throw when the type comes from buildParams.type (camelCase)', function () { + for (const txType of ['wrapNative', 'unwrapNative']) { + const txRequest = makeTxRequest(); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType })); + } + }); + + it('does not throw when the type comes only from intent.intentType (kebab-case)', function () { + for (const intentType of ['wrap-native', 'unwrap-native']) { + const txRequest = makeTxRequest({ intent: { intentType } as any }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.type, intentType); + assert.strictEqual(result.recipients, undefined); + } + }); + + it('does not throw when txParams is undefined entirely (pendingApproval re-sign path)', function () { + for (const intentType of ['wrap-native', 'unwrap-native']) { + const txRequest = makeTxRequest({ intent: { intentType, vaultId: 'vlt-weth-1', amount: '10' } as any }); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, undefined)); + } + }); + }); + it('does not throw when buildParams.type is PascalCase but intent.intentType is lowercase', function () { // signTransactionTss passes txPrebuild.buildParams as txParams. Prebuild uses // type: 'Import' while WP stores intentType: 'import' on the txRequest. diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts index f84d51857a..17338f3a63 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeGetUserPrv.ts @@ -121,7 +121,7 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { hardened.prv.should.not.eql(softDerivedPrv); }); - it('parses derivedFromParentWithHardenedPath as m/ primed', function () { + it('parses the hardened derivation path as m/ primed', function () { parseDerivedFromParentWithHardenedPath("m/0'").should.eql(0); parseDerivedFromParentWithHardenedPath("m/123'").should.eql(123); (() => parseDerivedFromParentWithHardenedPath('m/0')).should.throw(/derivedFromParentWithHardenedPath/); @@ -256,7 +256,7 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { pub: hardened.pub, type: 'independent', parent: rootKeyId, - derivedFromParentWithHardenedPath: "m/123'", + derivedFromParentWithPath: "m/123'", }, walletPassphrase: passphrase, }); @@ -267,7 +267,7 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { mockBaseCoin.deriveKeyWithSeed.notCalled.should.be.true(); }); - it('requires derivedFromParentWithHardenedPath on the child keychain', async function () { + it('requires derivedFromParentWithPath on the child keychain', async function () { const wallet = makeWallet({ safe: 'safe-id-1' }); keychainsGetStub.resolves({ id: rootKeyId, @@ -287,10 +287,10 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { }, walletPassphrase: passphrase, }) - .should.be.rejectedWith(/missing derivedFromParentWithHardenedPath/); + .should.be.rejectedWith(/missing derivedFromParentWithPath/); }); - it('fails closed when derivedFromParentWithHardenedPath does not match the registered pub', async function () { + it('fails closed when derivedFromParentWithPath does not match the registered pub', async function () { const wallet = makeWallet({ safe: 'safe-id-1' }); keychainsGetStub.resolves({ id: rootKeyId, @@ -307,14 +307,14 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { pub: hardened.pub, type: 'independent', parent: rootKeyId, - derivedFromParentWithHardenedPath: "m/0'", + derivedFromParentWithPath: "m/0'", }, walletPassphrase: passphrase, }) .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); }); - it('rejects a malformed derivedFromParentWithHardenedPath', async function () { + it('rejects a malformed derivedFromParentWithPath', async function () { const wallet = makeWallet({ safe: 'safe-id-1' }); keychainsGetStub.resolves({ id: rootKeyId, @@ -331,7 +331,7 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { pub: hardened.pub, type: 'independent', parent: rootKeyId, - derivedFromParentWithHardenedPath: "not-a-path'", + derivedFromParentWithPath: "not-a-path'", }, walletPassphrase: passphrase, }) @@ -395,7 +395,7 @@ describe('WCN-1200 safe child getUserPrv root-fetch detour', function () { pub: 'xpub-wrong-registered-key', type: 'independent', parent: rootKeyId, - derivedFromParentWithHardenedPath: "m/123'", + derivedFromParentWithPath: "m/123'", }, walletPassphrase: passphrase, }) diff --git a/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts new file mode 100644 index 0000000000..098dd22c55 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/safeShareWallet.ts @@ -0,0 +1,324 @@ +/** + * @prettier + * + * Safe-wallet SPEND sharing. Mirrors test/unit/bitgo/wallet/safeGetUserPrv.ts (sinon-stubbed, + * no nock) for the sharing analogue: a safe owner's children are pub-only, so the share path + * must re-derive the child prv from the root — never the root material. + */ +import 'should'; +import * as sinon from 'sinon'; +import { + IncorrectPasswordError, + SafeDerivedPublicKeyMismatchError, + SafeShareNotImplementedError, + Wallet, + deriveSafeChildHardenedFromXprv, +} from '../../../../src'; +import { BaseCoin } from '../../../../src/bitgo/baseCoin'; +import { getSharedSecret } from '../../../../src/bitgo/ecdh'; +import { makeRandomKey } from '../../../../src/bitgo/bitcoin'; + +require('should-sinon'); + +describe('Safe wallet spend sharing', function () { + const prv = + 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; + const hardened = deriveSafeChildHardenedFromXprv(prv, '123'); + const passphrase = 'test-passphrase'; + const rootKeyId = 'root-key-id'; + const SHAREE_PUB = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + + let mockBitGo: any; + let mockBaseCoin: any; + let keychainsGetStub: sinon.SinonStub; + let encryptStub: sinon.SinonStub; + let decryptStub: sinon.SinonStub; + let createShareStub: sinon.SinonStub; + let createBulkKeySharesStub: sinon.SinonStub; + + const baseWalletData = { + id: 'wallet-id', + coin: 'tbtc', + keys: ['user-key', 'backup-key', 'bitgo-key'], + type: 'hot', + multisigType: 'onchain', + enterprise: 'ent-id', + }; + + const childKeychain = (id: string) => ({ + id, + pub: hardened.pub, + type: 'independent' as const, + parent: rootKeyId, + derivedFromParentWithPath: "m/123'", + }); + const publicOnlyKeychain = (id: string) => ({ id, pub: 'pub-' + id, type: 'independent' as const }); + const rootKeychain = { + id: rootKeyId, + source: 'user' as const, + encryptedPrv: `enc:${prv}`, + type: 'independent' as const, + pub: 'root-pub', + }; + + beforeEach(function () { + keychainsGetStub = sinon.stub(); + encryptStub = sinon.stub(); + decryptStub = sinon.stub(); + + mockBitGo = { + encrypt: encryptStub, + decrypt: decryptStub, + url: sinon.stub().returns('https://test.bitgo.com/'), + setRequestTracer: sinon.stub(), + getSharingKey: sinon.stub().resolves({ userId: 'sharee-id', pubkey: SHAREE_PUB, path: 'm/0' }), + }; + + mockBaseCoin = { + getChain: sinon.stub().returns('tbtc'), + getFamily: sinon.stub().returns('btc'), + getFullName: sinon.stub().returns('Test Bitcoin'), + keychains: sinon.stub().returns({ + get: keychainsGetStub, + getKeysForSigning: sinon.stub().resolves([]), + }), + deriveKeyWithSeed: sinon.stub(), + url: sinon.stub().callsFake((path: string) => `https://test.bitgo.com/api/v2/tbtc${path}`), + supportsStaking: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(false), + getMPCAlgorithm: sinon.stub(), + keyIdsForSigning: sinon.stub().returns([0, 1, 2]), + }; + + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === rootKeyId ? rootKeychain : id === 'user-key' ? childKeychain(id) : publicOnlyKeychain(id)) + ); + + // Reversible encrypt: `shared::` so tests can verify the ECDH secret and + // that the payload encrypts the CHILD prv, not the root. + encryptStub.callsFake(({ input, password }: { input: string; password: string }) => + Promise.resolve(`shared:${password}:${input}`) + ); + + decryptStub.callsFake(({ input, password }: { input: string; password: string }) => { + if (password !== passphrase) return null; + if (typeof input === 'string' && input.startsWith('enc:')) return input.slice(4); + return null; + }); + + createShareStub = sinon.stub(Wallet.prototype, 'createShare').resolves({}); + createBulkKeySharesStub = sinon.stub(Wallet.prototype, 'createBulkKeyShares').resolves({ shares: [] }); + }); + + afterEach(function () { + sinon.restore(); + }); + + function makeWallet(overrides: Record = {}): Wallet { + return new Wallet(mockBitGo, mockBaseCoin as unknown as BaseCoin, { + ...baseWalletData, + ...overrides, + }); + } + + async function getShareOptions(wallet: Wallet, params: Record = {}) { + await wallet.shareWallet({ + email: 'shareto@test.com', + permissions: 'spend', + walletPassphrase: passphrase, + ...params, + }); + return createShareStub.firstCall.args[0]; + } + + describe('hot safe wallet, spend share', function () { + it('derives child material, uses the registered child pub, and never ships root material', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + const options = await getShareOptions(wallet); + + options.skipKeychain.should.equal(false); + options.keychain.should.be.ok(); + options.keychain.pub.should.equal(hardened.pub); + + const json = JSON.stringify(options.keychain); + json.should.not.containEql(prv); // root xprv never leaves + json.should.not.containEql('root-pub'); // root pub never leaves + }); + + it('the encrypted prv ECDH-decrypts (sharee side) to the CHILD prv', async function () { + const shareeKey = makeRandomKey(); + const shareePub = shareeKey.publicKey.toString('hex'); + mockBitGo.getSharingKey = sinon.stub().resolves({ userId: 'sharee-id', pubkey: shareePub, path: 'm/0' }); + + const wallet = makeWallet({ safe: 'safe-id-1' }); + const options = await getShareOptions(wallet); + + const shareeSecret = getSharedSecret(shareeKey, Buffer.from(options.keychain.fromPubKey, 'hex')).toString('hex'); + options.keychain.encryptedPrv.should.equal(`shared:${shareeSecret}:${hardened.prv}`); + }); + + it('a view-only share needs no keychain and does not fetch the root', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet.shareWallet({ email: 'shareto@test.com', permissions: 'view', walletPassphrase: passphrase }); + + createShareStub.firstCall.args[0].skipKeychain.should.equal(true); + createShareStub.firstCall.args[0].should.have.property('keychain', undefined); + keychainsGetStub.notCalled.should.equal(true); + }); + + it('a wrong passphrase rejects with IncorrectPasswordError and posts nothing', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: 'wrong-passphrase' }) + .should.be.rejectedWith(IncorrectPasswordError); + createShareStub.notCalled.should.equal(true); + }); + + it('a missing passphrase throws instead of silently skipKeychain', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend' }) + .should.be.rejectedWith(/Missing walletPassphrase argument/); + createShareStub.notCalled.should.equal(true); + }); + + it('fails closed when the derived pub does not match the registered child pub', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve( + id === rootKeyId + ? rootKeychain + : id === 'user-key' + ? { ...childKeychain(id), pub: 'wrong-child-pub', derivedFromParentWithPath: "m/123'" } + : publicOnlyKeychain(id) + ) + ); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(SafeDerivedPublicKeyMismatchError); + }); + }); + + describe('unsupported safe slots', function () { + it('TSS safe wallet throws SafeShareNotImplementedError without fetching the root', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(SafeShareNotImplementedError); + // child fetched (safe branch), root never fetched (guard fires first) + keychainsGetStub.calledOnce.should.equal(true); + keychainsGetStub.firstCall.args[0].should.deepEqual({ id: 'user-key' }); + }); + + it('ed25519 onchain safe wallet throws SafeShareNotImplementedError', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'txlm' }); + mockBaseCoin.getFamily.returns('xlm'); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(SafeShareNotImplementedError); + keychainsGetStub.calledOnce.should.equal(true); + }); + }); + + describe('regression: non-safe and sharee paths', function () { + it('a genuine cold wallet still yields skipKeychain', async function () { + const wallet = makeWallet({ type: 'cold' }); + const options = await getShareOptions(wallet); + options.skipKeychain.should.equal(true); + options.should.have.property('keychain', undefined); + }); + + it('a sharee re-sharing (child has encryptedPrv) takes the ordinary path and never fetches the root', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + const shareeChild = { + id: 'user-key', + pub: 'sharee-pub', + type: 'independent' as const, + encryptedPrv: `enc:sharee-prv`, + }; + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? shareeChild : publicOnlyKeychain(id)) + ); + const options = await getShareOptions(wallet); + options.skipKeychain.should.equal(false); + options.keychain.pub.should.equal('sharee-pub'); + // root (safe-owner detour) must never be reached for a sharee with an encrypted child prv + keychainsGetStub + .getCalls() + .filter((c) => c.args?.[0]?.id === rootKeyId) + .length.should.equal(0); + }); + + it('a malformed safe wallet (child with no parent) fails closed instead of cold-skipping', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? publicOnlyKeychain(id) : publicOnlyKeychain(id)) + ); + await wallet + .shareWallet({ email: 'shareto@test.com', permissions: 'spend', walletPassphrase: passphrase }) + .should.be.rejectedWith(/safe child could not be resolved/); + createShareStub.notCalled.should.equal(true); + }); + + it('a non-safe hot wallet spend share is unchanged', async function () { + const wallet = makeWallet({}); + const userKeychain = { + id: 'user-key', + pub: 'hot-pub', + type: 'independent' as const, + encryptedPrv: `enc:hot-prv`, + }; + keychainsGetStub.callsFake(({ id }: { id: string }) => + Promise.resolve(id === 'user-key' ? userKeychain : publicOnlyKeychain(id)) + ); + const options = await getShareOptions(wallet); + options.skipKeychain.should.equal(false); + options.keychain.pub.should.equal('hot-pub'); + }); + + it('an lnbtc wallet takes the userAuth path and never enters the safe branch', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', coin: 'lbtc' }); + mockBaseCoin.getFamily.returns('lnbtc'); + const safeChildSpy = sinon.spy(wallet as any, 'getSafeOwnerChildKeychain'); + // stubbing the private keychain fetch so lnbtc resolves a keychain without full lightning mocks + sinon.stub(wallet as any, 'getEncryptedWalletKeychainForWalletSharing').resolves({ + id: 'user-key', + pub: 'ln-pub', + encryptedPrv: `enc:ln-prv`, + type: 'independent', + }); + const options = await getShareOptions(wallet); + options.keychain.pub.should.equal('ln-pub'); + safeChildSpy.notCalled.should.equal(true); + }); + }); + + describe('createBulkWalletShare on a safe wallet', function () { + const bulkParams = { + walletPassphrase: passphrase, + keyShareOptions: [ + { userId: 'u1', pubKey: SHAREE_PUB, path: 'm/0', permissions: 'spend' }, + { userId: 'u2', pubKey: SHAREE_PUB, path: 'm/0', permissions: 'spend' }, + ], + } as any; + + it('derives the child once (one root decrypt) and fans it out per user', async function () { + const wallet = makeWallet({ safe: 'safe-id-1' }); + await wallet.createBulkWalletShare(bulkParams); + + createBulkKeySharesStub.calledOnce.should.equal(true); + const options = createBulkKeySharesStub.firstCall.args[0]; + options.length.should.equal(2); + options.forEach((o: any) => o.keychain.pub.should.equal(hardened.pub)); + // child + root exactly once (single root decrypt) + const rootCalls = keychainsGetStub.getCalls().filter((c) => c.args?.[0]?.id === rootKeyId); + rootCalls.length.should.equal(1); + }); + + it('the real error propagates instead of shareOptions cannot be empty', async function () { + const wallet = makeWallet({ safe: 'safe-id-1', multisigType: 'tss' }); + await wallet.createBulkWalletShare(bulkParams).should.be.rejectedWith(SafeShareNotImplementedError); + createBulkKeySharesStub.notCalled.should.equal(true); + }); + }); +}); diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts new file mode 100644 index 0000000000..cd67e2c6cd --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/dkg.ts @@ -0,0 +1,233 @@ +import type { MsgState, MsgStateMap, VrfShare } from '@bitgo/wasm-mps'; +import { Buffer } from 'buffer'; +import crypto from 'crypto'; +import { DeserializedMessages } from '../ecdsa-dkls/types'; +import { decodePartyId, decodeVrfDkgSessionData, decodeVrfRound1MsgMap, VrfDkgSessionData, VrfDkgState } from './types'; + +type NodeWasmer = typeof import('@bitgo/wasm-mps'); +type WebWasmer = typeof import('@bitgo/wasm-mps/web'); +type WasmMps = NodeWasmer | WebWasmer; + +/** + * Round driver for the EdDSA MPS VRF DKG, which produces a Ristretto VRF keyshare. + * + * Two message exchanges: round 0 broadcasts VrfKeygenMsg1, round 1 emits per-recipient + * VrfKeygenMsg2 as p2p messages, round 2 returns the VrfShare. There is no chain-code + * commitment step, unlike the signing DKG. + * + * Callers pass every message they hold; this class routes them internally. Round 1 + * must exclude the party's own commitment (the wasm rejects a sender set containing + * it) and round 2 consumes the openings addressed to this party. + * + * Party indices follow the MPCv2 convention: 0 = user, 1 = backup, 2 = bitgo. + * `VrfShare` exposes only share bytes — no public key, key id, or root chain code. + */ +export class VrfDkg { + protected n: number; + protected t: number; + protected partyIdx: number; + protected seed: Buffer | undefined; + /** Opaque wasm round-state bytes. Secret key material. */ + protected vrfStateBytes: Buffer | undefined; + protected keyShareBuff: Buffer | undefined; + protected vrfState: VrfDkgState = VrfDkgState.Uninitialized; + private wasmMps: WasmMps | null = null; + + constructor(n: number, t: number, partyIdx: number, seed?: Buffer) { + this.n = n; + this.t = t; + this.partyIdx = partyIdx; + this.seed = seed; + } + + private async loadWasmMps(): Promise { + if (!this.wasmMps) { + // Electron renderer sets process.type and must use the node wasm build. + if (typeof window !== 'undefined' && window.process?.['type'] !== 'renderer') { + // Browser: web build has explicit init() — guaranteed ready after await + // eslint-disable-next-line import/no-internal-modules -- @bitgo/wasm-mps exposes environment-specific subpath exports. + const webWasm = await import('@bitgo/wasm-mps/web'); + await webWasm.default(); + this.wasmMps = webWasm; + } else { + // Node.js: dynamic import() rewritten to require() by tsc → CJS build → readFileSync + this.wasmMps = await import('@bitgo/wasm-mps'); + } + } + } + + private getWasmMps(): WasmMps { + if (!this.wasmMps) { + throw Error('WASM module not loaded'); + } + return this.wasmMps; + } + + private getVrfStateBytes(): Buffer { + if (!this.vrfStateBytes) { + throw Error(`VRF DKG state bytes missing in state ${this.vrfState}`); + } + return this.vrfStateBytes; + } + + getState(): VrfDkgState { + return this.vrfState; + } + + /** + * Create this party's VRF DKG commitment (VrfKeygenMsg1, broadcast). + */ + async initDkg(): Promise { + await this.loadWasmMps(); + if (this.t > this.n || this.partyIdx >= this.n) { + throw Error('Invalid parameters for VRF DKG'); + } + if (this.seed && this.seed.length !== 32) { + throw Error(`Seed should be 32 bytes, got ${this.seed.length}.`); + } + if (this.vrfState !== VrfDkgState.Uninitialized) { + throw Error('VRF DKG session already initialized'); + } + + const wasm = this.getWasmMps(); + let result: MsgState; + try { + result = wasm.ed25519_vrf_dkg_round0_process(this.partyIdx, this.seed ?? crypto.randomBytes(32)); + } catch (err) { + throw new Error(`Error while creating the first VRF message from party ${this.partyIdx}: ${err}`); + } + const payload = new Uint8Array(result.msg); + this.vrfStateBytes = Buffer.from(result.state); + result.free(); + this.vrfState = VrfDkgState.Round1; + return { broadcastMessages: [{ payload, from: this.partyIdx }], p2pMessages: [] }; + } + + /** + * Process the messages this party holds for the current round and return this + * party's messages for the next round. Callers pass everything they hold; the + * round routing happens here: + * + * - Round 1: consumes the other parties' commitments (own excluded) and emits + * per-recipient openings (VrfKeygenMsg2) as p2p messages. + * - Round 2: consumes the openings addressed to this party and finalizes the DKG. + */ + async handleIncomingMessages(messagesForIthRound: DeserializedMessages): Promise { + await this.loadWasmMps(); + if (this.vrfState === VrfDkgState.Complete) { + throw Error('VRF DKG session already completed'); + } + if (this.vrfState === VrfDkgState.Uninitialized) { + throw Error('VRF DKG session not initialized'); + } + const wasm = this.getWasmMps(); + + switch (this.vrfState) { + case VrfDkgState.Round1: { + const othersCommitments = messagesForIthRound.broadcastMessages + .filter((m) => m.from !== this.partyIdx) + .sort((a, b) => a.from - b.from) + .map((m) => m.payload); + let result: MsgStateMap; + try { + result = wasm.ed25519_vrf_dkg_round1_process(othersCommitments, this.getVrfStateBytes()); + } catch (err) { + throw new Error( + `Error while creating VRF messages from party ${this.partyIdx}, state ${this.vrfState}: ${err}` + ); + } + const openings = Object.entries(decodeVrfRound1MsgMap(result.msg)).map(([recipient, payload]) => ({ + payload: new Uint8Array(payload), + from: this.partyIdx, + to: decodePartyId(recipient), + })); + this.vrfStateBytes = Buffer.from(result.state); + result.free(); + this.vrfState = VrfDkgState.Round2; + return { broadcastMessages: [], p2pMessages: openings }; + } + + case VrfDkgState.Round2: { + const openingsForMe = messagesForIthRound.p2pMessages + .filter((m) => m.to === this.partyIdx) + .sort((a, b) => a.from - b.from) + .map((m) => m.payload); + let share: VrfShare; + try { + share = wasm.ed25519_vrf_dkg_round2_process(openingsForMe, this.getVrfStateBytes()); + } catch (err) { + throw new Error( + `Error while creating VRF messages from party ${this.partyIdx}, state ${this.vrfState}: ${err}` + ); + } + this.keyShareBuff = Buffer.from(share.share); + share.free(); + this.vrfStateBytes = undefined; + this.vrfState = VrfDkgState.Complete; + return { broadcastMessages: [], p2pMessages: [] }; + } + + default: + throw Error(`Invalid VRF DKG state: ${this.vrfState}`); + } + } + + /** + * Get the VRF keyshare bytes once the DKG is complete. + * This buffer is private key material. + */ + getKeyShare(): Buffer { + if (!this.keyShareBuff) { + throw Error('Can not get key share, VRF DKG is not complete yet.'); + } + return this.keyShareBuff; + } + + /** + * Get the current session data that can be used to restore the session later. + * + * The returned state bytes are secret key material — they carry this party's + * secret VRF share. They must never be logged or persisted in the clear. + */ + getSessionData(): VrfDkgSessionData { + if (this.vrfState === VrfDkgState.Uninitialized) { + throw Error('VRF DKG session not initialized'); + } + const sessionData: VrfDkgSessionData = { vrfState: this.vrfState }; + if (this.vrfStateBytes) { + sessionData.vrfStateBytes = this.vrfStateBytes; + } + if (this.keyShareBuff) { + sessionData.keyShareBuff = this.keyShareBuff; + } + return sessionData; + } + + /** + * Restore a VRF DKG session from previous session data. + * MPS wasm state bytes have no round tag, so the persisted `vrfState` is used. + */ + static async restoreSession(n: number, t: number, partyIdx: number, sessionData: unknown): Promise { + const data = decodeVrfDkgSessionData(sessionData); + const vrfDkg = new VrfDkg(n, t, partyIdx); + switch (data.vrfState) { + case VrfDkgState.Round1: + case VrfDkgState.Round2: + if (!data.vrfStateBytes) { + throw Error(`Cannot restore VRF DKG session in state ${data.vrfState} without state bytes`); + } + vrfDkg.vrfStateBytes = Buffer.from(data.vrfStateBytes); + break; + case VrfDkgState.Complete: + if (!data.keyShareBuff) { + throw Error('Cannot restore a completed VRF DKG session without a key share'); + } + vrfDkg.keyShareBuff = data.keyShareBuff; + break; + default: + throw Error(`Invalid VRF DKG state: ${data.vrfState}`); + } + vrfDkg.vrfState = data.vrfState; + return vrfDkg; + } +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts new file mode 100644 index 0000000000..90b53daf53 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/index.ts @@ -0,0 +1,3 @@ +export * as MpsVrf from './dkg'; +export * as MpsVrfTypes from './types'; +export * as MpsVrfUtils from './util'; diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts new file mode 100644 index 0000000000..97a6a589ad --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/types.ts @@ -0,0 +1,85 @@ +import { Buffer } from 'buffer'; +import { isLeft } from 'fp-ts/Either'; +import * as t from 'io-ts'; + +/** + * States of the VRF DKG state machine. Kept separate from `eddsa-mps`'s signing + * `DkgState` because the round counts differ. MPS wasm state bytes have no round + * tag, so the round is tracked here and carried in `VrfDkgSessionData`. + */ +export enum VrfDkgState { + Uninitialized = 0, + /** Commitment created and broadcast; waiting for the other parties' VrfKeygenMsg1. */ + Round1, + /** Openings created; waiting for the VrfKeygenMsg2 entries addressed to this party. */ + Round2, + Complete, + InvalidState, +} + +export interface VrfDkgSessionData { + /** + * Serialized wasm round state. Secret key material — it carries this party's + * secret VRF share. Never log it or persist it in the clear. + */ + vrfStateBytes?: Uint8Array; + vrfState: VrfDkgState; + keyShareBuff?: Buffer; +} + +const Uint8ArrayCodec = new t.Type( + 'Uint8Array', + (u): u is Uint8Array => u instanceof Uint8Array, + (u, c) => (u instanceof Uint8Array ? t.success(u) : t.failure(u, c)), + t.identity +); + +const BufferCodec = new t.Type( + 'Buffer', + (u): u is Buffer => Buffer.isBuffer(u), + (u, c) => (Buffer.isBuffer(u) ? t.success(u) : t.failure(u, c)), + t.identity +); + +const VrfDkgRound1MsgMap = t.record(t.string, Uint8ArrayCodec); + +const RestorableVrfDkgState = t.union([ + t.literal(VrfDkgState.Round1), + t.literal(VrfDkgState.Round2), + t.literal(VrfDkgState.Complete), +]); + +const VrfDkgSessionDataCodec = t.intersection([ + t.type({ vrfState: RestorableVrfDkgState }), + t.partial({ + vrfStateBytes: Uint8ArrayCodec, + keyShareBuff: BufferCodec, + }), +]); + +/** Decode a wasm round-1 map key as a party index. */ +export function decodePartyId(recipient: string): number { + const to = Number.parseInt(recipient, 10); + if (!Number.isInteger(to) || to < 0 || String(to) !== recipient) { + throw new Error(`VRF DKG round-1 recipient is not a party id: ${recipient}`); + } + return to; +} + +/** Decode the wasm round-1 recipient → bytes map. */ +export function decodeVrfRound1MsgMap(msg: unknown): Record { + const decoded = VrfDkgRound1MsgMap.decode(msg); + if (isLeft(decoded)) { + throw new Error('VRF DKG round-1 message is not a party-id map of byte arrays'); + } + return decoded.right; +} + +/** Decode persisted VRF DKG session data. */ +export function decodeVrfDkgSessionData(sessionData: unknown): VrfDkgSessionData { + const decoded = VrfDkgSessionDataCodec.decode(sessionData); + if (isLeft(decoded)) { + throw new Error('Invalid VRF DKG session data'); + } + return decoded.right; +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts new file mode 100644 index 0000000000..3ba2877885 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps-vrf/util.ts @@ -0,0 +1,46 @@ +import { Buffer } from 'buffer'; +import { VrfDkg } from './dkg'; + +/** + * Runs a local 2-of-3 VRF DKG across user (0), backup (1) and bitgo (2) parties and + * returns the three completed VrfDkg sessions, mirroring `generateVrfDKGKeyShares` from + * `dkls-vrf/util.ts`. + */ +export async function generateVrfDKGKeyShares( + seedUser?: Buffer, + seedBackup?: Buffer, + seedBitgo?: Buffer +): Promise<[VrfDkg, VrfDkg, VrfDkg]> { + const user = new VrfDkg(3, 2, 0, seedUser); + const backup = new VrfDkg(3, 2, 1, seedBackup); + const bitgo = new VrfDkg(3, 2, 2, seedBitgo); + + // #region round 1 + const userRound1Messages = await user.initDkg(); + const backupRound1Messages = await backup.initDkg(); + const bitgoRound1Messages = await bitgo.initDkg(); + const round1Messages = [userRound1Messages, backupRound1Messages, bitgoRound1Messages]; + // #endregion + + // #region round 2 + const round2Outputs = await Promise.all( + [user, backup, bitgo].map((party, i) => + party.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: round1Messages.flatMap((m) => m.broadcastMessages).filter((m) => m.from !== i), + }) + ) + ); + // #endregion + + // #region finalize + for (const [i, party] of [user, backup, bitgo].entries()) { + await party.handleIncomingMessages({ + p2pMessages: round2Outputs.flatMap((m) => m.p2pMessages).filter((m) => m.to === i), + broadcastMessages: [], + }); + } + // #endregion + + return [user, backup, bitgo]; +} diff --git a/modules/sdk-lib-mpc/src/tss/index.ts b/modules/sdk-lib-mpc/src/tss/index.ts index 6233ce24d2..6e124e0a98 100644 --- a/modules/sdk-lib-mpc/src/tss/index.ts +++ b/modules/sdk-lib-mpc/src/tss/index.ts @@ -2,4 +2,5 @@ export * from './ecdsa'; export * from './ecdsa-dkls'; export * from './dkls-vrf'; export * from './eddsa-mps'; +export * from './eddsa-mps-vrf'; export * from './redpallas-mps'; diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa-mps-vrf/dkg.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa-mps-vrf/dkg.ts new file mode 100644 index 0000000000..dcd65be4d6 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa-mps-vrf/dkg.ts @@ -0,0 +1,267 @@ +import assert from 'assert'; +import crypto from 'crypto'; +import * as openpgp from 'openpgp'; +import { DklsTypes, MPSComms, MPSTypes, MPSUtil, MpsVrf, MpsVrfTypes, MpsVrfUtils } from '../../../../src/tss'; +import { serializeMessages, type DeserializedMessages } from '../../../../src/tss/ecdsa-dkls/types'; + +// Measured on @bitgo/wasm-mps 1.14.0; keycard sizing depends on this. +const VRF_KEYSHARE_SIZE_BYTES = 229; + +describe('MPS VRF DKG 2x3', function () { + it('should create VRF key shares of the measured size for all three parties', async function () { + const [user, backup, bitgo] = await MpsVrfUtils.generateVrfDKGKeyShares(); + const userKeyShare = user.getKeyShare(); + const backupKeyShare = backup.getKeyShare(); + const bitgoKeyShare = bitgo.getKeyShare(); + assert.equal(userKeyShare.length, VRF_KEYSHARE_SIZE_BYTES); + assert.equal(backupKeyShare.length, VRF_KEYSHARE_SIZE_BYTES); + assert.equal(bitgoKeyShare.length, VRF_KEYSHARE_SIZE_BYTES); + assert.notDeepStrictEqual(userKeyShare, backupKeyShare); + assert.notDeepStrictEqual(userKeyShare, bitgoKeyShare); + for (const party of [user, backup, bitgo]) { + assert.equal(party.getState(), MpsVrfTypes.VrfDkgState.Complete); + } + }); + + it('should produce key shares that agree on one VRF key, proven by hard derivation', async function () { + const mps = await import('@bitgo/wasm-mps'); + const [rootUser, rootBackup, rootBitgo] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [vrfUser, vrfBackup, vrfBitgo] = await MpsVrfUtils.generateVrfDKGKeyShares(); + const rootShares = [rootUser.getKeyShare(), rootBackup.getKeyShare(), rootBitgo.getKeyShare()]; + const vrfShares = [vrfUser.getKeyShare(), vrfBackup.getKeyShare(), vrfBitgo.getKeyShare()]; + const path = "m/0'"; + + const pairs: [number, number][] = [ + [0, 2], + [0, 1], + ]; + const derived = pairs.map(([a, b]) => { + const round0 = [a, b].map((i) => mps.ed25519_hard_derive_round0_process(vrfShares[i], rootShares[i], path)); + const round1 = [0, 1].map((i) => mps.ed25519_hard_derive_round1_process(round0[1 - i].msg, round0[i].state)); + return [0, 1].map((i) => mps.ed25519_hard_derive_round2_process(round1[1 - i].msg, round1[i].state)); + }); + + assert.deepStrictEqual(Buffer.from(derived[0][0].pk), Buffer.from(derived[1][0].pk)); + assert.deepStrictEqual(Buffer.from(derived[0][0].chaincode), Buffer.from(derived[1][0].chaincode)); + assert.deepStrictEqual(Buffer.from(derived[0][1].pk), Buffer.from(derived[0][0].pk)); + assert.deepStrictEqual(Buffer.from(derived[0][1].chaincode), Buffer.from(derived[0][0].chaincode)); + }); + + it('should carry VRF messages through the existing MPS sign/verify comms unchanged', async function () { + const [userGpg, backupGpg, bitgoGpg] = await Promise.all([ + openpgp.generateKey({ userIDs: [{ name: 'user', email: 'u@test.com' }], curve: 'ed25519', format: 'object' }), + openpgp.generateKey({ userIDs: [{ name: 'backup', email: 'b@test.com' }], curve: 'ed25519', format: 'object' }), + openpgp.generateKey({ userIDs: [{ name: 'bitgo', email: 'bg@test.com' }], curve: 'ed25519', format: 'object' }), + ]); + const prvKeys = [userGpg.privateKey, backupGpg.privateKey, bitgoGpg.privateKey]; + const pubKeys = [userGpg.publicKey, backupGpg.publicKey, bitgoGpg.publicKey]; + const parties = [new MpsVrf.VrfDkg(3, 2, 0), new MpsVrf.VrfDkg(3, 2, 1), new MpsVrf.VrfDkg(3, 2, 2)]; + + const round1 = await Promise.all(parties.map((p) => p.initDkg())); + const round1Signed = await Promise.all( + round1.map((m, i) => MPSComms.detachSignMpsMessage(Buffer.from(m.broadcastMessages[0].payload), prvKeys[i])) + ); + const round1Outputs: DeserializedMessages[] = []; + for (const [i, party] of parties.entries()) { + const signerIds = [0, 1, 2].filter((j) => j !== i); + const signed = round1Signed.filter((_, j) => j !== i); + const verified = await Promise.all(signed.map((s, k) => MPSComms.verifyMpsMessage(s, pubKeys[signerIds[k]]))); + round1Outputs.push( + await party.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: verified.map((payload, k) => ({ payload: new Uint8Array(payload), from: signerIds[k] })), + }) + ); + } + + const openingsForParty: { signed: MPSTypes.MPSSignedMessage; from: number }[][] = [[], [], []]; + for (const [i, msgs] of round1Outputs.entries()) { + for (const p2p of msgs.p2pMessages) { + openingsForParty[p2p.to].push({ + signed: await MPSComms.detachSignMpsMessage(Buffer.from(p2p.payload), prvKeys[i]), + from: i, + }); + } + } + for (const [i, party] of parties.entries()) { + const verified = await Promise.all( + openingsForParty[i].map(async (o) => ({ + payload: new Uint8Array(await MPSComms.verifyMpsMessage(o.signed, pubKeys[o.from])), + from: o.from, + to: i, + })) + ); + await party.handleIncomingMessages({ p2pMessages: verified, broadcastMessages: [] }); + } + + for (const party of parties) { + assert.equal(party.getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + } + }); + + it('should round-trip VRF messages through serializeMessages/deserializeMessages', async function () { + const parties = [new MpsVrf.VrfDkg(3, 2, 0), new MpsVrf.VrfDkg(3, 2, 1), new MpsVrf.VrfDkg(3, 2, 2)]; + const round1 = await Promise.all(parties.map((p) => p.initDkg())); + + const deserialized = DklsTypes.deserializeMessages(serializeMessages(round1[0])); + assert.equal(deserialized.broadcastMessages.length, round1[0].broadcastMessages.length); + assert.equal(deserialized.broadcastMessages[0].from, round1[0].broadcastMessages[0].from); + assert.deepEqual(deserialized.broadcastMessages[0].payload, round1[0].broadcastMessages[0].payload); + assert.equal(deserialized.p2pMessages.length, 0); + + const round2Outputs = await Promise.all( + parties.map((party, i) => + party.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: round1.flatMap((m) => m.broadcastMessages).filter((m) => m.from !== i), + }) + ) + ); + + const deserializedOpenings = DklsTypes.deserializeMessages(serializeMessages(round2Outputs[0])); + assert.equal(deserializedOpenings.p2pMessages.length, round2Outputs[0].p2pMessages.length); + assert.deepEqual(deserializedOpenings.p2pMessages[0].payload, round2Outputs[0].p2pMessages[0].payload); + assert.equal(deserializedOpenings.p2pMessages[0].to, round2Outputs[0].p2pMessages[0].to); + assert.equal(deserializedOpenings.p2pMessages[0].from, 0); + + for (const [i, party] of parties.entries()) { + await party.handleIncomingMessages({ + p2pMessages: round2Outputs.flatMap((m) => m.p2pMessages).filter((m) => m.to === i), + broadcastMessages: [], + }); + } + assert.equal(parties[0].getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + }); + + it('should restore a session serialized after initialization', async function () { + const restored = await runWithRestore('afterInit'); + assert.equal(restored.getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + }); + + it('should restore a session serialized after round 1', async function () { + const restored = await runWithRestore('afterRound1'); + assert.equal(restored.getKeyShare().length, VRF_KEYSHARE_SIZE_BYTES); + }); + + it('should restore a completed session from its key share', async function () { + const [user] = await MpsVrfUtils.generateVrfDKGKeyShares(); + const restored = await MpsVrf.VrfDkg.restoreSession(3, 2, 0, user.getSessionData()); + assert.deepEqual(restored.getKeyShare(), user.getKeyShare()); + }); + + it('should reject a wrong message count in round 1', async function () { + const [user, backupRound1] = await startThreeParties(); + await assert.rejects( + user.handleIncomingMessages({ p2pMessages: [], broadcastMessages: [...backupRound1.broadcastMessages] }), + /Invalid Input/ + ); + }); + + it('should reject duplicate senders in round 1', async function () { + const [user, backupRound1] = await startThreeParties(); + await assert.rejects( + user.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [...backupRound1.broadcastMessages, ...backupRound1.broadcastMessages], + }), + /Protocol Error/ + ); + }); + + it('should reject getKeyShare before the DKG completes', async function () { + const [user] = await startThreeParties(); + assert.throws(() => user.getKeyShare(), /Can not get key share/); + }); + + it('should reject invalid constructor parameters and double initialization', async function () { + await assert.rejects(new MpsVrf.VrfDkg(2, 3, 0).initDkg(), /Invalid parameters for VRF DKG/); + await assert.rejects(new MpsVrf.VrfDkg(3, 2, 5).initDkg(), /Invalid parameters for VRF DKG/); + await assert.rejects(new MpsVrf.VrfDkg(3, 2, 0, Buffer.alloc(16)).initDkg(), /Seed should be 32 bytes, got 16/); + const [user] = await startThreeParties(); + await assert.rejects(user.initDkg(), /VRF DKG session already initialized/); + }); + + it('should reject handling messages before initialization and after completion', async function () { + const user = new MpsVrf.VrfDkg(3, 2, 0); + await assert.rejects( + user.handleIncomingMessages({ p2pMessages: [], broadcastMessages: [] }), + /VRF DKG session not initialized/ + ); + const [completed] = await MpsVrfUtils.generateVrfDKGKeyShares(); + await assert.rejects( + completed.handleIncomingMessages({ p2pMessages: [], broadcastMessages: [] }), + /VRF DKG session already completed/ + ); + }); + + it('should reject restoring a session without the required material', async function () { + await assert.rejects( + MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: MpsVrfTypes.VrfDkgState.Round1 }), + /without state bytes/ + ); + await assert.rejects( + MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: MpsVrfTypes.VrfDkgState.Complete }), + /without a key share/ + ); + await assert.rejects( + MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: MpsVrfTypes.VrfDkgState.Uninitialized }), + /Invalid VRF DKG session data/ + ); + await assert.rejects(MpsVrf.VrfDkg.restoreSession(3, 2, 0, { vrfState: 'Round1' }), /Invalid VRF DKG session data/); + }); + + it('should reject non-integer round-1 recipient ids', function () { + assert.throws(() => MpsVrfTypes.decodePartyId('1.5'), /not a party id/); + assert.throws(() => MpsVrfTypes.decodePartyId('01'), /not a party id/); + assert.throws(() => MpsVrfTypes.decodePartyId('user'), /not a party id/); + assert.equal(MpsVrfTypes.decodePartyId('2'), 2); + }); + + async function startThreeParties(): Promise<[MpsVrf.VrfDkg, DeserializedMessages]> { + const user = new MpsVrf.VrfDkg(3, 2, 0); + const backup = new MpsVrf.VrfDkg(3, 2, 1); + const bitgo = new MpsVrf.VrfDkg(3, 2, 2); + const round1 = await Promise.all([user, backup, bitgo].map((p) => p.initDkg())); + return [user, round1[1]]; + } + + /** + * Runs a full ceremony where party 0's session is serialized and restored at + * the requested point, returning party 0's completed session. + */ + async function runWithRestore(restoreAfter: 'afterInit' | 'afterRound1'): Promise { + const user = new MpsVrf.VrfDkg(3, 2, 0, crypto.randomBytes(32)); + const backup = new MpsVrf.VrfDkg(3, 2, 1, crypto.randomBytes(32)); + const bitgo = new MpsVrf.VrfDkg(3, 2, 2, crypto.randomBytes(32)); + const round1 = await Promise.all([user, backup, bitgo].map((p) => p.initDkg())); + const [userRound1, backupRound1, bitgoRound1] = round1; + + let userSession: MpsVrf.VrfDkg = user; + if (restoreAfter === 'afterInit') { + userSession = await MpsVrf.VrfDkg.restoreSession(3, 2, 0, user.getSessionData()); + } + const userRound2 = await userSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [backupRound1, bitgoRound1].flatMap((m) => m.broadcastMessages), + }); + if (restoreAfter === 'afterRound1') { + userSession = await MpsVrf.VrfDkg.restoreSession(3, 2, 0, userSession.getSessionData()); + } + const backupRound2 = await backup.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [userRound1, bitgoRound1].flatMap((m) => m.broadcastMessages), + }); + const bitgoRound2 = await bitgo.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [userRound1, backupRound1].flatMap((m) => m.broadcastMessages), + }); + const round2Outputs = [userRound2, backupRound2, bitgoRound2]; + for (const [i, party] of [userSession, backup, bitgo].entries()) { + await party.handleIncomingMessages({ + p2pMessages: round2Outputs.flatMap((m) => m.p2pMessages).filter((m) => m.to === i), + broadcastMessages: [], + }); + } + return userSession; + } +}); diff --git a/modules/statics/src/coins/botOfcTokens.ts b/modules/statics/src/coins/botOfcTokens.ts index c0aebc4f42..46825d8b8e 100644 --- a/modules/statics/src/coins/botOfcTokens.ts +++ b/modules/statics/src/coins/botOfcTokens.ts @@ -1138,11 +1138,11 @@ export const botOfcTokens = [ 'eth' ), AccountCtors.ofcerc20( - 'ec6463ad-bb55-41d1-8e3e-4fdfcb5d0e08', - 'ofceth:kaio', - 'KAIO', + '15b3346a-6c6d-4ec9-9199-d4067f4ec819', + 'ofceth:wallet', + 'Ambire Wallet', 18, - 'eth:kaio' as unknown as UnderlyingAsset, + 'eth:wallet' as unknown as UnderlyingAsset, undefined, undefined, undefined, @@ -1152,11 +1152,11 @@ export const botOfcTokens = [ 'eth' ), AccountCtors.ofcerc20( - 'd108b1d9-6560-4732-a1b1-bba63f2d2308', - 'ofceth:vow', - 'Vow', + '7a71eba1-49b3-4630-8e13-1c43bea6f270', + 'ofceth:fhe', + 'MindNetwork FHE Token', 18, - 'eth:vow' as unknown as UnderlyingAsset, + 'eth:fhe' as unknown as UnderlyingAsset, undefined, undefined, undefined, @@ -1166,25 +1166,11 @@ export const botOfcTokens = [ 'eth' ), AccountCtors.ofcerc20( - 'aed23d74-d469-4f4a-a0a0-8fef5abf8e1c', - 'ofceth:rsc', - 'ResearchCoin', + 'd108b1d9-6560-4732-a1b1-bba63f2d2308', + 'ofceth:vow', + 'Vow', 18, - 'eth:rsc' as unknown as UnderlyingAsset, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - 'eth' - ), - AccountCtors.ofcerc20( - 'e426e313-98a7-4ed3-a6d9-580ca0b26fc6', - 'ofceth:pci', - 'PayProtocol Paycoin', - 8, - 'eth:pci' as unknown as UnderlyingAsset, + 'eth:vow' as unknown as UnderlyingAsset, undefined, undefined, undefined, @@ -1194,11 +1180,11 @@ export const botOfcTokens = [ 'eth' ), AccountCtors.ofcerc20( - '15b3346a-6c6d-4ec9-9199-d4067f4ec819', - 'ofceth:wallet', - 'Ambire Wallet', + 'a612a762-1940-4bd9-85e8-f92999c6ce43', + 'ofceth:tgc', + 'TG.Casino', 18, - 'eth:wallet' as unknown as UnderlyingAsset, + 'eth:tgc' as unknown as UnderlyingAsset, undefined, undefined, undefined, @@ -1208,11 +1194,11 @@ export const botOfcTokens = [ 'eth' ), AccountCtors.ofcerc20( - 'a612a762-1940-4bd9-85e8-f92999c6ce43', - 'ofceth:tgc', - 'TG.Casino', + 'aed23d74-d469-4f4a-a0a0-8fef5abf8e1c', + 'ofceth:rsc', + 'ResearchCoin', 18, - 'eth:tgc' as unknown as UnderlyingAsset, + 'eth:rsc' as unknown as UnderlyingAsset, undefined, undefined, undefined, @@ -1250,11 +1236,25 @@ export const botOfcTokens = [ 'eth' ), AccountCtors.ofcerc20( - '7a71eba1-49b3-4630-8e13-1c43bea6f270', - 'ofceth:fhe', - 'MindNetwork FHE Token', + 'ec6463ad-bb55-41d1-8e3e-4fdfcb5d0e08', + 'ofceth:kaio', + 'KAIO', 18, - 'eth:fhe' as unknown as UnderlyingAsset, + 'eth:kaio' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'eth' + ), + AccountCtors.ofcerc20( + 'e426e313-98a7-4ed3-a6d9-580ca0b26fc6', + 'ofceth:pci', + 'PayProtocol Paycoin', + 8, + 'eth:pci' as unknown as UnderlyingAsset, undefined, undefined, undefined, @@ -3323,4 +3323,182 @@ export const botOfcTokens = [ undefined, 'eth' ), + AccountCtors.ofcerc20( + 'be5a9af0-9b64-41c3-9fa3-74b5ca392681', + 'ofceth:usdu', + 'USD Universal', + 6, + 'eth:usdu' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'eth' + ), + AccountCtors.ofcerc20( + '7244113e-2581-4460-999d-448dae04ee13', + 'ofceth:eusdc132', + 'EVK Vault eUSDC-132', + 6, + 'eth:eusdc132' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'eth' + ), + AccountCtors.ofcerc20( + 'b07dd7cd-9282-432c-8f9d-17b2f32a8ad7', + 'ofceth:eurq', + 'Quantoz EURQ', + 6, + 'eth:eurq' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'eth' + ), + AccountCtors.ofcerc20( + 'e163e589-722e-4a7a-a98b-57b2c9505c4e', + 'ofceth:adi', + 'ADI', + 18, + 'eth:adi' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'eth' + ), + AccountCtors.ofcerc20( + 'fcc128c7-bca7-4bf5-831f-4ccb2c548dd5', + 'ofceth:hyper', + 'Hyperlane', + 18, + 'eth:hyper' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'eth' + ), + AccountCtors.ofcsolToken( + '630fe574-c2c0-4027-a024-dcad58e621f9', + 'ofcsol:auto', + 'ofcHastra AUTO', + 6, + 'sol:auto' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + '782bc748-f6f5-4658-817b-25a6f1152375', + 'ofcsol:susdai', + 'ofcStaked USDai', + 6, + 'sol:susdai' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + '098f76d5-5152-4af5-9a6e-9db97c6d86f1', + 'ofcsol:usdai', + 'ofcUSDai', + 6, + 'sol:usdai' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + '23c21ad0-3bc0-42ea-ae3e-9818fea126e2', + 'ofcsol:dith', + 'ofcDither', + 9, + 'sol:dith' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + '15cfb593-a2f3-4961-aa97-edd676cc2707', + 'ofcsol:cushy', + 'ofcCoinbase USD Stablecoin Yield Fund', + 6, + 'sol:cushy' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + '84cd9a5e-3ace-43e9-8729-47f57a9e85ef', + 'ofcsol:usdstar', + 'ofcUSD Star', + 6, + 'sol:usdstar' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + '00e2d90e-5ff7-457a-9c7f-d827aef23c1f', + 'ofcsol:usdu', + 'ofcUSDu', + 6, + 'sol:usdu' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + '53592c71-cc8b-4824-94e1-727a803153b8', + 'ofcsol:stonk', + 'ofcSTONK', + 9, + 'sol:stonk' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), + AccountCtors.ofcsolToken( + 'becf513a-f605-4276-80c4-091ecb284a3f', + 'ofcsol:hsdt', + 'ofcSolana Company', + 6, + 'sol:hsdt' as unknown as UnderlyingAsset, + undefined, + undefined, + undefined, + undefined, + undefined + ), ]; diff --git a/modules/statics/src/coins/botTokens.ts b/modules/statics/src/coins/botTokens.ts index 453dcf2f72..01a59b6ef0 100644 --- a/modules/statics/src/coins/botTokens.ts +++ b/modules/statics/src/coins/botTokens.ts @@ -460,6 +460,7 @@ export const botTokens = [ 'custody-bitgo-switzerland' as CoinFeature, 'custody-bitgo-sister-trust-one' as CoinFeature, 'custody-bitgo-korea' as CoinFeature, + CoinFeature.STAKING, ]), undefined, undefined, @@ -2371,72 +2372,60 @@ export const botTokens = [ undefined ), AccountCtors.erc20( - '38ea18a5-4565-4b40-99af-65f0b38af4d8', - 'eth:kaio', - 'KAIO', + '479bab8f-49db-4cec-8aee-c48574819b7a', + 'eth:wallet', + 'Ambire Wallet', 18, - '0x00bac91fd8f5b4a0dc03c8021139b76f6549ee7e', - 'eth:kaio' as unknown as UnderlyingAsset, + '0x88800092ff476844f74dc2fc427974bbee2794ae', + 'eth:wallet' as unknown as UnderlyingAsset, getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), undefined, undefined, undefined ), AccountCtors.erc20( - 'ff205253-373b-49d0-b32d-5211d20e0d62', - 'eth:vow', - 'Vow', + 'de7d9673-dfa0-440d-865b-823677e265e1', + 'eth:fhe', + 'MindNetwork FHE Token', 18, - '0x1bbf25e71ec48b84d773809b4ba55b6f4be946fb', - 'eth:vow' as unknown as UnderlyingAsset, + '0xd55c9fb62e176a8eb6968f32958fefdd0962727e', + 'eth:fhe' as unknown as UnderlyingAsset, getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), undefined, undefined, undefined ), AccountCtors.erc20( - '78bd4626-6958-4c56-961d-d9579e926f53', - 'eth:rsc', - 'ResearchCoin', + 'ff205253-373b-49d0-b32d-5211d20e0d62', + 'eth:vow', + 'Vow', 18, - '0xd101dcc414f310268c37eeb4cd376ccfa507f571', - 'eth:rsc' as unknown as UnderlyingAsset, - getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), - undefined, - undefined, - undefined - ), - AccountCtors.erc20( - '7f90a4e7-5dd1-4382-a4f3-95c070adcba5', - 'eth:pci', - 'PayProtocol Paycoin', - 8, - '0x3c2a309d9005433c1bc2c92ef1be06489e5bf258', - 'eth:pci' as unknown as UnderlyingAsset, + '0x1bbf25e71ec48b84d773809b4ba55b6f4be946fb', + 'eth:vow' as unknown as UnderlyingAsset, getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), undefined, undefined, undefined ), AccountCtors.erc20( - '479bab8f-49db-4cec-8aee-c48574819b7a', - 'eth:wallet', - 'Ambire Wallet', + 'cdc47df2-850a-4a3d-898a-a4fc540dd85e', + 'eth:tgc', + 'TG.Casino', 18, - '0x88800092ff476844f74dc2fc427974bbee2794ae', - 'eth:wallet' as unknown as UnderlyingAsset, + '0x25b4f5d4c314bcd5d7962734936c957b947cb7cf', + 'eth:tgc' as unknown as UnderlyingAsset, getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), undefined, undefined, undefined ), AccountCtors.erc20( - 'cdc47df2-850a-4a3d-898a-a4fc540dd85e', - 'eth:tgc', - 'TG.Casino', + '78bd4626-6958-4c56-961d-d9579e926f53', + 'eth:rsc', + 'ResearchCoin', 18, - '0x25b4f5d4c314bcd5d7962734936c957b947cb7cf', - 'eth:tgc' as unknown as UnderlyingAsset, + '0xd101dcc414f310268c37eeb4cd376ccfa507f571', + 'eth:rsc' as unknown as UnderlyingAsset, getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), undefined, undefined, @@ -2467,12 +2456,24 @@ export const botTokens = [ undefined ), AccountCtors.erc20( - 'de7d9673-dfa0-440d-865b-823677e265e1', - 'eth:fhe', - 'MindNetwork FHE Token', + '38ea18a5-4565-4b40-99af-65f0b38af4d8', + 'eth:kaio', + 'KAIO', 18, - '0xd55c9fb62e176a8eb6968f32958fefdd0962727e', - 'eth:fhe' as unknown as UnderlyingAsset, + '0x00bac91fd8f5b4a0dc03c8021139b76f6549ee7e', + 'eth:kaio' as unknown as UnderlyingAsset, + getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), + undefined, + undefined, + undefined + ), + AccountCtors.erc20( + '7f90a4e7-5dd1-4382-a4f3-95c070adcba5', + 'eth:pci', + 'PayProtocol Paycoin', + 8, + '0x3c2a309d9005433c1bc2c92ef1be06489e5bf258', + 'eth:pci' as unknown as UnderlyingAsset, getTokenFeatures('eth', ['custody-bitgo-new-york' as CoinFeature, 'custody-bitgo-germany' as CoinFeature]), undefined, undefined, @@ -4672,4 +4673,227 @@ export const botTokens = [ undefined, undefined ), + AccountCtors.erc20( + '59399d5f-1efa-4855-89fe-d92842764cc3', + 'eth:usdu', + 'USD Universal', + 6, + '0xe4ca6596d2c28014c6f89964f57838e0be9f369b', + 'eth:usdu' as unknown as UnderlyingAsset, + getTokenFeatures('eth', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-switzerland' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + undefined, + undefined, + undefined + ), + AccountCtors.erc20( + '7a25c418-d974-4883-99a2-b97d4571d64e', + 'eth:eusdc132', + 'EVK Vault eUSDC-132', + 6, + '0xf26c68e6d26f725858e7cc353ee30e43adf0b732', + 'eth:eusdc132' as unknown as UnderlyingAsset, + getTokenFeatures('eth', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-switzerland' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + undefined, + undefined, + undefined + ), + AccountCtors.erc20( + 'dc028d59-abc5-4ee5-ab42-83faff08a043', + 'eth:eurq', + 'Quantoz EURQ', + 6, + '0x8df723295214ea6f21026eeeb4382d475f146f9f', + 'eth:eurq' as unknown as UnderlyingAsset, + getTokenFeatures('eth', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-switzerland' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + undefined, + undefined, + undefined + ), + AccountCtors.erc20( + 'f57d6e19-775e-4451-a96d-011432a2bd89', + 'eth:adi', + 'ADI', + 18, + '0x8b1484d57abbe239bb280661377363b03c89caea', + 'eth:adi' as unknown as UnderlyingAsset, + getTokenFeatures('eth', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-switzerland' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + undefined, + undefined, + undefined + ), + AccountCtors.erc20( + '1a874e89-3c61-4111-8e83-975f85979fdb', + 'eth:hyper', + 'Hyperlane', + 18, + '0x93a2db22b7c736b341c32ff666307f4a9ed910f5', + 'eth:hyper' as unknown as UnderlyingAsset, + getTokenFeatures('eth', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-switzerland' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + 'a17ca86f-c6bc-4d59-8ac9-782e7b9389e4', + 'sol:auto', + 'Hastra AUTO', + 6, + 'GNE6oDS6jHrfaV3GQVVCCp37fDnT7PiPuewMKBj2bqNm', + 'GNE6oDS6jHrfaV3GQVVCCp37fDnT7PiPuewMKBj2bqNm', + 'sol:auto' as unknown as UnderlyingAsset, + getTokenFeatures('sol', ['custody-bitgo-germany' as CoinFeature, 'custody-bitgo-korea' as CoinFeature]), + AccountCtors.ProgramID.TokenProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + '7dafe7cb-21ca-4ebe-8427-32718f75b676', + 'sol:susdai', + 'Staked USDai', + 6, + 'sUSDai6Y3GxysDEtA9BVcEFTaog6UZpYUVxJiMhAKYE', + 'sUSDai6Y3GxysDEtA9BVcEFTaog6UZpYUVxJiMhAKYE', + 'sol:susdai' as unknown as UnderlyingAsset, + getTokenFeatures('sol', ['custody-bitgo-germany' as CoinFeature, 'custody-bitgo-korea' as CoinFeature]), + AccountCtors.ProgramID.Token2022ProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + '725f3df8-a7be-44d3-8cbf-fa814c65d507', + 'sol:usdai', + 'USDai', + 6, + 'USDai5XCUzNebYzUk6EuRiFCvnyoyEdj7VSyijYcz2A', + 'USDai5XCUzNebYzUk6EuRiFCvnyoyEdj7VSyijYcz2A', + 'sol:usdai' as unknown as UnderlyingAsset, + getTokenFeatures('sol', ['custody-bitgo-germany' as CoinFeature, 'custody-bitgo-korea' as CoinFeature]), + AccountCtors.ProgramID.Token2022ProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + '0ec1b3c9-6edc-4ba9-b90f-368fe3111150', + 'sol:dith', + 'Dither', + 9, + 'E1kvzJNxShvvWTrudokpzuc789vRiDXfXG3duCuY6ooE', + 'E1kvzJNxShvvWTrudokpzuc789vRiDXfXG3duCuY6ooE', + 'sol:dith' as unknown as UnderlyingAsset, + getTokenFeatures('sol', ['custody-bitgo-germany' as CoinFeature, 'custody-bitgo-korea' as CoinFeature]), + AccountCtors.ProgramID.TokenProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + '236b8971-57a1-4e55-9293-5ffcbb0882a1', + 'sol:cushy', + 'Coinbase USD Stablecoin Yield Fund', + 6, + 'HSWt2izBvgNnxG982DcsysGikDnrgSq3EAWMYCSMGziP', + 'HSWt2izBvgNnxG982DcsysGikDnrgSq3EAWMYCSMGziP', + 'sol:cushy' as unknown as UnderlyingAsset, + getTokenFeatures('sol', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-switzerland' as CoinFeature, + 'custody-bitgo-sister-trust-one' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + AccountCtors.ProgramID.Token2022ProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + '37b851d6-aa0d-44fe-8d0f-a1ea44894991', + 'sol:usdstar', + 'USD Star', + 6, + 'star9agSpjiFe3M49B3RniVU4CMBBEK3Qnaqn3RGiFM', + 'star9agSpjiFe3M49B3RniVU4CMBBEK3Qnaqn3RGiFM', + 'sol:usdstar' as unknown as UnderlyingAsset, + getTokenFeatures('sol', ['custody-bitgo-germany' as CoinFeature, 'custody-bitgo-korea' as CoinFeature]), + AccountCtors.ProgramID.TokenProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + '2416634b-e283-4261-a04c-5aa2cf5a7ac6', + 'sol:usdu', + 'USDu', + 6, + '9ckR7pPPvyPadACDTzLwK2ZAEeUJ3qGSnzPs8bVaHrSy', + '9ckR7pPPvyPadACDTzLwK2ZAEeUJ3qGSnzPs8bVaHrSy', + 'sol:usdu' as unknown as UnderlyingAsset, + getTokenFeatures('sol', ['custody-bitgo-germany' as CoinFeature, 'custody-bitgo-korea' as CoinFeature]), + AccountCtors.ProgramID.Token2022ProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + '11d8f84d-4549-4f02-ae34-6859ce79ce29', + 'sol:stonk', + 'STONK', + 9, + '6GmAFSYs4gk3FDao5FzzySQpPZaWsa4rUJHacpMpUNgx', + '6GmAFSYs4gk3FDao5FzzySQpPZaWsa4rUJHacpMpUNgx', + 'sol:stonk' as unknown as UnderlyingAsset, + getTokenFeatures('sol', ['custody-bitgo-germany' as CoinFeature, 'custody-bitgo-korea' as CoinFeature]), + AccountCtors.ProgramID.TokenProgramId, + undefined, + undefined, + undefined + ), + AccountCtors.solToken( + 'da2fed8a-81f3-4979-a47b-d5ca5a3da371', + 'sol:hsdt', + 'Solana Company', + 6, + 'HZBEgyBzXBTiJ9B3uaUmNWXmkNhZz5jzwwA6Et4L326J', + 'HZBEgyBzXBTiJ9B3uaUmNWXmkNhZz5jzwwA6Et4L326J', + 'sol:hsdt' as unknown as UnderlyingAsset, + getTokenFeatures('sol', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-switzerland' as CoinFeature, + 'custody-bitgo-sister-trust-one' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + AccountCtors.ProgramID.Token2022ProgramId, + undefined, + undefined, + undefined + ), ]; diff --git a/modules/statics/test/unit/base.ts b/modules/statics/test/unit/base.ts index b76922c1b2..5401b9bf55 100644 --- a/modules/statics/test/unit/base.ts +++ b/modules/statics/test/unit/base.ts @@ -1,4 +1,4 @@ -import { CoinFamily, CoinFeature, coins } from '../../src'; +import { CoinFamily, CoinFeature, Networks, coins } from '../../src'; const should = require('should'); const { UnderlyingAsset } = require('../../src/base'); @@ -374,3 +374,41 @@ describe('Tokenized Equity CoinFeatures', function () { errorMessage.should.containEql('tokenized-equity'); }); }); +describe('ZAMA staking feature', function () { + it('eth:zama should not expose STAKING', function () { + const coin = coins.get('eth:zama'); + coin.features.includes(CoinFeature.STAKING).should.be.false(); + }); + + it('hteth:zamamock should expose correct staking metadata', function () { + const coin = coins.get('hteth:zamamock'); + coin.fullName.should.equal('ZAMAMock'); + coin.decimalPlaces.should.equal(18); + coin.contractAddress.should.equal('0x58713eca04e01114480b30be8ca0d8838f342a55'); + coin.network.name.should.equal(Networks.test.hoodi.name); + coin.features.should.containEql(CoinFeature.STAKING); + }); + + it('ERC-7984 ZAMA tokens should not expose STAKING', function () { + [ + 'eth:czama', + 'eth:cxaut', + 'eth:ctgbp', + 'eth:cweth', + 'eth:cusdt', + 'eth:cusdc', + 'hteth:ctest1', + 'hteth:cusdt', + ].forEach((name) => { + coins.get(name).features.includes(CoinFeature.STAKING).should.be.false(); + }); + }); + + it('stZAMA LSTs should not expose STAKING', function () { + ['hteth:stzamakms', 'hteth:stzamadfns', 'hteth:stzamafig', 'hteth:stzamacop', 'hteth:stzamablco'].forEach( + (name) => { + coins.get(name).features.includes(CoinFeature.STAKING).should.be.false(); + } + ); + }); +}); diff --git a/modules/utxo-bin/package.json b/modules/utxo-bin/package.json index 5f693764bf..9ebd6a12a9 100644 --- a/modules/utxo-bin/package.json +++ b/modules/utxo-bin/package.json @@ -31,7 +31,7 @@ "@bitgo/unspents": "^0.51.10", "@bitgo/utxo-core": "^1.41.3", "@bitgo/utxo-lib": "^11.24.4", - "@bitgo/wasm-utxo": "^5.0.0", + "@bitgo/wasm-utxo": "^5.1.0", "@noble/curves": "1.8.1", "archy": "^1.0.0", "bech32": "^2.0.0", diff --git a/modules/utxo-core/package.json b/modules/utxo-core/package.json index 89d6e77612..cabd132038 100644 --- a/modules/utxo-core/package.json +++ b/modules/utxo-core/package.json @@ -81,7 +81,7 @@ "@bitgo/secp256k1": "^1.11.1", "@bitgo/unspents": "^0.51.10", "@bitgo/utxo-lib": "^11.24.4", - "@bitgo/wasm-utxo": "^5.0.0", + "@bitgo/wasm-utxo": "^5.1.0", "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", "fast-sha256": "^1.3.0" }, diff --git a/modules/utxo-descriptors/package.json b/modules/utxo-descriptors/package.json index 9fdc8f79a5..1b50de59a0 100644 --- a/modules/utxo-descriptors/package.json +++ b/modules/utxo-descriptors/package.json @@ -60,7 +60,7 @@ }, "dependencies": { "@bitgo/utxo-core": "^1.41.3", - "@bitgo/wasm-utxo": "^5.0.0" + "@bitgo/wasm-utxo": "^5.1.0" }, "devDependencies": { "@stacks/bitcoin-staking": "7.6.0" diff --git a/modules/utxo-descriptors/src/pox5/descriptor.ts b/modules/utxo-descriptors/src/pox5/descriptor.ts index 5496040575..146558b31e 100644 --- a/modules/utxo-descriptors/src/pox5/descriptor.ts +++ b/modules/utxo-descriptors/src/pox5/descriptor.ts @@ -57,7 +57,7 @@ function validateParams(params: Pox5LockupDescriptorParams): void { * Build the canonical PoX-5 P2WSH descriptor. The post-CLTV and early-exit * paths share BitGo's standard 2-of-3 compressed-key multisig tail. */ -export function createPox5LockupDescriptor(params: Pox5LockupDescriptorParams): string { +export function createPox5LockupDescriptor(params: Pox5LockupDescriptorParams): Descriptor { validateParams(params); const stakerKeys = params.stakerKeys.map((key, index) => asDescriptorKey(key, `stakerKeys[${index}]`)); const miniscript: ast.MiniscriptNode = { @@ -76,18 +76,17 @@ export function createPox5LockupDescriptor(params: Pox5LockupDescriptorParams): { multi: [2, ...stakerKeys] }, ], }; - return ast.formatNode({ wsh: miniscript }); + const descriptorString = ast.formatNode({ wsh: miniscript }); + return Descriptor.fromString(descriptorString, isBip32Triple(params.stakerKeys) ? 'derivable' : 'definite'); } /** Compile the PoX-5 P2WSH scriptPubKey at a BIP32 derivation index. */ export function createPox5LockupScriptPubKey(params: Pox5LockupDescriptorParams, derivationIndex = 0): Buffer { const descriptor = createPox5LockupDescriptor(params); if (isBip32Triple(params.stakerKeys)) { - return Buffer.from( - Descriptor.fromString(descriptor, 'derivable').atDerivationIndex(derivationIndex).scriptPubkey() - ); + return Buffer.from(descriptor.atDerivationIndex(derivationIndex).scriptPubkey()); } - return Buffer.from(Descriptor.fromString(descriptor, 'definite').scriptPubkey()); + return Buffer.from(descriptor.scriptPubkey()); } /** Derive the compressed staker keys needed to prepare a witness at an index. */ diff --git a/modules/utxo-descriptors/src/pox5/index.ts b/modules/utxo-descriptors/src/pox5/index.ts index f2bc6c47f3..412f53819e 100644 --- a/modules/utxo-descriptors/src/pox5/index.ts +++ b/modules/utxo-descriptors/src/pox5/index.ts @@ -1,2 +1,4 @@ export * from './descriptor'; export * from './parseDescriptor'; +export * from './input'; +export * from './validation'; diff --git a/modules/utxo-descriptors/src/pox5/input.ts b/modules/utxo-descriptors/src/pox5/input.ts new file mode 100644 index 0000000000..4032b3825f --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/input.ts @@ -0,0 +1,69 @@ +import { Descriptor, Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; + +import { Pox5DescriptorInfo, parsePox5LockupDescriptor } from './parseDescriptor'; + +type ResolvedPox5DescriptorInfo = Pox5DescriptorInfo & { + stakerKeys: [Buffer, Buffer, Buffer]; +}; + +export type Pox5DescriptorMatch = { + /** The concrete descriptor whose script matches the input. */ + descriptor: Descriptor; + /** The derivation index used for a wildcard descriptor, if any. */ + index: number | undefined; + info: ResolvedPox5DescriptorInfo; +}; + +/** A canonical PoX-5 descriptor match bound to a native PSBT input. */ +export type Pox5InputMatch = Pox5DescriptorMatch & { + inputIndex: number; +}; + +function getConcreteDescriptor(descriptor: Descriptor, index: number | undefined): Descriptor { + return index === undefined ? descriptor : descriptor.atDerivationIndex(index); +} + +/** + * Find and parse a canonical PoX-5 descriptor for a native PSBT input projection. + * + * Foreign root derivations are ignored by the shared native descriptor matcher. A + * match is returned only when all three staker keys can be resolved at the matched + * descriptor index. + */ +export function findPox5DescriptorForInput( + input: descriptorWallet.PsbtInput, + descriptors: descriptorWallet.DescriptorMap +): Pox5DescriptorMatch | undefined { + try { + const matched = descriptorWallet.findDescriptorForInput(input, descriptors); + if (!matched) { + return undefined; + } + const descriptor = getConcreteDescriptor(matched.descriptor, matched.index); + const info = parsePox5LockupDescriptor(descriptor); + if (!info?.stakerKeys) { + return undefined; + } + return { + descriptor, + index: matched.index, + info: { + ...info, + stakerKeys: info.stakerKeys, + }, + }; + } catch { + return undefined; + } +} + +/** Find and parse a canonical PoX-5 descriptor for one native PSBT input. */ +export function matchPox5Input( + psbt: Psbt, + inputIndex: number, + descriptors: descriptorWallet.DescriptorMap +): Pox5InputMatch | undefined { + const input = psbt.getInputs()[inputIndex]; + const match = input ? findPox5DescriptorForInput(input, descriptors) : undefined; + return match ? { ...match, inputIndex } : undefined; +} diff --git a/modules/utxo-descriptors/src/pox5/parseDescriptor.ts b/modules/utxo-descriptors/src/pox5/parseDescriptor.ts index 2623305a7f..90604e42dc 100644 --- a/modules/utxo-descriptors/src/pox5/parseDescriptor.ts +++ b/modules/utxo-descriptors/src/pox5/parseDescriptor.ts @@ -1,7 +1,8 @@ -import { BIP32, Descriptor, ast } from '@bitgo/wasm-utxo'; -import { Pattern, PatternMatcher } from '@bitgo/utxo-core/descriptor'; +import { BIP32, Descriptor, ast, descriptorWallet } from '@bitgo/wasm-utxo'; -export type ParsedPox5LockupDescriptor = { +type Pattern = descriptorWallet.Pattern; + +export type Pox5DescriptorInfo = { unlockHeight: number; stakerCommitment: Buffer; earlyExitKey: Buffer; @@ -10,6 +11,9 @@ export type ParsedPox5LockupDescriptor = { miniscriptNode: ast.MiniscriptNode; }; +/** @deprecated Use Pox5DescriptorInfo instead. */ +export type ParsedPox5LockupDescriptor = Pox5DescriptorInfo; + const COMPRESSED_KEY = /^(02|03)[0-9a-fA-F]{64}$/; const XPUB_WITH_INDEX = /^([1-9A-HJ-NP-Za-km-z]+)\/(\d+)$/; @@ -53,17 +57,15 @@ function resolveStakerKey(value: string): Buffer | undefined { /** * Parse only the canonical PoX-5 descriptor template. Other descriptors return - * null; malformed fields within the template throw so callers cannot finalize + * undefined; malformed fields within the template throw so callers cannot finalize * a script under an ambiguous policy. */ -export function parsePox5LockupDescriptor( - descriptor: Descriptor | ast.DescriptorNode -): ParsedPox5LockupDescriptor | null { - const matcher = new PatternMatcher(); +export function parsePox5LockupDescriptor(descriptor: Descriptor | ast.DescriptorNode): Pox5DescriptorInfo | undefined { + const matcher = new descriptorWallet.PatternMatcher(); const descriptorNode = descriptor instanceof Descriptor ? ast.fromDescriptor(descriptor) : descriptor; const matched = matcher.match(descriptorNode, { wsh: { $var: 'miniscript' } }); if (!matched) { - return null; + return undefined; } const miniscriptNode = matched.miniscript as ast.MiniscriptNode; @@ -80,7 +82,7 @@ export function parsePox5LockupDescriptor( }; const fields = matcher.match(miniscriptNode, pattern); if (!fields) { - return null; + return undefined; } const unlockHeight = asNumber(fields.unlockHeight, 'after argument'); diff --git a/modules/utxo-descriptors/src/pox5/validation.ts b/modules/utxo-descriptors/src/pox5/validation.ts new file mode 100644 index 0000000000..1bc1eb765a --- /dev/null +++ b/modules/utxo-descriptors/src/pox5/validation.ts @@ -0,0 +1,94 @@ +import { createHash } from 'crypto'; + +import { Psbt, type Descriptor, type PsbtInputKeyValue } from '@bitgo/wasm-utxo'; + +import { Pox5DescriptorInfo, parsePox5LockupDescriptor } from './parseDescriptor'; + +const SHA256_INPUT_KEY = 'PSBT_IN_SHA256'; + +type Sha256InputKeyValue = Extract; + +function isSha256InputKeyValue(keyValue: PsbtInputKeyValue): keyValue is Sha256InputKeyValue { + return keyValue.type === 'known' && keyValue.key === SHA256_INPUT_KEY; +} + +/** + * Read the principal preimage committed by a canonical PoX-5 descriptor from a + * native PSBT input. The PSBT_IN_SHA256 key data is the descriptor commitment. + */ +export function getPox5PrincipalPreimage( + psbt: Psbt, + inputIndex: number, + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode +): Buffer { + const info = getPox5DescriptorInfo(descriptor); + const records = psbt + .getInputKeyValues(inputIndex) + .filter(isSha256InputKeyValue) + .filter((record) => Buffer.from(record.keyData).equals(info.stakerCommitment)); + if (records.length !== 1) { + throw new Error( + `expected exactly one ${SHA256_INPUT_KEY} record matching the descriptor commitment, found ${records.length}` + ); + } + + const [record] = records; + if (record.keyData.length !== 32) { + throw new Error(`${SHA256_INPUT_KEY} digest must be 32 bytes`); + } + if (record.value.length !== 32) { + throw new Error(`${SHA256_INPUT_KEY} preimage must be 32 bytes`); + } + + const preimage = Buffer.from(record.value); + const digest = createHash('sha256').update(preimage).digest(); + if (!digest.equals(Buffer.from(record.keyData))) { + throw new Error(`${SHA256_INPUT_KEY} digest does not match its preimage`); + } + return preimage; +} + +function isPox5DescriptorInfo(value: unknown): value is Pox5DescriptorInfo { + return ( + value !== null && + typeof value === 'object' && + 'stakerCommitment' in value && + Buffer.isBuffer(value.stakerCommitment) + ); +} + +function getPox5DescriptorInfo( + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode +): Pox5DescriptorInfo { + if (isPox5DescriptorInfo(descriptor)) { + return descriptor; + } + const info = parsePox5LockupDescriptor(descriptor); + if (!info) { + throw new Error('descriptor is not a canonical PoX-5 lockup descriptor'); + } + return info; +} + +/** Verify that a principal preimage is committed by a canonical PoX-5 descriptor. */ +export function assertPox5PrincipalPreimage( + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode, + principalPreimage: Uint8Array +): void { + if (principalPreimage.length !== 32) { + throw new Error('principalPreimage must be 32 bytes'); + } + const info = getPox5DescriptorInfo(descriptor); + const digest = createHash('sha256').update(principalPreimage).digest(); + if (!digest.equals(info.stakerCommitment)) { + throw new Error('principalPreimage does not match the descriptor stakerCommitment'); + } +} + +/** Alias for callers that prefer validation terminology. */ +export function validatePox5PrincipalPreimage( + descriptor: Pox5DescriptorInfo | Descriptor | import('@bitgo/wasm-utxo').ast.DescriptorNode, + principalPreimage: Uint8Array +): void { + assertPox5PrincipalPreimage(descriptor, principalPreimage); +} diff --git a/modules/utxo-descriptors/test/unit/pox5/descriptor.ts b/modules/utxo-descriptors/test/unit/pox5/descriptor.ts index dadb181cf9..f72db220a7 100644 --- a/modules/utxo-descriptors/test/unit/pox5/descriptor.ts +++ b/modules/utxo-descriptors/test/unit/pox5/descriptor.ts @@ -93,8 +93,7 @@ describe('PoX-5 lockup descriptors', function () { 0 ); const definite = { ...derivable, stakerKeys }; - const descriptorString = createPox5LockupDescriptor(definite); - const descriptor = Descriptor.fromString(descriptorString, 'definite'); + const descriptor = createPox5LockupDescriptor(definite); const localWitnessScript = asmToScript(descriptor.toAsmString()); const unlockBytes = encodeTwoOfThreeUnlock(stakerKeys); const earlyUnlockBytes = buildUnlockScript(definite.earlyExitKey); @@ -137,7 +136,7 @@ describe('PoX-5 lockup descriptors', function () { it('supports derivation and preserves wildcard keys until an index is selected', function () { const value = params(); - const descriptor = Descriptor.fromString(createPox5LockupDescriptor(value), 'derivable'); + const descriptor = createPox5LockupDescriptor(value); const wildcard = parsePox5LockupDescriptor(descriptor); const derived = parsePox5LockupDescriptor(descriptor.atDerivationIndex(4)); @@ -162,6 +161,6 @@ describe('PoX-5 lockup descriptors', function () { ) ); const validKey = params().earlyExitKey.toString('hex'); - assert.strictEqual(parsePox5LockupDescriptor(Descriptor.fromString(`wsh(pk(${validKey}))`, 'definite')), null); + assert.strictEqual(parsePox5LockupDescriptor(Descriptor.fromString(`wsh(pk(${validKey}))`, 'definite')), undefined); }); }); diff --git a/modules/utxo-descriptors/test/unit/pox5/input.ts b/modules/utxo-descriptors/test/unit/pox5/input.ts new file mode 100644 index 0000000000..e716a98a3a --- /dev/null +++ b/modules/utxo-descriptors/test/unit/pox5/input.ts @@ -0,0 +1,105 @@ +import * as assert from 'assert'; +import { createHash } from 'crypto'; + +import { bip32, descriptorWallet, Psbt } from '@bitgo/wasm-utxo'; +import { getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { + createPox5LockupDescriptor, + derivePox5StakerKeys, + findPox5DescriptorForInput, + matchPox5Input, + Pox5LockupDescriptorParams, +} from '../../../src/pox5'; + +const UNLOCK_HEIGHT = 840_000; +type BIP32Interface = bip32.BIP32Interface; +type Pox5Bip32Params = Omit & { + stakerKeys: [BIP32Interface, BIP32Interface, BIP32Interface]; +}; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function getParams(): Pox5Bip32Params { + const stakerKeys = getKeyTriple('utxo-descriptors-pox5'); + return { + unlockHeight: UNLOCK_HEIGHT, + stakerCommitment: sha256(Buffer.alloc(32, 0x42)), + earlyExitKey: Buffer.from(stakerKeys[0].derive(9).publicKey), + stakerKeys, + }; +} + +function createInputPsbt(descriptor: ReturnType): Psbt { + return descriptorWallet.createPsbt( + { version: 2, locktime: 0 }, + [ + { + hash: '01'.repeat(32), + index: 0, + witnessUtxo: { script: descriptor.scriptPubkey(), value: 100_000n }, + descriptor, + }, + ], + [] + ); +} + +describe('PoX-5 input resolution', function () { + it('matches a definite descriptor without derivation metadata', function () { + const descriptor = createPox5LockupDescriptor({ + ...getParams(), + stakerKeys: derivePox5StakerKeys(getParams().stakerKeys, 0), + }); + const psbt = Psbt.create(2, 0); + psbt.addInput('01'.repeat(32), 0, 100_000n, descriptor.scriptPubkey()); + const match = matchPox5Input(psbt, 0, new Map([['lockup', descriptor]])); + + assert.ok(match); + assert.strictEqual(match.index, undefined); + assert.strictEqual(match.descriptor.toString(), descriptor.toString()); + assert.deepStrictEqual(match.info.stakerKeys, derivePox5StakerKeys(getParams().stakerKeys, 0)); + }); + + it('matches a derived descriptor while ignoring foreign root derivations', function () { + const params = getParams(); + const descriptor = createPox5LockupDescriptor(params); + const concreteDescriptor = descriptor.atDerivationIndex(4); + const psbt = createInputPsbt(concreteDescriptor); + const match = matchPox5Input(psbt, 0, new Map([['lockup', descriptor]])); + + assert.ok(match); + assert.strictEqual(match.index, 4); + assert.strictEqual(match.descriptor.toString(), concreteDescriptor.toString()); + assert.deepStrictEqual(match.info.stakerKeys, derivePox5StakerKeys(params.stakerKeys, 4)); + assert.ok(psbt.getInputs()[0]?.bip32Derivation.some((derivation) => derivation.path === '')); + }); + + it('returns no match when the input has no usable derivation metadata', function () { + const params = getParams(); + const descriptor = createPox5LockupDescriptor(params); + const concreteDescriptor = descriptor.atDerivationIndex(4); + const input = { + witnessUtxo: { script: concreteDescriptor.scriptPubkey(), value: 100_000n }, + bip32Derivation: [], + tapBip32Derivation: [], + }; + + assert.strictEqual(findPox5DescriptorForInput(input, new Map([['lockup', descriptor]])), undefined); + }); + + it('returns no match for a noncanonical descriptor', function () { + const params = getParams(); + const pox5Descriptor = createPox5LockupDescriptor(params); + const psbt = Psbt.create(2, 0); + psbt.addInput('01'.repeat(32), 0, 100_000n, pox5Descriptor.atDerivationIndex(0).scriptPubkey()); + const nonPox5Descriptor = createPox5LockupDescriptor({ + ...params, + unlockHeight: UNLOCK_HEIGHT + 1, + }).atDerivationIndex(0); + + assert.strictEqual(matchPox5Input(psbt, 0, new Map([['other', nonPox5Descriptor]])), undefined); + }); +}); diff --git a/modules/utxo-descriptors/test/unit/pox5/validation.ts b/modules/utxo-descriptors/test/unit/pox5/validation.ts new file mode 100644 index 0000000000..f8ca2fa238 --- /dev/null +++ b/modules/utxo-descriptors/test/unit/pox5/validation.ts @@ -0,0 +1,82 @@ +import * as assert from 'assert'; +import { createHash } from 'crypto'; + +import { Psbt } from '@bitgo/wasm-utxo'; +import { getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { + assertPox5PrincipalPreimage, + createPox5LockupDescriptor, + getPox5PrincipalPreimage, + Pox5LockupDescriptorParams, + parsePox5LockupDescriptor, +} from '../../../src/pox5'; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function getParams(stakerCommitment: Buffer): Pox5LockupDescriptorParams { + const stakerKeys = getKeyTriple('utxo-descriptors-pox5-validation'); + return { + unlockHeight: 840_000, + stakerCommitment, + earlyExitKey: Buffer.from(stakerKeys[0].derive(9).publicKey), + stakerKeys: [ + Buffer.from(stakerKeys[0].publicKey), + Buffer.from(stakerKeys[1].publicKey), + Buffer.from(stakerKeys[2].publicKey), + ], + }; +} + +function createPsbt(): Psbt { + const psbt = Psbt.create(2, 0); + psbt.addInput('01'.repeat(32), 0, 100_000n, new Uint8Array(34)); + psbt.addOutput(new Uint8Array([0x6a]), 0n); + return psbt; +} + +describe('PoX-5 principal preimage validation', function () { + it('extracts and validates the descriptor-committed SHA256 record', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + const psbt = createPsbt(); + psbt.addSha256Preimage(0, preimage); + + assert.deepStrictEqual(getPox5PrincipalPreimage(psbt, 0, descriptor), preimage); + }); + + it('ignores unrelated SHA256 records and rejects a missing committed preimage', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + assert.throws(() => getPox5PrincipalPreimage(createPsbt(), 0, descriptor), /matching the descriptor commitment/); + + const psbt = createPsbt(); + psbt.addSha256Preimage(0, preimage); + psbt.addSha256Preimage(0, Buffer.alloc(32, 0x43)); + assert.deepStrictEqual(getPox5PrincipalPreimage(psbt, 0, descriptor), preimage); + + const unmatchedPsbt = createPsbt(); + unmatchedPsbt.addSha256Preimage(0, Buffer.alloc(32, 0x43)); + assert.throws(() => getPox5PrincipalPreimage(unmatchedPsbt, 0, descriptor), /matching the descriptor commitment/); + }); + + it('rejects malformed SHA256 digest and preimage values', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + const psbt = createPsbt(); + psbt.setInputKV(0, { type: 'unknown', keyType: 0x0b, data: sha256(preimage) }, new Uint8Array(31)); + assert.throws(() => getPox5PrincipalPreimage(psbt, 0, descriptor), /preimage must be 32 bytes/); + }); + + it('checks the principal preimage against the descriptor commitment', function () { + const preimage = Buffer.alloc(32, 0x42); + const descriptor = createPox5LockupDescriptor(getParams(sha256(preimage))); + const info = parsePox5LockupDescriptor(descriptor); + assert.ok(info); + + assert.doesNotThrow(() => assertPox5PrincipalPreimage(info, preimage)); + assert.throws(() => assertPox5PrincipalPreimage(info, Buffer.alloc(32, 0x43)), /does not match/); + }); +}); diff --git a/modules/utxo-ord/package.json b/modules/utxo-ord/package.json index 1138fd6531..c100d5e2b8 100644 --- a/modules/utxo-ord/package.json +++ b/modules/utxo-ord/package.json @@ -45,7 +45,7 @@ "directory": "modules/utxo-ord" }, "dependencies": { - "@bitgo/wasm-utxo": "^5.0.0" + "@bitgo/wasm-utxo": "^5.1.0" }, "devDependencies": { "@bitgo/utxo-lib": "^11.24.4" diff --git a/modules/utxo-staking/package.json b/modules/utxo-staking/package.json index 00dcf9f96f..c9a46bbd98 100644 --- a/modules/utxo-staking/package.json +++ b/modules/utxo-staking/package.json @@ -64,7 +64,7 @@ "@bitgo/utxo-core": "^1.41.3", "@bitgo/utxo-descriptors": "^1.5.3", "@bitgo/utxo-lib": "^11.24.4", - "@bitgo/wasm-utxo": "^5.0.0", + "@bitgo/wasm-utxo": "^5.1.0", "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", "bip322-js": "^2.0.0", "bitcoinjs-lib": "^6.1.7", diff --git a/modules/utxo-staking/src/pox5/index.ts b/modules/utxo-staking/src/pox5/index.ts index 7ba6e34eca..628fcf1d2e 100644 --- a/modules/utxo-staking/src/pox5/index.ts +++ b/modules/utxo-staking/src/pox5/index.ts @@ -1 +1,2 @@ export * from './witness'; +export * from './recovery'; diff --git a/modules/utxo-staking/src/pox5/recovery.ts b/modules/utxo-staking/src/pox5/recovery.ts new file mode 100644 index 0000000000..bed1957431 --- /dev/null +++ b/modules/utxo-staking/src/pox5/recovery.ts @@ -0,0 +1,85 @@ +import { Psbt, Transaction } from '@bitgo/wasm-utxo'; +import { pox5 } from '@bitgo/utxo-descriptors'; + +export const POX5_MAX_UNLOCK_HEIGHT = 500_000_000; + +function assertPox5UnlockHeight(unlockHeight: number): void { + if (!Number.isSafeInteger(unlockHeight) || unlockHeight <= 0 || unlockHeight >= POX5_MAX_UNLOCK_HEIGHT) { + throw new Error(`PoX-5 unlock height must be a positive block height below ${POX5_MAX_UNLOCK_HEIGHT}`); + } +} + +function assertPox5BlockHeightLocktime(lockTime: number): void { + if (!Number.isSafeInteger(lockTime) || lockTime < 0 || lockTime >= POX5_MAX_UNLOCK_HEIGHT) { + throw new Error(`PoX-5 nLockTime must be a block height below ${POX5_MAX_UNLOCK_HEIGHT}`); + } +} + +function getMatchedTransactionInput(psbt: Psbt, match: pox5.Pox5InputMatch) { + const psbtInput = psbt.getInputs()[match.inputIndex]; + const transactionInput = Transaction.fromBytes(psbt.getUnsignedTx()).getInputs()[match.inputIndex]; + const descriptorScript = Buffer.from(match.descriptor.scriptPubkey()); + if ( + !psbtInput?.witnessUtxo || + !transactionInput || + !Buffer.from(psbtInput.witnessUtxo.script).equals(descriptorScript) + ) { + throw new Error(`PoX-5 descriptor match does not match PSBT input ${match.inputIndex}`); + } + return transactionInput; +} + +/** Validate the post-CLTV policy for all canonical PoX-5 inputs in a recovery PSBT. */ +export function assertPox5LocktimeSpend(psbt: Psbt, inputs: readonly pox5.Pox5InputMatch[]): void { + if (inputs.length === 0) { + throw new Error('PoX-5 lockup descriptor match is required'); + } + + const unlockHeights = inputs.map((input) => { + const { unlockHeight } = input.info; + assertPox5UnlockHeight(unlockHeight); + return unlockHeight; + }); + const lockTime = psbt.lockTime(); + assertPox5BlockHeightLocktime(lockTime); + const requiredLockTime = Math.max(...unlockHeights); + if (lockTime < requiredLockTime) { + throw new Error(`PoX-5 nLockTime must be at least ${requiredLockTime}`); + } + for (const input of inputs) { + if (getMatchedTransactionInput(psbt, input).sequence === 0xffffffff) { + throw new Error(`PoX-5 locktime spend input ${input.inputIndex} must use a non-final sequence`); + } + } +} + +/** Validate that a canonical PoX-5 input can use the principal-preimage branch. */ +export function assertPox5EarlyExitSpend(psbt: Psbt, input: pox5.Pox5InputMatch): void { + getMatchedTransactionInput(psbt, input); +} + +export type Pox5SpendBranch = 'locktime' | 'early-exit'; + +/** Classify a PoX-5 spend from native principal-preimage metadata. */ +export function classifyPox5Spend(psbt: Psbt, input: pox5.Pox5InputMatch): Pox5SpendBranch { + getMatchedTransactionInput(psbt, input); + const hasPrincipalPreimage = psbt + .getInputKeyValues(input.inputIndex) + .some((record) => record.type === 'known' && record.key === 'PSBT_IN_SHA256'); + return hasPrincipalPreimage ? 'early-exit' : 'locktime'; +} + +/** Add validated principal-preimage metadata for an early-exit spend. */ +export function preparePox5EarlyExit( + psbt: Psbt, + inputIndex: number, + input: pox5.Pox5InputMatch, + principalPreimage: Uint8Array +): void { + if (inputIndex !== input.inputIndex) { + throw new Error(`PoX-5 descriptor match belongs to PSBT input ${input.inputIndex}, not ${inputIndex}`); + } + assertPox5EarlyExitSpend(psbt, input); + pox5.assertPox5PrincipalPreimage(input.info, principalPreimage); + psbt.addSha256Preimage(inputIndex, principalPreimage); +} diff --git a/modules/utxo-staking/src/pox5/witness.ts b/modules/utxo-staking/src/pox5/witness.ts index fcddb2a5fc..80bb3848d3 100644 --- a/modules/utxo-staking/src/pox5/witness.ts +++ b/modules/utxo-staking/src/pox5/witness.ts @@ -1,44 +1,24 @@ -import { createHash } from 'crypto'; - -import { ast, Descriptor, Psbt } from '@bitgo/wasm-utxo'; +import { Psbt } from '@bitgo/wasm-utxo'; import { pox5 } from '@bitgo/utxo-descriptors'; -type Pox5Descriptor = Descriptor | ast.DescriptorNode; +import { assertPox5LocktimeSpend, preparePox5EarlyExit } from './recovery'; export type Pox5FinalizerParams = { - /** A definite or derivation-indexed canonical PoX-5 descriptor. */ - descriptor: Pox5Descriptor; - /** The derived user, backup, and BitGo keys in descriptor order. */ - stakerKeys: [Buffer, Buffer, Buffer]; + /** The canonical descriptor match for the input being finalized. */ + match: pox5.Pox5InputMatch; }; -function getParsedDescriptor(params: Pox5FinalizerParams) { - const parsed = pox5.parsePox5LockupDescriptor(params.descriptor); - if (!parsed || !parsed.stakerKeys) { - throw new Error('descriptor must be a definite or derivation-indexed canonical PoX-5 descriptor'); - } - if (!parsed.stakerKeys.every((key, index) => key.equals(params.stakerKeys[index]))) { - throw new Error('stakerKeys must match the canonical descriptor order'); +function assertFinalizerInputIndex(inputIndex: number, match: pox5.Pox5InputMatch): void { + if (inputIndex !== match.inputIndex) { + throw new Error(`PoX-5 descriptor match belongs to PSBT input ${match.inputIndex}, not ${inputIndex}`); } - return parsed; -} - -function getDescriptor(descriptor: Pox5Descriptor): Descriptor { - return descriptor instanceof Descriptor ? descriptor : Descriptor.fromString(ast.formatNode(descriptor), 'definite'); -} - -function prepareInput(psbt: Psbt, inputIndex: number, params: Pox5FinalizerParams) { - const parsed = getParsedDescriptor(params); - psbt.updateInputWithDescriptor(inputIndex, getDescriptor(params.descriptor)); - return parsed; } /** Finalize the post-CLTV 2-of-3 PoX-5 spend branch. */ export function finalizePox5LocktimePath(psbt: Psbt, inputIndex: number, params: Pox5FinalizerParams): void { - const parsed = prepareInput(psbt, inputIndex, params); - if (psbt.lockTime() < parsed.unlockHeight) { - throw new Error(`transaction locktime must be at least ${parsed.unlockHeight}`); - } + assertFinalizerInputIndex(inputIndex, params.match); + assertPox5LocktimeSpend(psbt, [params.match]); + psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor); psbt.finalizeInput(inputIndex); } @@ -46,13 +26,10 @@ export function finalizePox5LocktimePath(psbt: Psbt, inputIndex: number, params: export function finalizePox5EarlyExitPath( psbt: Psbt, inputIndex: number, - params: Pox5FinalizerParams & { principalPreimage: Buffer } + params: Pox5FinalizerParams & { principalPreimage: Uint8Array } ): void { - const parsed = prepareInput(psbt, inputIndex, params); - const preimageHash = createHash('sha256').update(params.principalPreimage).digest(); - if (!preimageHash.equals(parsed.stakerCommitment)) { - throw new Error('principalPreimage does not match the descriptor stakerCommitment'); - } - psbt.addSha256Preimage(inputIndex, params.principalPreimage); + assertFinalizerInputIndex(inputIndex, params.match); + preparePox5EarlyExit(psbt, inputIndex, params.match, params.principalPreimage); + psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor); psbt.finalizeInput(inputIndex); } diff --git a/modules/utxo-staking/test/unit/pox5/recovery.ts b/modules/utxo-staking/test/unit/pox5/recovery.ts new file mode 100644 index 0000000000..4570da2786 --- /dev/null +++ b/modules/utxo-staking/test/unit/pox5/recovery.ts @@ -0,0 +1,122 @@ +import assert from 'assert/strict'; +import { createHash } from 'crypto'; + +import { pox5 } from '@bitgo/utxo-descriptors'; +import { Psbt, type Descriptor } from '@bitgo/wasm-utxo'; +import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils'; + +import { + assertPox5EarlyExitSpend, + assertPox5LocktimeSpend, + classifyPox5Spend, + POX5_MAX_UNLOCK_HEIGHT, + preparePox5EarlyExit, +} from '../../../src/pox5'; + +type Pox5InputMatch = pox5.Pox5InputMatch; + +const UNLOCK_HEIGHT = 840_000; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +function createPox5RecoveryPsbt( + lockTime: number, + sequence = 0xfffffffe, + unlockHeight = UNLOCK_HEIGHT +): { + psbt: Psbt; + match: Pox5InputMatch; + principalPreimage: Buffer; +} { + const [user, backup, bitgo] = getKeyTriple('utxo-staking-pox5-recovery'); + const earlyExit = getKey('utxo-staking-pox5-recovery-early-exit'); + const principalPreimage = Buffer.alloc(32, 0x42); + const descriptor = pox5.createPox5LockupDescriptor({ + unlockHeight, + stakerCommitment: sha256(principalPreimage), + earlyExitKey: Buffer.from(earlyExit.publicKey), + stakerKeys: [Buffer.from(user.publicKey), Buffer.from(backup.publicKey), Buffer.from(bitgo.publicKey)], + }); + const concreteDescriptor = descriptor as Descriptor; + const psbt = Psbt.create(2, lockTime); + psbt.addInput('01'.repeat(32), 0, 100_000n, concreteDescriptor.scriptPubkey(), sequence); + psbt.addOutput(concreteDescriptor.scriptPubkey(), 90_000n); + psbt.updateInputWithDescriptor(0, concreteDescriptor); + + const match = pox5.matchPox5Input(psbt, 0, new Map([['pox5', concreteDescriptor]])); + assert.ok(match); + return { psbt, match: match as Pox5InputMatch, principalPreimage }; +} + +describe('PoX-5 spend policy', function () { + it('allows the early-exit branch regardless of nLockTime', function () { + const locktimeSpend = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + const earlyExitSpend = createPox5RecoveryPsbt(0); + const timestampLocktime = createPox5RecoveryPsbt(POX5_MAX_UNLOCK_HEIGHT); + + assert.doesNotThrow(() => assertPox5EarlyExitSpend(locktimeSpend.psbt, locktimeSpend.match)); + assert.doesNotThrow(() => assertPox5EarlyExitSpend(earlyExitSpend.psbt, earlyExitSpend.match)); + assert.doesNotThrow(() => assertPox5EarlyExitSpend(timestampLocktime.psbt, timestampLocktime.match)); + }); + + it('classifies the spend from native principal-preimage metadata', function () { + const locktimeSpend = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + assert.equal(classifyPox5Spend(locktimeSpend.psbt, locktimeSpend.match), 'locktime'); + + const earlyExitSpend = createPox5RecoveryPsbt(0); + preparePox5EarlyExit(earlyExitSpend.psbt, 0, earlyExitSpend.match, earlyExitSpend.principalPreimage); + assert.equal(classifyPox5Spend(earlyExitSpend.psbt, earlyExitSpend.match), 'early-exit'); + }); + + it('enforces the block-height and unlock-height boundaries', function () { + const atHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + const aboveHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT + 1); + const belowHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT - 1); + const timestampLocktime = createPox5RecoveryPsbt(POX5_MAX_UNLOCK_HEIGHT); + + assert.doesNotThrow(() => assertPox5LocktimeSpend(atHeight.psbt, [atHeight.match])); + assert.doesNotThrow(() => assertPox5LocktimeSpend(aboveHeight.psbt, [aboveHeight.match])); + assert.throws(() => assertPox5LocktimeSpend(belowHeight.psbt, [belowHeight.match]), /at least/); + assert.throws( + () => assertPox5LocktimeSpend(timestampLocktime.psbt, [timestampLocktime.match]), + /block height below/ + ); + }); + + it('requires non-final sequences for locktime spends', function () { + const final = createPox5RecoveryPsbt(UNLOCK_HEIGHT, 0xffffffff); + const nonFinal = createPox5RecoveryPsbt(UNLOCK_HEIGHT, 0xfffffffe); + + assert.throws(() => assertPox5LocktimeSpend(final.psbt, [final.match]), /non-final sequence/); + assert.doesNotThrow(() => assertPox5LocktimeSpend(nonFinal.psbt, [nonFinal.match])); + }); + + it('allows final sequences on unrelated transaction inputs', function () { + const { psbt, match } = createPox5RecoveryPsbt(UNLOCK_HEIGHT); + psbt.addInput('02'.repeat(32), 0, 10_000n, new Uint8Array([0x51]), 0xffffffff); + + assert.doesNotThrow(() => assertPox5LocktimeSpend(psbt, [match])); + }); + + it('requires an input match to remain bound to its original PSBT input', function () { + const { psbt, match, principalPreimage } = createPox5RecoveryPsbt(0); + + assert.throws(() => preparePox5EarlyExit(psbt, 1, match, principalPreimage), /belongs to PSBT input/); + }); + + it('adds a validated principal preimage through the native PSBT API', function () { + const earlyExitSpend = createPox5RecoveryPsbt(0); + + preparePox5EarlyExit(earlyExitSpend.psbt, 0, earlyExitSpend.match, earlyExitSpend.principalPreimage); + + const records = earlyExitSpend.psbt + .getInputKeyValues(0) + .filter((record) => record.type === 'known' && record.key === 'PSBT_IN_SHA256'); + assert.equal(records.length, 1); + const [record] = records; + assert.deepStrictEqual(Buffer.from(record.keyData), sha256(earlyExitSpend.principalPreimage)); + assert.deepStrictEqual(Buffer.from(record.value), earlyExitSpend.principalPreimage); + }); +}); diff --git a/modules/utxo-staking/test/unit/pox5/witness.ts b/modules/utxo-staking/test/unit/pox5/witness.ts index 28d89a78b3..4529b275b0 100644 --- a/modules/utxo-staking/test/unit/pox5/witness.ts +++ b/modules/utxo-staking/test/unit/pox5/witness.ts @@ -2,10 +2,12 @@ import * as assert from 'assert'; import { createHash } from 'crypto'; import { pox5 } from '@bitgo/utxo-descriptors'; -import { Descriptor, Psbt } from '@bitgo/wasm-utxo'; +import { Psbt, type Descriptor } from '@bitgo/wasm-utxo'; import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils'; -import { finalizePox5EarlyExitPath, finalizePox5LocktimePath, Pox5FinalizerParams } from '../../../src/pox5'; +import { finalizePox5EarlyExitPath, finalizePox5LocktimePath, type Pox5FinalizerParams } from '../../../src/pox5'; + +type Pox5InputMatch = pox5.Pox5InputMatch; const UNLOCK_HEIGHT = 840_000; @@ -29,24 +31,22 @@ function createPox5Psbt( Buffer, Buffer ]; - const params: Pox5FinalizerParams = { - descriptor: Descriptor.fromString( - pox5.createPox5LockupDescriptor({ - unlockHeight: UNLOCK_HEIGHT, - stakerCommitment: sha256(principalPreimage), - earlyExitKey: Buffer.from(earlyExit.publicKey), - stakerKeys, - }), - 'definite' - ), + const descriptor = pox5.createPox5LockupDescriptor({ + unlockHeight: UNLOCK_HEIGHT, + stakerCommitment: sha256(principalPreimage), + earlyExitKey: Buffer.from(earlyExit.publicKey), stakerKeys, - }; - const descriptor = params.descriptor as Descriptor; - const scriptPubKey = descriptor.scriptPubkey(); + }); + const paramsDescriptor = descriptor as Descriptor; + const scriptPubKey = paramsDescriptor.scriptPubkey(); const psbt = Psbt.create(2, lockTime); psbt.addInput('01'.repeat(32), 0, 100_000n, scriptPubKey, 0xfffffffe); psbt.addOutput(scriptPubKey, 90_000n); - psbt.updateInputWithDescriptor(0, descriptor); + psbt.updateInputWithDescriptor(0, paramsDescriptor); + + const match = pox5.matchPox5Input(psbt, 0, new Map([['pox5', paramsDescriptor]])); + assert.ok(match); + const params: Pox5FinalizerParams = { match: match as Pox5InputMatch }; for (const key of includeEarlyExitSignature ? [user, backup, earlyExit] : [user, backup]) { assert.ok(key.privateKey, 'test key must include private key material'); diff --git a/yarn.lock b/yarn.lock index b6416757d3..2f9d5f283e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1052,10 +1052,10 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-ton/-/wasm-ton-1.1.1.tgz" integrity sha512-Y4x2V2ZcYWlmx42v7dlrKDtT2DuUt8smk8E98mh7RhpiifJhLk2v5RmXDwBl0A3v9TzUOU6qMOnSS/iZ8Pq52w== -"@bitgo/wasm-utxo@^5.0.0": - version "5.0.0" - resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-5.0.0.tgz" - integrity sha512-9QGC5bt0Dno2ugOmWDF1GxM2kCRZxDhlqAwbCHVL2Yni7qKoZg35AD9+F+qDSp/qShT7pZpTpguofotf+oWbBQ== +"@bitgo/wasm-utxo@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-5.1.0.tgz" + integrity sha512-0qCAFmiDW6ZthbMG6sPkyL2c+RHU8T2zSRGMzeDEjNz48O9h2wBcATNmFQ5q+QutpKjbdIa/gvYF0UeyQHMs7Q== "@brandonblack/musig@^0.0.1-alpha.0": version "0.0.1-alpha.1"