From 8360a49ea5b0163c16c63670005c53d3cffaa767 Mon Sep 17 00:00:00 2001 From: "asset-metadata-bot[bot]" <226385837+asset-metadata-bot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:58:42 +0000 Subject: [PATCH 01/24] feat: add new tokens from AMS API CSHLD-000 --- modules/statics/src/coins/botOfcTokens.ts | 12 ++++++++++++ modules/statics/src/coins/botTokens.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/modules/statics/src/coins/botOfcTokens.ts b/modules/statics/src/coins/botOfcTokens.ts index aa5b206e2a..f1311e0f3d 100644 --- a/modules/statics/src/coins/botOfcTokens.ts +++ b/modules/statics/src/coins/botOfcTokens.ts @@ -145,4 +145,16 @@ export const botOfcTokens = [ undefined, undefined ), + AccountCtors.ofcerc20( + '4f06be6b-081c-45cd-8d13-77f88e735ebc', + 'ofceth:usdds', + 'Usdd Stablecoin', + 18, + 'eth:usdds' 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 f359faf7de..06596347ab 100644 --- a/modules/statics/src/coins/botTokens.ts +++ b/modules/statics/src/coins/botTokens.ts @@ -1439,4 +1439,20 @@ export const botTokens = [ undefined, undefined ), + AccountCtors.erc20( + '29db2da1-2c00-4b31-9974-73b981ec3b3e', + 'eth:usdds', + 'Usdd Stablecoin', + 18, + '0x4f8e5de400de08b164e7421b3ee387f461becd1a', + 'eth:usdds' as unknown as UnderlyingAsset, + getTokenFeatures('eth', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + undefined, + undefined, + undefined + ), ]; From 83a050c8dff3ec120920dfba36d66964167d138a Mon Sep 17 00:00:00 2001 From: rajangarg047 Date: Thu, 11 Jun 2026 11:10:30 -0400 Subject: [PATCH 02/24] refactor(sdk-core): extract deriveMPCWalletAddress from verifyMPCWalletAddress verifyMPCWalletAddress already computed the expected address internally and then compared it to a candidate. Extract that derivation half into a reusable deriveMPCWalletAddress() that returns the derived address and the HD path used, and have verifyMPCWalletAddress() delegate to it before comparing. This lets callers produce an MPC wallet address offline (public keys only) using the exact same code path as verification, so derive and verify can never diverge. Behavior of verifyMPCWalletAddress is unchanged. Adds unit coverage for deriveMPCWalletAddress (ed25519 MPCv2, SMC prefix path, secp256k1) including a derive->verify round-trip assertion. WCN-913 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bitgo/utils/tss/addressVerification.ts | 70 ++++++++++++----- .../bitgo/utils/tss/addressVerification.ts | 76 +++++++++++++++++++ 2 files changed, 128 insertions(+), 18 deletions(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts index 20e1a75332..203d97c30e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts @@ -49,28 +49,35 @@ export async function verifyEddsaTssWalletAddress( } /** - * Verifies if an address belongs to a wallet using ECDSA TSS MPC derivation. - * This is a common implementation for ECDSA-based MPC coins (ETH, BTC, etc.) + * Options for deriving an MPC wallet address. This is the subset of + * {@link TssVerifyAddressOptions} needed to *produce* an address (no `address` to check), + * plus the key curve. + */ +export type DeriveMPCWalletAddressOptions = Pick< + TssVerifyAddressOptions, + 'keychains' | 'index' | 'derivedFromParentWithSeed' | 'multisigTypeVersion' +> & { + keyCurve: 'secp256k1' | 'ed25519'; +}; + +/** + * Derives a wallet address using TSS/MPC HD derivation from the commonKeychain, + * using public key material only (no private keys, no network access). + * Shared by EdDSA- and ECDSA-based MPC coins. * - * @param params - Verification options including keychains, address, and derivation index - * @param isValidAddress - Coin-specific function to validate address format + * This is the derivation half of {@link verifyMPCWalletAddress}: it *produces* the address + * rather than comparing it against a candidate, so the two can never diverge. + * + * @param params - keychains (commonKeychain), derivation index, optional seed/version, and keyCurve * @param getAddressFromPublicKey - Coin-specific function to convert public key to address - * @returns true if the address matches the derived address, false otherwise - * @throws {InvalidAddressError} if the address is invalid + * @returns the derived address and the HD derivation path used to derive it * @throws {Error} if required parameters are missing or invalid */ -export async function verifyMPCWalletAddress( - params: TssVerifyAddressOptions & { - keyCurve: 'secp256k1' | 'ed25519'; - }, - isValidAddress: (address: string) => boolean, +export async function deriveMPCWalletAddress( + params: DeriveMPCWalletAddressOptions, getAddressFromPublicKey: (publicKey: string) => string -): Promise { - const { keychains, address, index, derivedFromParentWithSeed } = params; - - if (!isValidAddress(address)) { - throw new InvalidAddressError(`invalid address: ${address}`); - } +): Promise<{ address: string; derivationPath: string }> { + const { keychains, index, derivedFromParentWithSeed } = params; const commonKeychain = extractCommonKeychain(keychains); @@ -93,7 +100,34 @@ export async function verifyMPCWalletAddress( const publicKeySize = params.keyCurve === 'secp256k1' ? 33 : 32; const publicKeyOnly = Buffer.from(derivedPublicKey, 'hex').subarray(0, publicKeySize).toString('hex'); - const expectedAddress = getAddressFromPublicKey(publicKeyOnly); + return { address: getAddressFromPublicKey(publicKeyOnly), derivationPath }; +} + +/** + * Verifies if an address belongs to a wallet using ECDSA TSS MPC derivation. + * This is a common implementation for ECDSA-based MPC coins (ETH, BTC, etc.) + * + * @param params - Verification options including keychains, address, and derivation index + * @param isValidAddress - Coin-specific function to validate address format + * @param getAddressFromPublicKey - Coin-specific function to convert public key to address + * @returns true if the address matches the derived address, false otherwise + * @throws {InvalidAddressError} if the address is invalid + * @throws {Error} if required parameters are missing or invalid + */ +export async function verifyMPCWalletAddress( + params: TssVerifyAddressOptions & { + keyCurve: 'secp256k1' | 'ed25519'; + }, + isValidAddress: (address: string) => boolean, + getAddressFromPublicKey: (publicKey: string) => string +): Promise { + const { address } = params; + + if (!isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const { address: expectedAddress } = await deriveMPCWalletAddress(params, getAddressFromPublicKey); return address === expectedAddress; } diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts index 8f72b179e9..c9b84c4d0b 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts @@ -10,6 +10,7 @@ function getAddressVerificationModule() { const getExtractCommonKeychain = () => getAddressVerificationModule().extractCommonKeychain; const getVerifyEddsaTssWalletAddress = () => getAddressVerificationModule().verifyEddsaTssWalletAddress; const getVerifyMPCWalletAddress = () => getAddressVerificationModule().verifyMPCWalletAddress; +const getDeriveMPCWalletAddress = () => getAddressVerificationModule().deriveMPCWalletAddress; // RFC 8032 test vector: known valid Ed25519 public key + arbitrary chaincode = 128 hex chars. const TEST_PK = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'; @@ -244,3 +245,78 @@ describe('verifyMPCWalletAddress - ECDSA (secp256k1)', function () { result.should.be.false(); }); }); + +describe('deriveMPCWalletAddress', function () { + const getAddressFromPublicKey = (pk: string) => pk; + + describe('ed25519 (MPCv2)', function () { + const keychains = [{ commonKeychain: TEST_KEYCHAIN }, { commonKeychain: TEST_KEYCHAIN }]; + + it('derives the address and path for the simple m/{index} path', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, 'm/3').slice(0, 64); + + const result = await deriveMPCWalletAddress( + { keychains, index: 3, multisigTypeVersion: 'MPCv2', keyCurve: 'ed25519' }, + getAddressFromPublicKey + ); + + result.address.should.equal(expectedAddress); + result.derivationPath.should.equal('m/3'); + }); + + it('uses the SMC prefix path when derivedFromParentWithSeed is set', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const seed = 'smc-seed-123'; + const prefix = getDerivationPath(seed); + const expectedAddress = deriveUnhardenedMps(TEST_KEYCHAIN, `${prefix}/0`).slice(0, 64); + + const result = await deriveMPCWalletAddress( + { keychains, index: 0, multisigTypeVersion: 'MPCv2', derivedFromParentWithSeed: seed, keyCurve: 'ed25519' }, + getAddressFromPublicKey + ); + + result.address.should.equal(expectedAddress); + result.derivationPath.should.equal(`${prefix}/0`); + }); + }); + + describe('secp256k1', function () { + const ecdsaKeychains = [{ commonKeychain: ECDSA_KEYCHAIN }, { commonKeychain: ECDSA_KEYCHAIN }]; + + it('derives the secp256k1 address (33-byte pubkey)', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const expectedAddress = new Ecdsa().deriveUnhardened(ECDSA_KEYCHAIN, 'm/2').slice(0, 66); + + const result = await deriveMPCWalletAddress( + { keychains: ecdsaKeychains, index: 2, keyCurve: 'secp256k1' }, + getAddressFromPublicKey + ); + + result.address.should.equal(expectedAddress); + result.derivationPath.should.equal('m/2'); + }); + }); + + describe('round-trip with verifyMPCWalletAddress', function () { + it('an address produced by deriveMPCWalletAddress verifies as true', async function () { + const deriveMPCWalletAddress = getDeriveMPCWalletAddress(); + const verifyMPCWalletAddress = getVerifyMPCWalletAddress(); + const ecdsaKeychains = [{ commonKeychain: ECDSA_KEYCHAIN }, { commonKeychain: ECDSA_KEYCHAIN }]; + const isValidEcdsaAddress = (addr: string) => addr.length === 66; + + const { address } = await deriveMPCWalletAddress( + { keychains: ecdsaKeychains, index: 7, keyCurve: 'secp256k1' }, + getAddressFromPublicKey + ); + + const verified = await verifyMPCWalletAddress( + { address, keychains: ecdsaKeychains, index: 7, keyCurve: 'secp256k1' }, + isValidEcdsaAddress, + getAddressFromPublicKey + ); + + verified.should.be.true(); + }); + }); +}); From c17f6b44093426ae0ccdb5922bd396cf73952a45 Mon Sep 17 00:00:00 2001 From: Bhavi Dhingra Date: Thu, 11 Jun 2026 21:49:37 +0530 Subject: [PATCH 03/24] fix(sdk-coin-trx): allow TSS TRC20 consolidation without recipients TICKET: COINS-392 --- commitlint.config.js | 1 + modules/sdk-coin-trx/src/trxToken.ts | 21 +++++++++++++-------- modules/sdk-coin-trx/test/unit/trxToken.ts | 16 +++++++--------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/commitlint.config.js b/commitlint.config.js index d7817d01f8..a886231017 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -75,6 +75,7 @@ module.exports = { 'WCN-', 'WCI-', 'COIN-', + 'COINS-', 'COINFLP-', 'FIAT-', 'ME-', diff --git a/modules/sdk-coin-trx/src/trxToken.ts b/modules/sdk-coin-trx/src/trxToken.ts index d824b9c16e..75bfa659cc 100644 --- a/modules/sdk-coin-trx/src/trxToken.ts +++ b/modules/sdk-coin-trx/src/trxToken.ts @@ -100,8 +100,10 @@ export class TrxToken extends Trx { if (walletType === 'tss') { // For TSS wallets, TRC20 token transfers are TriggerSmartContract transactions. - // Decode the transaction and validate destination address and amount against - // txParams.recipients before signing to ensure intent matches the prebuild. + // Always verify structure and ABI decodability. Intent validation (address + amount + // comparison) is performed only when recipients are present — absent recipients + // indicates a server-determined transfer (e.g. consolidation) where the server + // owns the intent. const rawDataHex = this.extractRawDataHex(txPrebuild.txHex); const decodedTx = Utils.decodeTransaction(rawDataHex); @@ -110,7 +112,6 @@ export class TrxToken extends Trx { `Expected TriggerSmartContract for TRC20 token transfer, got contract type: ${decodedTx.contractType}` ); } - if (!Array.isArray(decodedTx.contract) || decodedTx.contract.length !== 1) { throw new Error('Invalid TriggerSmartContract structure'); } @@ -119,11 +120,6 @@ export class TrxToken extends Trx { // data is base64-encoded from protobuf decoding; convert to hex for decodeDataParams const contractData = Buffer.from(triggerContract.parameter.value.data, 'base64').toString('hex'); - const recipients = txParams.recipients || (txPrebuild.txInfo as TronTxInfo).recipients; - if (!recipients || recipients.length !== 1) { - throw new Error('missing or invalid required property recipients'); - } - let recipientHex: string; let transferAmount: { toString(): string }; try { @@ -135,6 +131,15 @@ export class TrxToken extends Trx { throw new Error(`Failed to decode TRC20 transfer ABI data: ${e instanceof Error ? e.message : String(e)}`); } + const recipients = txParams.recipients || (txPrebuild.txInfo as TronTxInfo | undefined)?.recipients; + if (!recipients || recipients.length === 0) { + // No recipients — server-determined transfer (e.g. consolidation); structural check above is sufficient. + return true; + } + if (recipients.length !== 1) { + throw new Error('invalid required property recipients'); + } + // recipientHex has '41' hex prefix; convert to base58 for comparison const actualDestination = Utils.getBase58AddressFromHex(recipientHex); const actualAmount = transferAmount.toString(); diff --git a/modules/sdk-coin-trx/test/unit/trxToken.ts b/modules/sdk-coin-trx/test/unit/trxToken.ts index 37d1fc78eb..7238289ef1 100644 --- a/modules/sdk-coin-trx/test/unit/trxToken.ts +++ b/modules/sdk-coin-trx/test/unit/trxToken.ts @@ -83,15 +83,13 @@ describe('TrxToken verifyTransaction:', function () { ); }); - it('should throw when recipients is empty', async function () { - await assert.rejects( - tokenCoin.verifyTransaction({ - txPrebuild: { txHex: TRC20_RAW_DATA_HEX }, - txParams: { recipients: [] }, - walletType: 'tss', - } as any), - { message: 'missing or invalid required property recipients' } - ); + it('should return true when recipients is empty array (consolidation path)', async function () { + const result = await tokenCoin.verifyTransaction({ + txPrebuild: { txHex: TRC20_RAW_DATA_HEX }, + txParams: { recipients: [] }, + walletType: 'tss', + } as any); + assert.strictEqual(result, true); }); it('should throw when contract type is not TriggerSmartContract', async function () { From 9a5888ddbbcbddcd42fce4a6c53f8b9c9d3a24b5 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Thu, 11 Jun 2026 15:37:16 -0400 Subject: [PATCH 04/24] feat: add 'advanced' wallet generation in type Ticket: WCN-685 --- modules/sdk-core/src/bitgo/wallet/iWallets.ts | 2 +- .../test/unit/bitgo/wallet/walletsExternalSigner.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index a277c0c771..ae233c4346 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -101,7 +101,7 @@ export interface GenerateWalletOptions { isDistributedCustody?: boolean; bitgoKeyId?: string; commonKeychain?: string; - type?: 'hot' | 'cold' | 'custodial' | 'trading'; + type?: 'hot' | 'cold' | 'custodial' | 'trading' | 'advanced'; subType?: 'lightningCustody' | 'lightningSelfCustody'; evmKeyRingReferenceWalletId?: string; lightningProvider?: 'amboss' | 'voltage'; diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts index 428a197918..1547e515ec 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts @@ -102,6 +102,18 @@ describe('Wallets - external signer onchain wallet generation', function () { assert.strictEqual(result.bitgoKeychain.pub, bitgoPub); }); + it('should pass advanced wallet type through to wallet/add', async function () { + await wallets.generateWalletWithExternalSigner({ + label: 'Advanced Wallet', + enterprise: 'enterprise-id', + type: 'advanced', + createKeychainCallback, + }); + + const walletBody = sendStub.firstCall.args[0]; + walletBody.type.should.equal('advanced'); + }); + it('should reject when callback source does not match requested source', async function () { createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ pub: userPub, From b1583312194d7d6700a6a231f47110542c924e26 Mon Sep 17 00:00:00 2001 From: Abhijeet Biradar Date: Fri, 12 Jun 2026 02:52:40 +0530 Subject: [PATCH 05/24] feat(sdk-coin-canton): handle ISO timestamp vs microsecond mismatch Ticket: SCAAS-9727 --- modules/sdk-coin-canton/src/lib/utils.ts | 22 ++++++++ modules/sdk-coin-canton/test/unit/utils.ts | 60 ++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/modules/sdk-coin-canton/src/lib/utils.ts b/modules/sdk-coin-canton/src/lib/utils.ts index 13cecca6a3..2b01c3f394 100644 --- a/modules/sdk-coin-canton/src/lib/utils.ts +++ b/modules/sdk-coin-canton/src/lib/utils.ts @@ -637,6 +637,17 @@ export class Utils implements BaseUtils { } return; } + // ISO 8601 input vs microsecond-since-epoch encoding in the prepared-transaction protobuf. + if (this.isIsoTimestamp(expected) && this.isIntegerString(actual)) { + const expectedMicroseconds = new BigNumber(new Date(expected).getTime()).multipliedBy(1000); + if (!expectedMicroseconds.isEqualTo(new BigNumber(actual))) { + throw new Error( + `Canton command timestamp mismatch at '${currentPath || ''}': ` + + `expected '${expected}' (${expectedMicroseconds.toFixed(0)} µs), got '${actual}'` + ); + } + return; + } if (expected !== actual) { throw new Error( `Canton command mismatch at '${currentPath || ''}': expected '${expected}', got '${actual}'` @@ -854,6 +865,17 @@ export class Utils implements BaseUtils { return numericRe.test(a) && numericRe.test(b); } + private isIsoTimestamp(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value)) { + return false; + } + return Number.isFinite(Date.parse(value)); + } + + private isIntegerString(value: string): boolean { + return /^\d+$/.test(value); + } + private describeType(value: unknown): string { if (value === null) return 'null'; if (Array.isArray(value)) return 'array'; diff --git a/modules/sdk-coin-canton/test/unit/utils.ts b/modules/sdk-coin-canton/test/unit/utils.ts index 4cb21adcc1..d2cbc6302b 100644 --- a/modules/sdk-coin-canton/test/unit/utils.ts +++ b/modules/sdk-coin-canton/test/unit/utils.ts @@ -508,5 +508,65 @@ describe('Canton Utils - CantonCommand helpers', function () { it('should throw on type mismatch', function () { assert.throws(() => utils.assertDeepCantonMatch({ a: 'str' }, { a: 42 }, noInject), /mismatch/); }); + + describe('ISO timestamp vs microsecond string normalisation', function () { + const isoTs = '2026-06-12T10:00:00.000Z'; + const isoTsUs = String(new Date(isoTs).getTime() * 1000); + + it('should pass when ISO timestamp (expected) equals microseconds (actual)', function () { + assert.doesNotThrow(() => utils.assertDeepCantonMatch(isoTs, isoTsUs, noInject)); + }); + + it('should throw timestamp mismatch when microseconds do not correspond to the ISO value', function () { + const wrongUs = String(new Date(isoTs).getTime() * 1000 + 1_000_000); + assert.throws(() => utils.assertDeepCantonMatch(isoTs, wrongUs, noInject), /timestamp mismatch/); + }); + + it('should pass for ISO timestamp nested inside a mint-like choiceArgument object', function () { + const executeBefore = '2026-06-13T10:00:00.000Z'; + const executeBeforeUs = String(new Date(executeBefore).getTime() * 1000); + + const expected = { + expectedAdmin: 'registrar::1220abc', + mint: { + instrumentId: { admin: 'registrar::1220abc', id: 'STGUSD1' }, + amount: '1000000.0', + holder: 'registrar::1220abc', + reference: 'mint-stgusd1-12345', + requestedAt: isoTs, + executeBefore: executeBefore, + meta: { values: {} }, + }, + }; + + const actual = { + expectedAdmin: 'registrar::1220abc', + mint: { + instrumentId: { admin: 'registrar::1220abc', id: 'STGUSD1' }, + amount: '1000000.0000000000', + holder: 'registrar::1220abc', + reference: 'mint-stgusd1-12345', + requestedAt: isoTsUs, + executeBefore: executeBeforeUs, + meta: { values: {} }, + }, + }; + + assert.doesNotThrow(() => utils.assertDeepCantonMatch(expected, actual, noInject)); + }); + + it('should throw when ISO timestamp in nested object does not match microseconds', function () { + const wrongUs = String(new Date(isoTs).getTime() * 1000 + 5_000_000); + + const expected = { mint: { requestedAt: isoTs } }; + const actual = { mint: { requestedAt: wrongUs } }; + + assert.throws(() => utils.assertDeepCantonMatch(expected, actual, noInject), /timestamp mismatch/); + }); + + it('should not treat an arbitrary non-timestamp ISO-like string as a timestamp', function () { + assert.throws(() => utils.assertDeepCantonMatch('not-a-timestamp', isoTsUs, noInject), /mismatch/); + }); + }); }); }); From 501346960b82ed0e5ea3d6e79491d1f77011966f Mon Sep 17 00:00:00 2001 From: Prajwal U Date: Thu, 11 Jun 2026 23:10:47 +0530 Subject: [PATCH 06/24] fix: update package deps to fix vuln current grpc had the version with vulnerability ref:https://github.com/advisories/GHSA-99f4-grh7-6pcq upgrade to fix it Ticket: CSHLD-1028 --- package.json | 2 +- yarn.lock | 101 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 55cde6498c..bb5467567a 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "**/swarm-js/**/ws": "5.2.4", "**/swarm-js/**/tar": "6.2.1", "serialize-javascript": "7.0.5", - "@grpc/grpc-js": "^1.12.6", + "@grpc/grpc-js": "^1.14.4", "bigint-buffer": "npm:@trufflesuite/bigint-buffer@1.1.10", "request": "npm:@cypress/request@3.0.9", "**/avalanche/store2": "2.14.4", diff --git a/yarn.lock b/yarn.lock index 3a010e56b9..e968a816c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2886,22 +2886,22 @@ resolved "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== -"@grpc/grpc-js@^1.12.6": - version "1.13.4" - resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz" - integrity sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg== +"@grpc/grpc-js@^1.12.6", "@grpc/grpc-js@^1.14.4": + version "1.14.4" + resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz#e73ff57d97802f063999545f43ebb2b1eca65d9d" + integrity sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ== dependencies: - "@grpc/proto-loader" "^0.7.13" + "@grpc/proto-loader" "^0.8.0" "@js-sdsl/ordered-map" "^4.4.2" -"@grpc/proto-loader@^0.7.13": - version "0.7.15" - resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz" - integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== +"@grpc/proto-loader@^0.8.0": + version "0.8.1" + resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz#5a6b290ccbfb1ae2f6775afb74e9898bd8c5d4e8" + integrity sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg== dependencies: lodash.camelcase "^4.3.0" long "^5.0.0" - protobufjs "^7.2.5" + protobufjs "^7.5.5" yargs "^17.7.2" "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": @@ -3492,6 +3492,78 @@ dependencies: bs58 "^5.0.0" +"@napi-rs/canvas-android-arm64@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz#b7c68b91d57702a5fc523fa82de72ea741e6766e" + integrity sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ== + +"@napi-rs/canvas-darwin-arm64@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz#d1686fa6ca699b07640efa5f45425db0a4e725e2" + integrity sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A== + +"@napi-rs/canvas-darwin-x64@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz#92978fd7eca21f7f1b59bd13ba3ce7c0e7c879a1" + integrity sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw== + +"@napi-rs/canvas-linux-arm-gnueabihf@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz#95f9892e1d5a8274871d8ee406374e9ef692bd9f" + integrity sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA== + +"@napi-rs/canvas-linux-arm64-gnu@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz#6b2b7c4bb016b8f5308115ac9ae909df4967a94b" + integrity sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw== + +"@napi-rs/canvas-linux-arm64-musl@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz#48cf6342543e4f87cf1f8e9b1aaa19ad85bcc178" + integrity sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ== + +"@napi-rs/canvas-linux-riscv64-gnu@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz#71b011007b03755c834a961735302c5837b041da" + integrity sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw== + +"@napi-rs/canvas-linux-x64-gnu@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz#3f479d3b8b8c4658e5dec1b943ad7d2eefd2811f" + integrity sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg== + +"@napi-rs/canvas-linux-x64-musl@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz#1beaca22c1fe97709a9c287115cb3c90194ddec6" + integrity sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA== + +"@napi-rs/canvas-win32-arm64-msvc@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz#8a1728b455dd17965f95c0bfdf6753be8c5851c0" + integrity sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw== + +"@napi-rs/canvas-win32-x64-msvc@0.1.100": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz#1e52883f8784ffdbe52dc2d47b05a0886aa6e9cf" + integrity sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA== + +"@napi-rs/canvas@^0.1.65": + version "0.1.100" + resolved "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz#2c89a15c28a62e1372be23fd474762ae1a8b16b7" + integrity sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA== + optionalDependencies: + "@napi-rs/canvas-android-arm64" "0.1.100" + "@napi-rs/canvas-darwin-arm64" "0.1.100" + "@napi-rs/canvas-darwin-x64" "0.1.100" + "@napi-rs/canvas-linux-arm-gnueabihf" "0.1.100" + "@napi-rs/canvas-linux-arm64-gnu" "0.1.100" + "@napi-rs/canvas-linux-arm64-musl" "0.1.100" + "@napi-rs/canvas-linux-riscv64-gnu" "0.1.100" + "@napi-rs/canvas-linux-x64-gnu" "0.1.100" + "@napi-rs/canvas-linux-x64-musl" "0.1.100" + "@napi-rs/canvas-win32-arm64-msvc" "0.1.100" + "@napi-rs/canvas-win32-x64-msvc" "0.1.100" + "@napi-rs/wasm-runtime@0.2.4": version "0.2.4" resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz" @@ -16911,6 +16983,13 @@ pbkdf2@^3.0.17, pbkdf2@^3.0.3, pbkdf2@^3.0.9, pbkdf2@^3.1.2: sha.js "^2.4.11" to-buffer "^1.2.0" +pdfjs-dist@^4.0.0: + version "4.10.38" + resolved "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz#3ee698003790dc266cc8b55c0e662ccb9ae18f53" + integrity sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ== + optionalDependencies: + "@napi-rs/canvas" "^0.1.65" + pend@~1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" @@ -17512,7 +17591,7 @@ propagate@^2.0.0: resolved "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz" integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== -protobufjs@7.2.5, protobufjs@7.5.8, protobufjs@^6.8.8, protobufjs@^7.2.5, protobufjs@^7.5.8, protobufjs@~6.11.2, protobufjs@~6.11.3: +protobufjs@7.2.5, protobufjs@7.5.8, protobufjs@^6.8.8, protobufjs@^7.5.5, protobufjs@^7.5.8, protobufjs@~6.11.2, protobufjs@~6.11.3: version "7.5.8" resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz#51b153a06da6e47153a1aa6800cb1253bc502436" integrity sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA== From 7c3f7c025b01fa68d45f2deffe007a32c96a47d2 Mon Sep 17 00:00:00 2001 From: damodarnaik699 Date: Thu, 11 Jun 2026 19:23:53 +0530 Subject: [PATCH 07/24] fix: migrate tcanton:usd1 token to testnet Ticket: SCAAS-9714 --- modules/statics/src/coins/cantonTokens.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/statics/src/coins/cantonTokens.ts b/modules/statics/src/coins/cantonTokens.ts index e6a90aa00e..b24f9de2b0 100644 --- a/modules/statics/src/coins/cantonTokens.ts +++ b/modules/statics/src/coins/cantonTokens.ts @@ -119,8 +119,8 @@ export const cantonTokens = [ 'tcanton:usd1', 'Test USD1 Token', 10, - 'https://api.utilities.digitalasset-dev.com/api/token-standard/v0/registrars/', - '12209::12209fec64653a5324fba57424949ccbdb51828b76d914451772c4496a0215d0cca7:USD1', + 'https://api.utilities.digitalasset-staging.com/api/token-standard/v0/registrars/', + '12203::12203db723d11df3e69eb66fa9bbb5904269895ac0b284b61e3aeb03b748ca09802e:USD1', UnderlyingAsset['tcanton:usd1'], [...CANTON_TOKEN_FEATURES, CoinFeature.STABLECOIN] ), From eeae274c41c79ce982e5b33cbaa744d68c0c3a4d Mon Sep 17 00:00:00 2001 From: Ashutosh Kumar Date: Fri, 12 Jun 2026 12:44:17 +0530 Subject: [PATCH 08/24] feat(sdk-coin-flrp): add createPairedWallet method to Flrp Also adds nock to devDependencies to make the dependency explicit rather than relying on hoisted resolution from other modules. Refs: SI-287 Co-Authored-By: Claude Sonnet 4.6 --- modules/sdk-coin-flrp/package.json | 3 +- modules/sdk-coin-flrp/src/flrp.ts | 10 ++++ modules/sdk-coin-flrp/src/lib/iface.ts | 31 +++++++++++ modules/sdk-coin-flrp/test/unit/flrp.ts | 73 ++++++++++++++++++++++++- 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index d5f2db13d9..cc3706faba 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -44,7 +44,8 @@ }, "devDependencies": { "@bitgo/sdk-api": "^1.81.1", - "@bitgo/sdk-test": "^9.1.46" + "@bitgo/sdk-test": "^9.1.46", + "nock": "^13.3.1" }, "dependencies": { "@bitgo/public-types": "6.22.0", diff --git a/modules/sdk-coin-flrp/src/flrp.ts b/modules/sdk-coin-flrp/src/flrp.ts index 9d2c132c76..92c2edd84d 100644 --- a/modules/sdk-coin-flrp/src/flrp.ts +++ b/modules/sdk-coin-flrp/src/flrp.ts @@ -22,6 +22,8 @@ import { } from '@bitgo/sdk-core'; import * as FlrpLib from './lib'; import { + CreatePairedWalletParams, + CreatePairedWalletResponse, FlrpEntry, FlrpExplainTransactionOptions, FlrpSignTransactionOptions, @@ -444,4 +446,12 @@ export class Flrp extends BaseCoin { auditDecryptedKey(params: AuditDecryptedKeyParams): void { throw new MethodNotImplementedError(); } + + async createPairedWallet(params: CreatePairedWalletParams): Promise { + const { walletId, label } = params; + return this.bitgo + .post(this.url(`/wallet/${walletId}/create-paired-wallet`)) + .send(label ? { label } : {}) + .result(); + } } diff --git a/modules/sdk-coin-flrp/src/lib/iface.ts b/modules/sdk-coin-flrp/src/lib/iface.ts index 83f4ae49f4..1c3a786090 100644 --- a/modules/sdk-coin-flrp/src/lib/iface.ts +++ b/modules/sdk-coin-flrp/src/lib/iface.ts @@ -176,3 +176,34 @@ export interface ExportEVMOptions { threshold: number; locktime: bigint; } + +/** + * Parameters for creating a paired FLR C-chain wallet from an FLR P-chain wallet. + */ +export interface CreatePairedWalletParams { + /** The ID of the source FLRP (FLR P-chain) MPC wallet. */ + walletId: string; + /** Optional label for the new FLR C-chain wallet. */ + label?: string; +} + +/** + * Response from the create-paired-wallet endpoint. + */ +export interface CreatePairedWalletResponse { + id: string; + coin: string; + label: string; + keys: string[]; + keySignatures: Record; + m: number; + n: number; + type: string; + multisigType: string; + coinSpecific: { + pairedWalletId?: string; + baseAddress?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} diff --git a/modules/sdk-coin-flrp/test/unit/flrp.ts b/modules/sdk-coin-flrp/test/unit/flrp.ts index dbb5f4df84..97aba87307 100644 --- a/modules/sdk-coin-flrp/test/unit/flrp.ts +++ b/modules/sdk-coin-flrp/test/unit/flrp.ts @@ -14,10 +14,12 @@ import { MULTISIG_DELEGATION_PARAMS, MPC_DELEGATION_UNSIGNED_TX_HEX, } from '../resources/transactionData/multisigDelegationTx'; -import { HalfSignedAccountTransaction, TransactionType, MPCAlgorithm } from '@bitgo/sdk-core'; +import { HalfSignedAccountTransaction, TransactionType, MPCAlgorithm, common } from '@bitgo/sdk-core'; import { secp256k1 } from '@flarenetwork/flarejs'; import { FlrpContext } from '@bitgo/public-types'; import assert from 'assert'; +import nock from 'nock'; +import { CreatePairedWalletResponse } from '../../src/lib/iface'; describe('Flrp test cases', function () { const coinName = 'flrp'; @@ -1086,4 +1088,73 @@ describe('Flrp test cases', function () { }); }); }); + + describe('createPairedWallet', function () { + const walletId = 'abc123def456abc123def456abc123de'; + + afterEach(function () { + nock.cleanAll(); + }); + + it('should POST to create-paired-wallet and return new wallet', async function () { + const bgUrl = common.Environments[bitgo.getEnv()].uri; + const expectedResponse: CreatePairedWalletResponse = { + id: 'newwalletid000000000000000000001', + coin: 'tflr', + label: 'My FLR C Wallet', + keys: ['key1', 'key2', 'key3'], + keySignatures: { backupPub: 'sig1', bitgoPub: 'sig2' }, + m: 2, + n: 3, + type: 'hot', + multisigType: 'tss', + coinSpecific: { + pairedWalletId: walletId, + baseAddress: '0x627306090abaB3A6e1400e9345bC60c78a8BEf57', + }, + }; + + nock(bgUrl) + .post(`/api/v2/tflrp/wallet/${walletId}/create-paired-wallet`, { label: 'My FLR C Wallet' }) + .reply(200, expectedResponse); + + const result = await basecoin.createPairedWallet({ walletId, label: 'My FLR C Wallet' }); + result.should.deepEqual(expectedResponse); + result.coin.should.equal('tflr'); + result.coinSpecific.pairedWalletId.should.equal(walletId); + }); + + it('should POST without body when label is not provided', async function () { + const bgUrl = common.Environments[bitgo.getEnv()].uri; + const expectedResponse: CreatePairedWalletResponse = { + id: 'newwalletid000000000000000000002', + coin: 'tflr', + label: 'FLR C wallet (from tflrp wallet abc123def456abc123def456abc123de)', + keys: ['key1', 'key2', 'key3'], + keySignatures: {}, + m: 2, + n: 3, + type: 'hot', + multisigType: 'tss', + coinSpecific: { pairedWalletId: walletId }, + }; + + nock(bgUrl).post(`/api/v2/tflrp/wallet/${walletId}/create-paired-wallet`, {}).reply(200, expectedResponse); + + const result = await basecoin.createPairedWallet({ walletId }); + result.should.deepEqual(expectedResponse); + }); + + it('should propagate HTTP errors from the server', async function () { + const bgUrl = common.Environments[bitgo.getEnv()].uri; + + nock(bgUrl) + .post(`/api/v2/tflrp/wallet/${walletId}/create-paired-wallet`) + .reply(400, { error: 'Source FLR P wallet is not MPC (multisigType: onchain)' }); + + await basecoin + .createPairedWallet({ walletId }) + .should.be.rejectedWith('Source FLR P wallet is not MPC (multisigType: onchain)'); + }); + }); }); From 82e0b42ecab138a1aa0992b554cb866dec088c18 Mon Sep 17 00:00:00 2001 From: "asset-metadata-bot[bot]" <226385837+asset-metadata-bot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:54:12 +0000 Subject: [PATCH 09/24] feat: add new tokens from AMS API CSHLD-000 --- modules/statics/src/coins/botTokens.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/modules/statics/src/coins/botTokens.ts b/modules/statics/src/coins/botTokens.ts index 06596347ab..63268685d2 100644 --- a/modules/statics/src/coins/botTokens.ts +++ b/modules/statics/src/coins/botTokens.ts @@ -128,6 +128,22 @@ export const botTokens = [ undefined, Networks.test.hoodi ), + AccountCtors.terc20( + '6ed32c82-b855-4e23-a156-2e12569b854d', + 'hteth:cssv', + 'cSSV', + 18, + '0x6e1a5d27361c666f681af06535c8ac773e571d4d', + 'hteth:cssv' as unknown as UnderlyingAsset, + getTokenFeatures('eth', [ + 'custody-bitgo-new-york' as CoinFeature, + 'custody-bitgo-germany' as CoinFeature, + 'custody-bitgo-korea' as CoinFeature, + ]), + undefined, + undefined, + Networks.test.hoodi + ), AccountCtors.erc20( '4161ed06-c331-4e21-8791-eaca5151e869', 'eth:abtx', From 629332db37815a7a0f04849c628bb6fdf6654836 Mon Sep 17 00:00:00 2001 From: ArunBala-Bitgo Date: Fri, 12 Jun 2026 13:37:55 +0530 Subject: [PATCH 10/24] feat(sdk-core): add pre-hashed signable support for Avalanche atomic MPCv2 txs Mirror WP's isSignablePreHashed flow so Avalanche atomic cross-chain transactions use SHA-256 signableHex directly instead of re-hashing with keccak256. Co-authored-by: Cursor TICKET: CECHO-1295 --- CODEOWNERS | 1 + modules/sdk-coin-flrp/src/flrp.ts | 11 ++++ modules/sdk-coin-flrp/test/unit/flrp.ts | 13 ++++- .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 7 +++ .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 57 +++++++++---------- modules/sdk-core/src/bitgo/utils/tss/index.ts | 1 + .../src/bitgo/utils/tss/preHashedSignable.ts | 36 ++++++++++++ .../unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 4 ++ .../unit/bitgo/utils/tss/preHashedSignable.ts | 27 +++++++++ 9 files changed, 126 insertions(+), 31 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/utils/tss/preHashedSignable.ts create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/preHashedSignable.ts diff --git a/CODEOWNERS b/CODEOWNERS index 995b72daf4..c1b5e4326e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -136,6 +136,7 @@ /modules/sdk-core/src/bitgo/lightning/ @BitGo/btc-team /modules/sdk-core/test/unit/bitgo/lightning/ @BitGo/btc-team /modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts @BitGo/ethalt-team +/modules/sdk-core/test/unit/bitgo/utils/tss/preHashedSignable.ts @BitGo/ethalt-team /modules/sdk-lib-mpc/ @BitGo/wallet-core @BitGo/wallet-core-india @BitGo/hsm /modules/deser-lib/ @BitGo/wallet-core @BitGo/wallet-core-india @BitGo/hsm /modules/sdk-rpc-wrapper @BitGo/ethalt-team diff --git a/modules/sdk-coin-flrp/src/flrp.ts b/modules/sdk-coin-flrp/src/flrp.ts index 9d2c132c76..4e57781385 100644 --- a/modules/sdk-coin-flrp/src/flrp.ts +++ b/modules/sdk-coin-flrp/src/flrp.ts @@ -19,6 +19,8 @@ import { BaseTransaction, SigningError, MethodNotImplementedError, + SignableTransaction, + isAvalancheAtomicTx, } from '@bitgo/sdk-core'; import * as FlrpLib from './lib'; import { @@ -440,6 +442,15 @@ export class Flrp extends BaseCoin { return FlrpLib.Utils.createSignature(this._staticsCoin.network as FlareNetwork, message, prv); } + /** + * Returns true when the signableHex for this transaction is already the + * final signing hash (SHA-256 for Avalanche atomic txs), and the MPC + * signing flow should use it directly without additional hashing. + */ + isSignablePreHashed(unsignedTx: SignableTransaction): boolean { + return isAvalancheAtomicTx(unsignedTx); + } + /** @inheritDoc */ auditDecryptedKey(params: AuditDecryptedKeyParams): void { throw new MethodNotImplementedError(); diff --git a/modules/sdk-coin-flrp/test/unit/flrp.ts b/modules/sdk-coin-flrp/test/unit/flrp.ts index dbb5f4df84..d7bde7e94b 100644 --- a/modules/sdk-coin-flrp/test/unit/flrp.ts +++ b/modules/sdk-coin-flrp/test/unit/flrp.ts @@ -1,7 +1,7 @@ import * as FlrpLib from '../../src/lib'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { Flrp, TflrP } from '../../src/'; -import { randomBytes } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import { BitGoAPI } from '@bitgo/sdk-api'; import { coins } from '@bitgo/statics'; import { SEED_ACCOUNT, ACCOUNT_1, ACCOUNT_2, ON_CHAIN_TEST_WALLET, CONTEXT } from '../resources/account'; @@ -1053,6 +1053,17 @@ describe('Flrp test cases', function () { isVerified.should.equal(true); }); + it('isSignablePreHashed should be true for Avalanche atomic txs', async () => { + const txHex = await buildUnsignedExportInP(); + const rawHex = txHex.startsWith('0x') ? txHex.substring(2) : txHex; + const signableHex = createHash('sha256').update(Buffer.from(rawHex, 'hex')).digest('hex'); + + basecoin.isSignablePreHashed({ serializedTxHex: rawHex, signableHex }).should.equal(true); + basecoin + .isSignablePreHashed({ serializedTxHex: 'f86c808504a817c800825208', signableHex: 'f86c808504a817c800825208' }) + .should.equal(false); + }); + it('should verify signablePayload is SHA-256 of serialized tx (sandbox-verified)', async () => { // unsignedTx.toBytes() → sha256 → MPC.sign() // The signablePayload must be exactly 32 bytes (SHA-256 digest). diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 510d0d8abd..f41d637d50 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -15,6 +15,7 @@ import { Hash } from 'crypto'; import { TransactionType } from '../../account-lib'; import { IInscriptionBuilder } from '../inscriptionBuilder'; import { MessageStandardType, MPCTx, PopulatedIntent, TokenTransferRecipientParams, TokenType } from '../utils'; +import type { SignableTransaction } from '../utils/tss/baseTypes'; import { IWebhooks } from '../webhook/iWebhooks'; export const multisigTypes = { @@ -657,6 +658,12 @@ export interface IBaseCoin { */ buildNftTransferData(params: BuildNftTransferDataOptions): string | TokenTransferRecipientParams; getHashFunction(): Hash; + /** + * Returns true when signableHex is already the final signing hash for MPC signing + * (e.g. SHA-256 for Avalanche atomic cross-chain txs). Only implemented by coins + * that produce pre-hashed signable material. + */ + isSignablePreHashed?(unsignedTx: SignableTransaction): boolean; broadcastTransaction(params: BaseBroadcastTransactionOptions): Promise; setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void; diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 03d16dd215..5f3ca85d3a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -49,7 +49,9 @@ import { TssTxRecipientSource, TxRequest, isV2Envelope, + SignableTransaction, } from '../baseTypes'; +import { shouldUsePreHashedSignable } from '../preHashedSignable'; import { BaseEcdsaUtils } from './base'; import { EcdsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './ecdsaMPCv2KeyGenSender'; import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys'; @@ -837,8 +839,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { // serializedTxHex is the PVM/EVM atomic tx (codec prefix 0x0000). // For all other coins, signableHex IS the unsigned transaction (e.g. RLP bytes). const isIcp = this.baseCoin.getConfig().family === 'icp'; - const isAvalancheAtomic = unsignedTx.serializedTxHex && unsignedTx.serializedTxHex.startsWith('0000'); - if (isIcp || isAvalancheAtomic) { + const isPreHashed = shouldUsePreHashedSignable(this.baseCoin, unsignedTx); + if (isIcp || isPreHashed) { await this.baseCoin.verifyTransaction({ txPrebuild: { txHex: unsignedTx.serializedTxHex, txInfo: unsignedTx.signableHex }, txParams: params.txParams || { recipients: [] }, @@ -870,19 +872,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { // pre-hashed digest. Use it directly as the DKLS message hash instead of // applying the coin's hash function (keccak256 for EVM coins). // Same logic as getHashStringAndDerivationPath (external signer path). - let hashBuffer: Buffer; - if (serializedTxHex && serializedTxHex.startsWith('0000')) { - hashBuffer = bufferContent; - assert(hashBuffer.length === 32, `Avalanche pre-hashed signableHex must be 32 bytes, got ${hashBuffer.length}`); - } else { - let hash: Hash; - try { - hash = this.baseCoin.getHashFunction(); - } catch (err) { - hash = createKeccakHash('keccak256') as Hash; - } - hashBuffer = hash.update(bufferContent).digest(); - } + const hashBuffer = this.getMpcv2HashBuffer(serializedTxHex, txOrMessageToSign, bufferContent); const otherSigner = new DklsDsg.Dsg( userKeyShare, params.mpcv2PartyId !== undefined ? params.mpcv2PartyId : 0, @@ -1059,18 +1049,27 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { throw new Error('Invalid request type, got: ' + requestType); } - // For Avalanche atomic transactions (cross-chain export/import between - // C-chain and P-chain), signableHex is already SHA-256(txBody) — a 32-byte - // pre-hashed digest. Use it directly as the DKLS message hash instead of - // applying the coin's hash function (keccak256 for EVM coins). - // This matches the WP/HSM BitGo-party behaviour (MPCv2Signer.isPreHashed) - // so both DKLS parties agree on the same message hash. - // Detection: Avalanche codec type ID prefix is 0x0000; standard EVM RLP - // starts with 0xf8xx, so there is no collision. - if (serializedTxHex && serializedTxHex.startsWith('0000')) { - const hashBuffer = Buffer.from(txToSign, 'hex'); - assert(hashBuffer.length === 32, `Avalanche pre-hashed signableHex must be 32 bytes, got ${hashBuffer.length}`); - return { hashBuffer, derivationPath }; + const hashBuffer = this.getMpcv2HashBuffer(serializedTxHex, txToSign, Buffer.from(txToSign, 'hex')); + return { hashBuffer, derivationPath }; + } + + /** + * Build the DKLS message hash for MPCv2 signing. + * For pre-hashed signable material (Avalanche atomic txs), signableHex is + * already SHA-256(txBody) and is used directly. Otherwise the coin hash + * function is applied (keccak256 for EVM coins by default). + */ + private getMpcv2HashBuffer(serializedTxHex: string | undefined, signableHex: string, bufferContent: Buffer): Buffer { + const unsignedTx: SignableTransaction = { + serializedTxHex: serializedTxHex ?? '', + signableHex, + }; + if (serializedTxHex && shouldUsePreHashedSignable(this.baseCoin, unsignedTx)) { + assert( + bufferContent.length === 32, + `Avalanche pre-hashed signableHex must be 32 bytes, got ${bufferContent.length}` + ); + return bufferContent; } let hash: Hash; @@ -1079,9 +1078,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { } catch (err) { hash = createKeccakHash('keccak256') as Hash; } - const hashBuffer = hash.update(Buffer.from(txToSign, 'hex')).digest(); - - return { hashBuffer, derivationPath }; + return hash.update(bufferContent).digest(); } // #endregion diff --git a/modules/sdk-core/src/bitgo/utils/tss/index.ts b/modules/sdk-core/src/bitgo/utils/tss/index.ts index 2276906b6c..58a6db8be9 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/index.ts @@ -15,3 +15,4 @@ export { ITssUtils, IEddsaUtils, TxRequest, EddsaUnsignedTransaction } from './e export * as BaseTssUtils from './baseTSSUtils'; export * from './baseTypes'; export * from './addressVerification'; +export * from './preHashedSignable'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/preHashedSignable.ts b/modules/sdk-core/src/bitgo/utils/tss/preHashedSignable.ts new file mode 100644 index 0000000000..2e3e2b0d2d --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/preHashedSignable.ts @@ -0,0 +1,36 @@ +import { IBaseCoin } from '../../baseCoin'; +import { SignableTransaction } from './baseTypes'; + +export interface CoinWithPreHashedSignable { + /** + * Returns true when the signableHex for this transaction is already the + * final signing hash (SHA-256 for Avalanche atomic txs), and the MPC + * signing flow should use it directly without additional hashing. + */ + isSignablePreHashed(unsignedTx: SignableTransaction): boolean; +} + +export function isCoinWithPreHashedSignable(coin: unknown): coin is CoinWithPreHashedSignable { + return ( + coin !== null && + typeof coin === 'object' && + typeof (coin as CoinWithPreHashedSignable).isSignablePreHashed === 'function' + ); +} + +/** + * Detect Avalanche atomic cross-chain transactions by codec prefix. + * + * Avalanche codec type IDs start with 0x00000000 (first 4 hex chars = '0000'). + * Standard EVM RLP encoding starts with 0xf8xx — overlap is currently impossible. + * + * Note: The WP strips the '0x' prefix from serializedTxHex before storing, + * so we check for '0000' not '0x0000'. + */ +export function isAvalancheAtomicTx(unsignedTx: SignableTransaction): boolean { + return unsignedTx.serializedTxHex.startsWith('0000'); +} + +export function shouldUsePreHashedSignable(coin: IBaseCoin, unsignedTx: SignableTransaction): boolean { + return isCoinWithPreHashedSignable(coin) && coin.isSignablePreHashed(unsignedTx); +} 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 12e2f5b09c..579cea332d 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 @@ -65,6 +65,7 @@ describe('ECDSA MPC v2', async () => { const mockCoin = {} as IBaseCoin; mockCoin.getHashFunction = sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash); + mockCoin.isSignablePreHashed = (unsignedTx) => unsignedTx.serializedTxHex?.startsWith('0000') ?? false; ecdsaMPCv2Utils = new EcdsaMPCv2Utils(mockBg, mockCoin); }); @@ -630,6 +631,8 @@ describe('ECDSA MPC v2', async () => { verifyTransaction: sinon.stub().resolves(true), getMPCAlgorithm: sinon.stub().returns('ecdsa'), getConfig: sinon.stub().returns({ family: 'flrp' }), + isSignablePreHashed: (unsignedTx: { serializedTxHex?: string }) => + unsignedTx.serializedTxHex?.startsWith('0000') ?? false, } as unknown as IBaseCoin; const mockWallet = { @@ -910,6 +913,7 @@ describe('ECDSA MPC v2', async () => { const mockCoin = {} as IBaseCoin; mockCoin.getHashFunction = sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash); + mockCoin.isSignablePreHashed = (unsignedTx) => unsignedTx.serializedTxHex?.startsWith('0000') ?? false; ecdsaMPCv2UtilsWithSpy = new EcdsaMPCv2Utils(mockBg, mockCoin); }); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/preHashedSignable.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/preHashedSignable.ts new file mode 100644 index 0000000000..78d356439f --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/preHashedSignable.ts @@ -0,0 +1,27 @@ +import * as assert from 'assert'; +import { isAvalancheAtomicTx, isCoinWithPreHashedSignable } from '../../../../../src/bitgo/utils/tss/preHashedSignable'; + +describe('preHashedSignable', function () { + it('isAvalancheAtomicTx detects codec prefix 0000', function () { + assert.strictEqual( + isAvalancheAtomicTx({ + serializedTxHex: '0000000000010000007278db5c', + signableHex: 'abc', + }), + true + ); + assert.strictEqual( + isAvalancheAtomicTx({ + serializedTxHex: 'f86c808504a817c800825208', + signableHex: 'f86c808504a817c800825208', + }), + false + ); + }); + + it('isCoinWithPreHashedSignable type guard', function () { + assert.strictEqual(isCoinWithPreHashedSignable({ isSignablePreHashed: () => true }), true); + assert.strictEqual(isCoinWithPreHashedSignable({}), false); + assert.strictEqual(isCoinWithPreHashedSignable(null), false); + }); +}); From 4698398c4c5ccb576c9afd78509637b0e1d8f4d2 Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Fri, 12 Jun 2026 17:21:55 +0530 Subject: [PATCH 11/24] fix(sdk-coin-sui): handle gas coin empty with only address balance TICKET: CSHLD-983 --- .../sdk-coin-sui/src/lib/transferBuilder.ts | 60 ++++- modules/sdk-coin-sui/src/sui.ts | 4 +- .../transactionBuilder/transferBuilder.ts | 205 +++++++++++++++--- 3 files changed, 235 insertions(+), 34 deletions(-) diff --git a/modules/sdk-coin-sui/src/lib/transferBuilder.ts b/modules/sdk-coin-sui/src/lib/transferBuilder.ts index be98d8ae29..56b6b7ef21 100644 --- a/modules/sdk-coin-sui/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-sui/src/lib/transferBuilder.ts @@ -193,9 +193,17 @@ export class TransferBuilder extends TransactionBuilder0): + * withdrawal(totalRecipientAmount) → redeem_funds → Coin + * SplitCoins(addrCoin, [amount]) → TransferObjects + * MergeCoins(GasCoin, [addrCoin]) ← destroys 0-balance addrCoin; gas paid from accumulator remainder + * Handles Case 2 (addr-bal only, caller sets ValidDuring). + * NOTE: SplitCoins(GasCoin, [amount]) does NOT work here — with payment=[], GasCoin only + * carries up to gas-budget-worth of balance for PTB operations, not the full address balance. * * Path 2c — Self-pay, mixed (sender === gasData.owner, gasData.payment non-empty AND fundsInAddressBalance > 0): * Sui does NOT automatically merge address balance into gas coin when payment is non-empty. @@ -359,7 +367,53 @@ export class TransferBuilder extends TransactionBuilder0). + // With payment=[], GasCoin is backed by the address accumulator only for gas reservation; + // it does NOT expose the full address balance for SplitCoins. Attempting + // SplitCoins(GasCoin, [large_amount]) with payment=[] always fails with + // InsufficientCoinBalance in command 0 because GasCoin only carries up to + // gas-budget-worth of balance for PTB operations. + // Fix: use withdrawal+redeem_funds to materialise the transfer amount as a coin + // object, split from that coin, then merge the 0-balance remainder into GasCoin + // (destroying it). The accumulator retains the gas budget for gas settlement. + if (this._fundsInAddressBalance.gt(0) && this._gasData.payment.length === 0) { + const totalRecipientAmount = this._recipients.reduce( + (acc, r) => acc.plus(new BigNumber(r.amount)), + new BigNumber(0) + ); + // Withdraw exactly the total transfer amount. The difference + // (fundsInAddressBalance - totalRecipientAmount ≈ gasBudget) remains in the + // accumulator and is consumed by the empty-payment gas mechanism. + const [addrCoin] = programmableTxBuilder.moveCall({ + target: '0x2::coin::redeem_funds', + typeArguments: ['0x2::sui::SUI'], + arguments: [programmableTxBuilder.withdrawal({ amount: BigInt(totalRecipientAmount.toFixed()) })], + }); + this._recipients.forEach((recipient) => { + const splitObject = programmableTxBuilder.splitCoins(addrCoin, [ + programmableTxBuilder.pure(BigInt(recipient.amount)), + ]); + programmableTxBuilder.transferObjects([splitObject], programmableTxBuilder.object(recipient.address)); + }); + // addrCoin has 0 balance after all splits; merge into GasCoin to delete it. + programmableTxBuilder.mergeCoins(programmableTxBuilder.gas, [addrCoin]); + const txData2b = programmableTxBuilder.blockData; + return { + type: this._type, + sender: this._sender, + tx: { + inputs: [...txData2b.inputs], + transactions: [...txData2b.transactions], + }, + gasData: { + ...this._gasData, + }, + expiration: this._expiration, + fundsInAddressBalance: this._fundsInAddressBalance.toFixed(), + }; + } + + // Path 2a: self-pay, coin objects only (payment non-empty, fundsInAddressBalance==0). // number of objects passed as gas payment should be strictly less than `MAX_GAS_OBJECTS`. When the transaction // requires a larger number of inputs we use the merge command to merge the rest of the objects into the gasCoin if (this._gasData.payment.length >= MAX_GAS_OBJECTS) { diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index db21b3a7d3..8809696be6 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -394,8 +394,8 @@ export class Sui extends BaseCoin { inputCoins = inputCoins.slice(0, MAX_OBJECT_LIMIT); } // Include funds held in the address balance system (not in coin objects). - // SplitCoins(GasCoin, [amount]) draws from both gasData.payment objects - // and address balance at execution time, so both are spendable. + // When gasData.payment is non-empty, Path 2c merges the address balance into GasCoin. + // When gasData.payment is empty (address-balance only), Path 2b uses withdrawal+redeem_funds. const coinObjectsBalance = inputCoins.reduce((acc, obj) => acc.plus(obj.balance), new BigNumber(0)); let netAmount = coinObjectsBalance.plus(fundsInAddressBalance).minus(MAX_GAS_BUDGET); diff --git a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts index 45abccdeda..301285cbf0 100644 --- a/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-sui/test/unit/transactionBuilder/transferBuilder.ts @@ -313,7 +313,12 @@ describe('Sui Transfer Builder', () => { describe('fundsInAddressBalance', () => { const FUNDS_IN_ADDRESS_BALANCE = '5000000000'; // 5 SUI - it('should build a self-pay transfer using only address balance (empty payment)', async function () { + it('should build Path 2b: self-pay, address-balance only — uses withdrawal+redeem_funds, not SplitCoins(GasCoin)', async function () { + // Regression for production InsufficientCoinBalance failures on consolidation. + // With payment=[], GasCoin only has gas-budget-worth of balance for PTB operations; + // SplitCoins(GasCoin, [large_amount]) always fails at command 0. + // Path 2b must use withdrawal+redeem_funds to materialise the address balance as a + // coin object and split from that coin instead. const gasDataNoPayment = { ...testData.gasDataWithoutGasPayment, payment: [], @@ -322,7 +327,7 @@ describe('Sui Transfer Builder', () => { const txBuilder = factory.getTransferBuilder(); txBuilder.type(SuiTransactionType.Transfer); txBuilder.sender(testData.sender.address); - txBuilder.send(testData.recipients); + txBuilder.send(testData.recipients); // 2 recipients txBuilder.gasData(gasDataNoPayment); txBuilder.fundsInAddressBalance(FUNDS_IN_ADDRESS_BALANCE); @@ -332,20 +337,149 @@ describe('Sui Transfer Builder', () => { const suiTx = tx as SuiTransaction; suiTx.suiTransaction.gasData.payment.length.should.equal(0); - // Self-pay path: SplitCoins(GasCoin, [amount]) — no MoveCall needed - const programmableTx = suiTx.suiTransaction.tx; - (programmableTx.transactions[0] as any).kind.should.equal('SplitCoins'); + // Path 2b PTB command sequence (2 recipients): + // 0: MoveCall(redeem_funds) — materialise addrCoin from address balance + // 1: SplitCoins(addrCoin, [amount0]) — split for recipient 0 + // 2: TransferObjects — send to recipient 0 + // 3: SplitCoins(addrCoin, [amount1]) — split for recipient 1 + // 4: TransferObjects — send to recipient 1 + // 5: MergeCoins(GasCoin, [addrCoin]) — destroy 0-balance addrCoin; gas from accumulator + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + cmds[0].kind.should.equal('MoveCall', 'command 0 must be MoveCall(redeem_funds)'); + cmds[0].target.should.equal('0x2::coin::redeem_funds'); + cmds[1].kind.should.equal('SplitCoins', 'command 1 must be SplitCoins(addrCoin)'); + cmds[2].kind.should.equal('TransferObjects', 'command 2 must be TransferObjects'); + cmds[3].kind.should.equal('SplitCoins', 'command 3 must be SplitCoins(addrCoin)'); + cmds[4].kind.should.equal('TransferObjects', 'command 4 must be TransferObjects'); + cmds[5].kind.should.equal('MergeCoins', 'command 5 must be MergeCoins(GasCoin, [addrCoin])'); + cmds.length.should.equal(6); + + suiTx.suiTransaction.fundsInAddressBalance!.should.equal(FUNDS_IN_ADDRESS_BALANCE); + + // Transaction parser must correctly identify recipients despite the MoveCall preamble + const parsedRecipients = utils.getRecipients(suiTx.suiTransaction); + parsedRecipients.length.should.equal(testData.recipients.length); + parsedRecipients[0].address.should.equal(testData.recipients[0].address); + parsedRecipients[0].amount.should.equal(testData.recipients[0].amount); + + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + // Round-trip: rebuilt transaction must be bit-for-bit identical + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + }); + + it('should build Path 2b with a single recipient', async function () { + const SEND_AMOUNT = '1275170993710'; // realistic consolidation amount (~1275 SUI) + const ADDR_BAL = '1275177540910'; // fundsInAddressBalance > SEND_AMOUNT + gas budget + const gasDataNoPayment = { + ...testData.gasDataWithoutGasPayment, + payment: [], + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: SEND_AMOUNT }]); + txBuilder.gasData(gasDataNoPayment); + txBuilder.fundsInAddressBalance(ADDR_BAL); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + + // Command sequence for 1 recipient: + // 0: MoveCall(redeem_funds) + // 1: SplitCoins(addrCoin, [SEND_AMOUNT]) + // 2: TransferObjects + // 3: MergeCoins(GasCoin, [addrCoin]) + cmds[0].kind.should.equal('MoveCall'); + cmds[0].target.should.equal('0x2::coin::redeem_funds'); + cmds[1].kind.should.equal('SplitCoins'); + cmds[2].kind.should.equal('TransferObjects'); + cmds[3].kind.should.equal('MergeCoins', 'expected MergeCoins to consume 0-balance addrCoin'); + cmds.length.should.equal(4); + + const parsedRecipients = utils.getRecipients(suiTx.suiTransaction); + parsedRecipients.length.should.equal(1); + parsedRecipients[0].address.should.equal(testData.recipients[0].address); + parsedRecipients[0].amount.should.equal(SEND_AMOUNT); const rawTx = tx.toBroadcastFormat(); should.equal(utils.isValidRawTransaction(rawTx), true); - // Round-trip: rebuild from raw const rebuilder = factory.from(rawTx); rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); const rebuiltTx = await rebuilder.build(); rebuiltTx.toBroadcastFormat().should.equal(rawTx); }); + it('should build Path 2b with ValidDuring expiration (production consolidation scenario)', async function () { + // Mirrors the real on-chain consolidation that failed: address index 10, wallet + // 67fe3f15..., txid JDbnybotg3axfjtB53LrnV1YtvGLFBVG8o2Xz5xX1m6Z. + // The recover() code sets ValidDuring when coinObjectsBalance=0 and + // fundsInAddressBalance>0 (Case 2). This test verifies both the expiration survives + // serialization and the PTB uses Path 2b (not the broken SplitCoins(GasCoin) path). + const GENESIS_CHAIN_ID = 'GAFpCCcRCxTdFfUEMbQbkLBaZy2RNiGAfvFBhMNpq2kT'; + const SEND_AMOUNT = '1275170993710'; + const ADDR_BAL = '1275177540910'; + const gasDataNoPayment = { + ...testData.gasDataWithoutGasPayment, + payment: [], + }; + + const txBuilder = factory.getTransferBuilder(); + txBuilder.type(SuiTransactionType.Transfer); + txBuilder.sender(testData.sender.address); + txBuilder.send([{ address: testData.recipients[0].address, amount: SEND_AMOUNT }]); + txBuilder.gasData(gasDataNoPayment); + txBuilder.fundsInAddressBalance(ADDR_BAL); + txBuilder.expiration({ + ValidDuring: { + minEpoch: { Some: 1156 }, + maxEpoch: { Some: 1157 }, + minTimestamp: { None: null }, + maxTimestamp: { None: null }, + chain: GENESIS_CHAIN_ID, + nonce: 12345678, + }, + }); + + const tx = await txBuilder.build(); + should.equal(tx.type, TransactionType.Send); + + const suiTx = tx as SuiTransaction; + suiTx.suiTransaction.gasData.payment.length.should.equal(0); + + // Must use Path 2b (MoveCall first), never SplitCoins(GasCoin) first + const cmds = suiTx.suiTransaction.tx.transactions as any[]; + cmds[0].kind.should.equal('MoveCall', 'Path 2b must start with MoveCall(redeem_funds), not SplitCoins(GasCoin)'); + cmds[0].target.should.equal('0x2::coin::redeem_funds'); + cmds[cmds.length - 1].kind.should.equal( + 'MergeCoins', + 'Path 2b must end with MergeCoins to destroy 0-balance addrCoin' + ); + + // ValidDuring expiration must survive BCS round-trip + const rawTx = tx.toBroadcastFormat(); + should.equal(utils.isValidRawTransaction(rawTx), true); + + const rebuilder = factory.from(rawTx); + rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); + const rebuiltTx = await rebuilder.build(); + rebuiltTx.toBroadcastFormat().should.equal(rawTx); + + const expiration = (rebuiltTx.toJson().expiration as any)?.ValidDuring; + should.exist(expiration, 'ValidDuring expiration must survive round-trip'); + Number(expiration.minEpoch.Some).should.equal(1156); + Number(expiration.maxEpoch.Some).should.equal(1157); + }); + it('should build a self-pay transfer with coin objects + address balance (Path 2c)', async function () { // Regression test for COINS-331: when both gasData.payment (coin objects) and // fundsInAddressBalance are set, Sui does NOT automatically merge address balance into @@ -969,10 +1103,12 @@ describe('Sui Transfer Builder', () => { bw.reservation.MaxAmountU64.toString().should.equal(AMOUNT); }); - it('should encode gas-from-address-balance (self-pay, empty payment) with no FundsWithdrawal input', async function () { - // When gasData.payment=[] and sender===gasData.owner, the Sui runtime automatically - // uses address balance to fund both gas and the transfer via GasCoin. - // No BalanceWithdrawal CallArg is needed — the runtime handles it implicitly. + it('should build Path 2b: self-pay, empty payment — uses withdrawal+redeem_funds instead of SplitCoins(GasCoin)', async function () { + // With gasData.payment=[] the runtime exposes only the gas-budget amount via GasCoin for + // PTB SplitCoins operations. SplitCoins(GasCoin, [large_amount]) always fails with + // InsufficientCoinBalance (confirmed by production failures JDbnybotg3ax..., ATUbeLX3...). + // Path 2b fixes this by withdrawing from the address accumulator via redeem_funds, + // splitting from that coin object, and merging the 0-balance remainder into GasCoin. const selfPayNoPayment = { ...testData.gasDataWithoutGasPayment, payment: [] }; const txBuilder = factory.getTransferBuilder(); @@ -992,16 +1128,23 @@ describe('Sui Transfer Builder', () => { // Payment must be empty — gas funded from address balance by the runtime suiTx.suiTransaction.gasData.payment.length.should.equal(0); - // No BalanceWithdrawal input — runtime handles address balance automatically + // Path 2b: BalanceWithdrawal input MUST be present (withdrawal+redeem_funds approach) const inputs = suiTx.suiTransaction.tx.inputs as any[]; const bwInput = inputs.find( (inp) => inp?.BalanceWithdrawal !== undefined || inp?.value?.BalanceWithdrawal !== undefined ); - should.not.exist(bwInput, 'self-pay must NOT have a BalanceWithdrawal input — runtime handles it'); + should.exist(bwInput, 'Path 2b: BalanceWithdrawal input must be present'); + const bw = bwInput.BalanceWithdrawal ?? bwInput.value?.BalanceWithdrawal; + bw.reservation.MaxAmountU64.toString().should.equal(AMOUNT); - // First command must be SplitCoins(GasCoin) — no redeem_funds needed + // First command must be MoveCall(redeem_funds), NOT SplitCoins(GasCoin) const commands = suiTx.suiTransaction.tx.transactions as any[]; - commands[0].kind.should.equal('SplitCoins'); + commands[0].kind.should.equal('MoveCall'); + commands[0].target.should.equal('0x2::coin::redeem_funds'); + // Followed by: SplitCoins(addrCoin), TransferObjects, MergeCoins(GasCoin, addrCoin) + commands[1].kind.should.equal('SplitCoins'); + commands[2].kind.should.equal('TransferObjects'); + commands[3].kind.should.equal('MergeCoins'); const rawTx = tx.toBroadcastFormat(); should.equal(utils.isValidRawTransaction(rawTx), true); @@ -1011,11 +1154,12 @@ describe('Sui Transfer Builder', () => { deserialized.gasData.owner.should.equal(testData.sender.address); deserialized.gasData.payment.length.should.equal(0); - // No FundsWithdrawal in decoded inputs — only Pure args + // BalanceWithdrawal must survive BCS round-trip const decodedInputs = deserialized.tx.inputs as any[]; - decodedInputs - .every((inp: any) => inp?.BalanceWithdrawal === undefined && inp?.value?.BalanceWithdrawal === undefined) - .should.be.true('decoded inputs must contain no FundsWithdrawal for self-pay path'); + const decodedBwInput = decodedInputs.find( + (inp: any) => inp?.BalanceWithdrawal !== undefined || inp?.value?.BalanceWithdrawal !== undefined + ); + should.exist(decodedBwInput, 'BalanceWithdrawal must survive BCS round-trip for Path 2b'); const rebuilder = factory.from(rawTx); rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); @@ -1023,9 +1167,10 @@ describe('Sui Transfer Builder', () => { rebuiltTx.toBroadcastFormat().should.equal(rawTx); }); - it('should encode gas-from-address-balance with ValidDuring expiration (self-pay, empty payment)', async function () { - // When gasData.payment=[] the Sui node requires a ValidDuring expiration to prevent - // replay attacks. Gas and transfer are both funded from address balance via GasCoin. + it('should build Path 2b with ValidDuring expiration (self-pay, empty payment)', async function () { + // Path 2b with ValidDuring expiration — required when gasData.payment=[] to prevent + // replay attacks. Uses withdrawal+redeem_funds to materialise transfer amount from the + // address accumulator, NOT SplitCoins(GasCoin) which fails with InsufficientCoinBalance. const GENESIS_CHAIN_ID = 'GAFpCCcRCxTdFfUEMbQbkLBaZy2RNiGAfvFBhMNpq2kT'; const selfPayNoPayment = { ...testData.gasDataWithoutGasPayment, payment: [] }; @@ -1055,16 +1200,17 @@ describe('Sui Transfer Builder', () => { suiTx.suiTransaction.gasData.owner.should.equal(testData.sender.address); suiTx.suiTransaction.gasData.payment.length.should.equal(0); - // No BalanceWithdrawal input — runtime handles GasCoin from address balance + // Path 2b: BalanceWithdrawal input MUST be present const inputs = suiTx.suiTransaction.tx.inputs as any[]; const bwInput = inputs.find( (inp) => inp?.BalanceWithdrawal !== undefined || inp?.value?.BalanceWithdrawal !== undefined ); - should.not.exist(bwInput, 'self-pay must NOT have a BalanceWithdrawal input'); + should.exist(bwInput, 'Path 2b: BalanceWithdrawal input must be present'); - // First command: SplitCoins(GasCoin) — no redeem_funds for self-pay + // First command: MoveCall(redeem_funds), NOT SplitCoins(GasCoin) const commands = suiTx.suiTransaction.tx.transactions as any[]; - commands[0].kind.should.equal('SplitCoins'); + commands[0].kind.should.equal('MoveCall'); + commands[0].target.should.equal('0x2::coin::redeem_funds'); const rawTx = tx.toBroadcastFormat(); should.equal(utils.isValidRawTransaction(rawTx), true); @@ -1080,11 +1226,12 @@ describe('Sui Transfer Builder', () => { expiration.chain.should.equal(GENESIS_CHAIN_ID); Number(expiration.nonce).should.equal(0xdeadbeef); - // Inputs must contain no FundsWithdrawal — only Pure args + // BalanceWithdrawal must survive BCS round-trip const decodedInputs = deserialized.tx.inputs as any[]; - decodedInputs - .every((inp: any) => inp?.BalanceWithdrawal === undefined && inp?.value?.BalanceWithdrawal === undefined) - .should.be.true('decoded inputs must contain no FundsWithdrawal for self-pay path'); + const decodedBwInput = decodedInputs.find( + (inp: any) => inp?.BalanceWithdrawal !== undefined || inp?.value?.BalanceWithdrawal !== undefined + ); + should.exist(decodedBwInput, 'BalanceWithdrawal must survive BCS round-trip for Path 2b'); const rebuilder = factory.from(rawTx); rebuilder.addSignature({ pub: testData.sender.publicKey }, Buffer.from(testData.sender.signatureHex)); From f5c2224b4a1537cab6c53a877162382d94e2c753 Mon Sep 17 00:00:00 2001 From: rajangarg047 Date: Thu, 11 Jun 2026 11:02:47 -0400 Subject: [PATCH 12/24] feat(sdk-core): add deriveAddress primitive to BaseCoin Add a deriveAddress(params) method to the IBaseCoin interface and a default BaseCoin implementation that throws NotImplementedError, so coins can opt in to locally deriving a wallet receive address from a derivation path. This is the inverse of isWalletAddress: instead of checking a candidate address, it produces the address offline from public key material only (xpub triple for BIP32 multisig coins, or commonKeychain for TSS/MPC coins) - no private keys and no network access required. Introduces DeriveAddressOptions and DeriveAddressResult to mirror the existing VerifyAddressOptions / TssVerifyAddressOptions shape. WCN-912 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sdk-core/src/bitgo/baseCoin/baseCoin.ts | 18 +++++++ .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts index 2b25c88fb0..56fdef4ec4 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts @@ -47,6 +47,8 @@ import { AuditKeyParams, AuditDecryptedKeyParams, TssVerifyAddressOptions, + DeriveAddressOptions, + DeriveAddressResult, } from './iBaseCoin'; import { IInscriptionBuilder } from '../inscriptionBuilder'; import { @@ -352,6 +354,22 @@ export abstract class BaseCoin implements IBaseCoin { */ abstract isWalletAddress(params: VerifyAddressOptions | TssVerifyAddressOptions): Promise; + /** + * Locally derive and return a wallet receive address for the given derivation path, + * using public key material only (no private keys, no network access). + * + * This is the inverse of {@link isWalletAddress}: rather than checking a candidate + * address, it *produces* the address from the wallet's keychains and a chain/index. + * + * Coins opt in by overriding this method; the default implementation throws. + * + * @param {DeriveAddressOptions} params - parameters for address derivation (keychains, chain, index, …) + * @returns {Promise} the derived address plus the path and coin-specific data + */ + deriveAddress(params: DeriveAddressOptions): Promise { + throw new NotImplementedError('deriveAddress is not supported for this coin'); + } + /** * convert address into desired address format. * @param address diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 510d0d8abd..86ac97fd83 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -210,6 +210,56 @@ export function isTssVerifyAddressOptions { + // `format`, `derivedFromParentWithSeed` and `multisigTypeVersion` are reused from + // VerifyAddressOptions so derive and verify stay in sync on shared fields. + /** + * Derivation index for the address. + * For BIP32 multisig coins this is combined with `chain`; for TSS/MPC coins the + * derivation path is `m/{index}` (or `{prefix}/{index}` for SMC wallets). + */ + index: number; + /** Derivation chain code (UTXO script-type / external-vs-internal selector). */ + chain?: number; + /** + * Public key material for derivation. Each keychain must carry at least one of: + * - BIP32 multisig (UTXO, legacy EVM): the user/backup/bitgo xpub triple via `pub`. + * - TSS/MPC (SOL, EVM MPC, etc.): the `commonKeychain` (identical across keychains). + * + * Modelled as a union so a keychain with neither field is a type error. A keychain may + * still carry both fields (TSS keychains commonly expose `pub` alongside `commonKeychain`). + */ + keychains?: ({ pub: string } | { commonKeychain: string })[]; + /** Wallet version, used to disambiguate derivation strategy for some coin families. */ + walletVersion?: number; +} + +/** + * Result of locally deriving a wallet receive address. + * + * Extends {@link AddressVerificationData} (`chain`, `index`, `coinSpecific`) and adds the + * resolved `address` and the HD `derivationPath` actually used. + */ +export interface DeriveAddressResult extends AddressVerificationData { + /** The derived address. */ + address: string; + /** The derivation index used. */ + index: number; + /** The HD derivation path actually used to derive the address. */ + derivationPath?: string; +} + export interface TransactionParams { recipients?: ITransactionRecipient[]; walletPassphrase?: string; @@ -618,6 +668,7 @@ export interface IBaseCoin { verifyTransaction(params: VerifyTransactionOptions): Promise; verifyAddress(params: VerifyAddressOptions): Promise; isWalletAddress(params: VerifyAddressOptions | TssVerifyAddressOptions, wallet?: IWallet): Promise; + deriveAddress(params: DeriveAddressOptions): Promise; canonicalAddress(address: string, format: unknown): string; supportsBlockTarget(): boolean; supportsLightning(): boolean; From c3aff4ffc8b19f6559401b51c19093cb988c48a6 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Fri, 12 Jun 2026 14:59:43 -0400 Subject: [PATCH 13/24] fix(sdk-core): skip keychain fetch in createAddress for OFC wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OFC coins never use keychains for address verification — isWalletAddress always throws MethodNotImplementedError and the check is skipped. Fetching all wallet keys was unnecessary and failed for wallets where a server-managed key at index 1 has no accessible keychain record in the OFC namespace. Fixes WCN-942 Co-Authored-By: Claude Sonnet 4.6 --- modules/sdk-core/src/bitgo/wallet/wallet.ts | 8 +- .../bitgo/wallet/ofcWalletAddressCreation.ts | 154 ++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/ofcWalletAddressCreation.ts diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 4d43c53b77..8909f33e34 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -1462,8 +1462,12 @@ export class Wallet implements IWallet { addressParams.evmKeyRingReferenceAddress = evmKeyRingReferenceAddress; } - // get keychains for address verification - const keychains = await Promise.all(this._wallet.keys.map((k) => this.baseCoin.keychains().get({ id: k, reqId }))); + // OFC coins skip address verification (isWalletAddress throws MethodNotImplementedError), + // so fetching keychains is unnecessary and can fail for server-managed keys. + const keychains = + this.baseCoin.getFamily() === 'ofc' + ? [] + : await Promise.all(this._wallet.keys.map((k) => this.baseCoin.keychains().get({ id: k, reqId }))); const rootAddress = _.get(this._wallet, 'receiveAddress.address'); const newAddresses = _.times(count, async () => { diff --git a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletAddressCreation.ts b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletAddressCreation.ts new file mode 100644 index 0000000000..d1d1cacd96 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletAddressCreation.ts @@ -0,0 +1,154 @@ +/** + * @prettier + */ +import sinon from 'sinon'; +import 'should'; +import { Wallet } from '../../../../src/bitgo/wallet/wallet'; +import { OfcToken } from '../../../../src/coins'; + +const OFC_ZEC_CONFIG = { + coin: 'ofczec', + decimalPlaces: 8, + name: 'OFCZEC', + type: 'ofczec', + backingCoin: 'zec', + isFiat: false, +}; + +describe('Wallet - OFC createAddress', function () { + let mockBitGo: any; + let ofcToken: OfcToken; + let keychainsGetStub: sinon.SinonStub; + + function makePostChain(resolved: unknown): any { + const chain: any = {}; + chain.send = sinon.stub().returns(chain); + chain.result = sinon.stub().resolves(resolved); + return chain; + } + + beforeEach(function () { + keychainsGetStub = sinon.stub().resolves({ id: 'user-key-id', pub: 'xpub-value' }); + mockBitGo = { + url: sinon.stub().returns('https://test.bitgo.com/'), + post: sinon.stub(), + setRequestTracer: sinon.stub(), + }; + ofcToken = new OfcToken(mockBitGo, OFC_ZEC_CONFIG); + mockBitGo.coin = sinon.stub().returns(ofcToken); + sinon.stub(OfcToken.prototype, 'keychains').returns({ get: keychainsGetStub } as any); + }); + + afterEach(function () { + sinon.restore(); + }); + + const mockAddressResponse = { + id: 'new-address-id', + address: 'bg-aabbccddeeff00112233445566778899', + chain: 0, + index: 1, + }; + + describe('single-key OFC wallet (userKeySigningRequired: true)', function () { + it('should create a receive address without fetching any keychains', async function () { + const walletData = { + id: 'wallet-id', + coin: 'ofc', + keys: ['user-key-id'], + type: 'trading', + multisigType: 'onchain', + enterprise: 'ent-id', + userKeySigningRequired: true, + }; + const wallet = new Wallet(mockBitGo, ofcToken, walletData); + mockBitGo.post.returns(makePostChain(mockAddressResponse)); + + const result = await wallet.createAddress({ onToken: 'ofczec' }); + + result.should.have.property('id', 'new-address-id'); + result.should.have.property('address', 'bg-aabbccddeeff00112233445566778899'); + keychainsGetStub.called.should.be.false(); + }); + }); + + describe('two-key OFC wallet (userKeySigningRequired: false)', function () { + it('should create a receive address without fetching keychains', async function () { + const walletData = { + id: 'wallet-id', + coin: 'ofc', + keys: ['user-key-id', 'bitgo-key-id'], + type: 'trading', + multisigType: 'onchain', + enterprise: 'ent-id', + userKeySigningRequired: false, + }; + const wallet = new Wallet(mockBitGo, ofcToken, walletData); + mockBitGo.post.returns(makePostChain(mockAddressResponse)); + + const result = await wallet.createAddress({ onToken: 'ofczec' }); + + result.should.have.property('id', 'new-address-id'); + keychainsGetStub.called.should.be.false(); + }); + + it('should succeed even if keychains().get() would fail for a server-managed key', async function () { + keychainsGetStub.rejects(new Error('key not found')); + const walletData = { + id: 'wallet-id', + coin: 'ofc', + keys: ['user-key-id', 'bitgo-key-id'], + type: 'trading', + multisigType: 'onchain', + enterprise: 'ent-id', + userKeySigningRequired: false, + }; + const wallet = new Wallet(mockBitGo, ofcToken, walletData); + mockBitGo.post.returns(makePostChain(mockAddressResponse)); + + const result = await wallet.createAddress({ onToken: 'ofczec' }); + result.should.have.property('id', 'new-address-id'); + }); + }); + + describe('non-OFC wallet (eth)', function () { + it('should still fetch keychains for address verification', async function () { + const mockEthCoin: any = { + getFamily: sinon.stub().returns('eth'), + isEVM: sinon.stub().returns(true), + supportsTss: sinon.stub().returns(false), + url: sinon.stub().returns('/api/v2/eth/wallet/wallet-id/address'), + isWalletAddress: sinon.stub().resolves(true), + keychains: sinon.stub().returns({ get: keychainsGetStub }), + }; + const walletData = { + id: 'wallet-id', + coin: 'eth', + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + coinSpecific: { pendingChainInitialization: false }, + }; + const wallet = new Wallet(mockBitGo, mockEthCoin, walletData); + mockBitGo.post.returns(makePostChain({ id: 'eth-addr-id', address: '0xabc', coinSpecific: {} })); + + await wallet.createAddress({}); + + keychainsGetStub.callCount.should.equal(3); + }); + }); + + describe('missing onToken parameter', function () { + it('should throw for OFC wallets when onToken is omitted', async function () { + const walletData = { + id: 'wallet-id', + coin: 'ofc', + keys: ['user-key-id'], + type: 'trading', + multisigType: 'onchain', + enterprise: 'ent-id', + }; + const wallet = new Wallet(mockBitGo, ofcToken, walletData); + + await wallet.createAddress({}).should.be.rejectedWith('onToken is a mandatory parameter for OFC wallets'); + }); + }); +}); From c578250a1aa6df1ae44c641cb527dde1abbeb10e Mon Sep 17 00:00:00 2001 From: vinhkhangtieu Date: Fri, 12 Jun 2026 15:25:23 -0400 Subject: [PATCH 14/24] feat: skip forceV1Auth when HMAC present for SSO Ticket: ANT-963 TICKET: ANT-963 --- modules/sdk-api/src/bitgoAPI.ts | 15 +++-- modules/sdk-api/test/unit/bitgoAPI.ts | 88 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 6eeb41b6d5..8be93026b4 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -1561,8 +1561,9 @@ export class BitGoAPI implements BitGoBase { const authUrl = this.microservicesUrl('/api/auth/v1/accesstoken'); const request = this.post(authUrl); - if (!this._ecdhXprv) { - // without a private key, the user cannot decrypt the new access token the server will send + const strategyAuthenticated = this._hmacAuthStrategy.isAuthenticated?.() ?? false; + if (!this._ecdhXprv && !strategyAuthenticated) { + // No ECDH key and no authenticated HMAC strategy — fall back to V1 Bearer auth. request.forceV1Auth = true; debug('forcing v1 auth for adding access token using token %s', this._token?.substr(0, 8)); } @@ -1576,8 +1577,14 @@ export class BitGoAPI implements BitGoBase { // verify the authenticity of the server's response before proceeding any further await verifyResponseAsync(this, this._token, 'post', request, response, this._authVersion); - const responseDetails = await this.handleTokenIssuanceAsync(response.body); - response.body.token = responseDetails.token; + // When ecdhXprv is available, the server returns an ECDH-encrypted token that + // must be decrypted. When the HMAC strategy is authenticated but ecdhXprv is + // absent (e.g. SSO/WebCrypto users), the server includes the plain token + // directly in response.body.token — no decryption step needed. + if (this._ecdhXprv) { + const responseDetails = await this.handleTokenIssuanceAsync(response.body); + response.body.token = responseDetails.token; + } return handleResponseResult()(response); } catch (e) { diff --git a/modules/sdk-api/test/unit/bitgoAPI.ts b/modules/sdk-api/test/unit/bitgoAPI.ts index 7717fa7ac2..59a934ad6d 100644 --- a/modules/sdk-api/test/unit/bitgoAPI.ts +++ b/modules/sdk-api/test/unit/bitgoAPI.ts @@ -699,6 +699,94 @@ describe('Constructor', function () { setTokenStub.called.should.be.false(); }); }); + + describe('addAccessToken()', function () { + const validParams = { + label: 'test-token', + scope: ['wallet_view_all'], + duration: 3600, + }; + + it('should use HMAC auth when ecdhXprv is absent but hmacAuthStrategy is authenticated', async function () { + const { strategy } = makeStrategy({ + isAuthenticated: sinon.stub().returns(true), + }); + const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy }); + // Do NOT set _ecdhXprv — simulates SSO/WebCrypto session + // Set a v2x token so the request goes through the v2 auth path + bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' }); + + const scope = nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xnewplaintoken', + label: 'test-token', + }); + + const result = await bitgo.addAccessToken(validParams); + + scope.isDone().should.be.true(); + // forceV1Auth should NOT have been set, so no downgrade warning + (result as any).should.not.have.property('warning'); + // The plain token from the response body should be returned directly + result.token.should.equal('v2xnewplaintoken'); + }); + + it('should return plain token from response body when ecdhXprv is absent but strategyAuthenticated', async function () { + const handleTokenSpy = sinon.spy(BitGoAPI.prototype, 'handleTokenIssuanceAsync'); + const { strategy } = makeStrategy({ + isAuthenticated: sinon.stub().returns(true), + }); + const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy }); + bitgo.authenticateWithAccessToken({ accessToken: 'v2xstrategytoken' }); + + nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xplaintoken', + label: 'test-token', + }); + + const result = await bitgo.addAccessToken(validParams); + + // handleTokenIssuanceAsync should NOT be called — no ECDH decryption needed + handleTokenSpy.called.should.be.false(); + result.token.should.equal('v2xplaintoken'); + + handleTokenSpy.restore(); + }); + + it('should still force V1 auth when neither ecdhXprv nor strategy is authenticated', async function () { + const { strategy } = makeStrategy({ + isAuthenticated: sinon.stub().returns(false), + }); + const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy }); + bitgo.authenticateWithAccessToken({ accessToken: 'v2xlegacytoken' }); + + nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xlegacyresult', + label: 'test-token', + }); + + const result = await bitgo.addAccessToken(validParams); + + // V1 auth path should add the downgrade warning + (result as any).warning.should.match(/protocol downgrade/); + }); + + it('should still force V1 auth when isAuthenticated is not defined on strategy', async function () { + const { strategy } = makeStrategy(); + // strategy has no isAuthenticated method by default from makeStrategy + const bitgo = new BitGoAPI({ env: 'custom', customRootURI: ROOT, hmacAuthStrategy: strategy }); + bitgo.authenticateWithAccessToken({ accessToken: 'v2xnoauthmethod' }); + + nock(ROOT).post('/api/auth/v1/accesstoken').reply(200, { + token: 'v2xresult', + label: 'test-token', + }); + + const result = await bitgo.addAccessToken(validParams); + + // Without isAuthenticated, should fall back to V1 auth + (result as any).warning.should.match(/protocol downgrade/); + }); + }); }); describe('constants parameter', function () { From 448229ac81fddd6e557d09df7f8ebc5d9e738c7c Mon Sep 17 00:00:00 2001 From: rajangarg047 Date: Fri, 12 Jun 2026 15:45:39 -0400 Subject: [PATCH 15/24] feat(sdk-coin-sol): implement deriveAddress for SOL Override BaseCoin.deriveAddress on the Sol coin to locally derive a receive address from the wallet's commonKeychain + index, reusing the shared deriveMPCWalletAddress (ed25519) helper. This is the inverse of isWalletAddress and shares its exact derivation path, so derive and verify can never diverge. Offline and key-material-free (public keys only). Supports the SMC prefix path via derivedFromParentWithSeed. Adds unit coverage including a derive->verify round-trip and an SMC-seed case. WCN-917 Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/sdk-coin-sol/src/sol.ts | 27 +++++++++++++++++ modules/sdk-coin-sol/test/unit/sol.ts | 43 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 9f2aaf29fa..c16cd651fc 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -54,6 +54,9 @@ import { VerifyTransactionOptions, TssVerifyAddressOptions, verifyEddsaTssWalletAddress, + deriveMPCWalletAddress, + DeriveAddressOptions, + DeriveAddressResult, UnexpectedAddressError, } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; @@ -714,6 +717,30 @@ export class Sol extends BaseCoin { return true; } + /** + * Locally derive a SOL wallet receive address from a derivation path, using the wallet's + * commonKeychain only (no private keys, no network access). This is the inverse of + * {@link isWalletAddress}: it *produces* the address via the same EdDSA TSS derivation that + * isWalletAddress checks against, so the two can never diverge. + * @param params keychains (commonKeychain), derivation index, and optional SMC seed + * @returns the derived address, the index used, and the HD derivation path + */ + async deriveAddress(params: DeriveAddressOptions): Promise { + const { address, derivationPath } = await deriveMPCWalletAddress( + { + // extractCommonKeychain validates the commonKeychain is present at runtime + keychains: (params.keychains ?? []) as TssVerifyAddressOptions['keychains'], + index: params.index, + derivedFromParentWithSeed: params.derivedFromParentWithSeed, + multisigTypeVersion: params.multisigTypeVersion, + keyCurve: 'ed25519', + }, + (publicKey) => this.getAddressFromPublicKey(publicKey) + ); + + return { address, index: params.index, derivationPath }; + } + /** * Converts a Solana public key to an address * @param publicKey Hex-encoded public key (64 hex characters = 32 bytes) diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 040d99ad76..3f244285b7 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -3842,6 +3842,49 @@ describe('SOL:', function () { }); }); + describe('deriveAddress', () => { + // Same vector as the isWalletAddress tests above: commonKeychain @ index 1 -> address. + const address = '7YAesfwPk41VChUgr65bm8FEep7ymWqLSW5rpYB5zZPY'; + const commonKeychain = + '8ea32ecacfc83effbd2e2790ee44fa7c59b4d86c29a12f09fb613d8195f93f4e21875cad3b98adada40c040c54c3569467df41a020881a6184096378701862bd'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain }]; + + it('should derive the expected address for a given commonKeychain and index', async function () { + const result = await basecoin.deriveAddress({ keychains, index: 1 }); + result.address.should.equal(address); + result.index.should.equal(1); + assert.strictEqual(result.derivationPath, 'm/1'); + }); + + it('should derive a different address for a different index', async function () { + const result = await basecoin.deriveAddress({ keychains, index: 2 }); + result.address.should.not.equal(address); + }); + + it('round-trips with isWalletAddress (derived address verifies as true)', async function () { + const derived = await basecoin.deriveAddress({ keychains, index: 5 }); + const verified = await basecoin.isWalletAddress({ keychains, address: derived.address, index: 5 }); + verified.should.equal(true); + }); + + it('should use the SMC prefix path when derivedFromParentWithSeed is set', async function () { + const derivedFromParentWithSeed = 'smc-test-seed-123'; + const derived = await basecoin.deriveAddress({ keychains, index: 1, derivedFromParentWithSeed }); + // round-trips through the SMC verify path + const verified = await basecoin.isWalletAddress({ + keychains, + address: derived.address, + index: 1, + derivedFromParentWithSeed, + }); + verified.should.equal(true); + }); + + it('should throw if keychains are missing', async function () { + await assert.rejects(async () => await basecoin.deriveAddress({ keychains: [], index: 0 }), /keychains/); + }); + }); + describe('getAddressFromPublicKey', () => { it('should convert public key to base58 address', function () { const publicKey = '61220a9394802b1d1df37b35f7a3197970f48081092cee011fc98f7b71b2bd43'; From 59dc3d2f3daabc17fef0b36245f0bedf380581d1 Mon Sep 17 00:00:00 2001 From: rajangarg047 Date: Fri, 12 Jun 2026 15:48:44 -0400 Subject: [PATCH 16/24] feat(abstract-eth): implement deriveAddress for MPC/TSS ETH wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override BaseCoin.deriveAddress on AbstractEthLikeNewCoins to locally derive a receive address from the wallet's commonKeychain + index for MPC/TSS wallets (wallet versions 3, 5, 6), reusing the shared deriveMPCWalletAddress (secp256k1) helper plus KeyPair.getAddress() — the exact derivation isWalletAddress checks against, so derive and verify can never diverge. Offline and key-material-free (public keys only). Legacy BIP32 forwarder wallets (versions 1, 2, 4) throw a clear error and are handled in a separate ticket. Adds unit coverage (in sdk-coin-eth) asserting exact-match against the existing MPC test vector, a derive->verify round-trip, the forwarder-version guard, and the missing-keychains error. WCN-916 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/abstractEthLikeNewCoins.ts | 40 +++++++++++++++ modules/sdk-coin-eth/test/unit/eth.ts | 50 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 9a102036e2..89e3bd9482 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -41,8 +41,11 @@ import { VerifyTransactionOptions, Wallet, verifyMPCWalletAddress, + deriveMPCWalletAddress, TssVerifyAddressOptions, isTssVerifyAddressOptions, + DeriveAddressOptions, + DeriveAddressResult, } from '@bitgo/sdk-core'; import { getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { bip32 } from '@bitgo/secp256k1'; @@ -3040,6 +3043,43 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { throw new Error(`Base address verification not supported for wallet version ${params.walletVersion}`); } + /** + * Locally derive an ETH wallet receive address from a derivation path, using the wallet's + * commonKeychain only (no private keys, no network access). The inverse of + * {@link isWalletAddress}: it *produces* the address via the same secp256k1 MPC derivation + * that isWalletAddress checks against (`deriveMPCWalletAddress` + `KeyPair.getAddress()`), + * so derive and verify can never diverge. + * + * Supports MPC/TSS wallets only (wallet versions 3, 5, 6). Legacy BIP32 forwarder wallets + * (versions 1, 2, 4) derive per-index forwarder contract addresses and are handled separately. + * @param params keychains (commonKeychain), derivation index, walletVersion, and optional SMC seed + * @returns the derived address, the index used, and the HD derivation path + */ + async deriveAddress(params: DeriveAddressOptions): Promise { + const isMpcWallet = params.walletVersion === 3 || params.walletVersion === 5 || params.walletVersion === 6; + if (!isMpcWallet) { + throw new Error( + `deriveAddress currently supports only MPC/TSS ETH wallets (wallet versions 3, 5, 6). ` + + `Legacy BIP32 forwarder wallets (versions 1, 2, 4) are not yet supported. ` + + `Got walletVersion ${params.walletVersion}.` + ); + } + + const { address, derivationPath } = await deriveMPCWalletAddress( + { + // extractCommonKeychain validates the commonKeychain is present at runtime + keychains: (params.keychains ?? []) as TssVerifyAddressOptions['keychains'], + index: params.index, + derivedFromParentWithSeed: params.derivedFromParentWithSeed, + multisigTypeVersion: params.multisigTypeVersion, + keyCurve: 'secp256k1', + }, + (pubKey) => new KeyPairLib({ pub: pubKey }).getAddress() + ); + + return { address, index: params.index, derivationPath }; + } + /** * * @param {TransactionPrebuild} txPrebuild diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index b544ea7bb0..8534388aa4 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -1486,6 +1486,56 @@ describe('ETH:', function () { }); }); + describe('deriveAddress (MPC)', function () { + // Same vector as the MPC isWalletAddress tests above: + // commonKeychain @ index 0 -> 0x01153f3adfe454a72589ca9ef74f013c19e54961 + const commonKeychain = + '03f9c2fb2e5a8b78a44f5d1e4f906f8e3d7a0e6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9e8d7c6b5a4' + + '93827160594857463728190a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + const keychains = [ + { pub: 'user_pub', commonKeychain }, + { pub: 'backup_pub', commonKeychain }, + { pub: 'bitgo_pub', commonKeychain }, + ]; + + it('should derive the expected MPC address for a commonKeychain at index 0', async function () { + const coin = bitgo.coin('teth') as Teth; + const result = await coin.deriveAddress({ keychains, index: 0, walletVersion: 3 }); + result.address.should.equal('0x01153f3adfe454a72589ca9ef74f013c19e54961'); + result.index.should.equal(0); + assert.strictEqual(result.derivationPath, 'm/0'); + }); + + it('round-trips with isWalletAddress (derived address verifies as true)', async function () { + const coin = bitgo.coin('teth') as Teth; + const derived = await coin.deriveAddress({ keychains, index: 3, walletVersion: 6 }); + const verified = await coin.isWalletAddress({ + address: derived.address, + coinSpecific: { forwarderVersion: 5 }, + keychains, + index: 3, + walletVersion: 6, + } as unknown as TssVerifyEthAddressOptions); + verified.should.equal(true); + }); + + it('should throw for legacy BIP32 forwarder wallet versions (1/2/4)', async function () { + const coin = bitgo.coin('teth') as Teth; + await assert.rejects( + async () => coin.deriveAddress({ keychains, index: 0, walletVersion: 1 }), + /only MPC\/TSS ETH wallets/ + ); + }); + + it('should throw if keychains are missing', async function () { + const coin = bitgo.coin('teth') as Teth; + await assert.rejects( + async () => coin.deriveAddress({ keychains: [], index: 0, walletVersion: 3 }), + /keychains/ + ); + }); + }); + describe('Base Address Verification', function () { it('should verify base address for wallet version 6 (TSS)', async function () { const coin = bitgo.coin('hteth') as Hteth; From 44c361f544b10d4eb778c0d92c6616cfc33de6ad Mon Sep 17 00:00:00 2001 From: rajangarg047 Date: Fri, 12 Jun 2026 15:52:18 -0400 Subject: [PATCH 17/24] feat(abstract-utxo): implement deriveAddress for fixed-script UTXO wallets Override BaseCoin.deriveAddress on AbstractUtxoCoin to locally derive a 2-of-3 multisig receive address from the xpub triple and a chain/index, delegating to the existing generateAddress used by the isWalletAddress verification path, so derive and verify can never diverge. Offline and key-material-free (public keys only). The chain code selects the script type (P2SH, P2WSH/bech32, P2TR) with an optional format override. Adds unit coverage for legacy P2SH (chain 0) and bech32 P2WSH (chain 20): derived address matches generateAddress, plus a derive->verify round-trip. WCN-915 Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 33 ++++++++++++++++++- modules/abstract-utxo/test/unit/address.ts | 29 +++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 4ebe72d4c3..d3be6293e1 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -41,6 +41,8 @@ import { Wallet, isValidPrv, isValidXprv, + DeriveAddressOptions, + DeriveAddressResult, } from '@bitgo/sdk-core'; import { @@ -75,7 +77,7 @@ import { } from './transaction/descriptor/verifyTransaction'; import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptorWallet } from './descriptor'; import { getFullNameFromCoinName, getMainnetCoinName, isMainnetCoin, UtxoCoinName, UtxoCoinNameMainnet } from './names'; -import { assertFixedScriptWalletAddress } from './address/fixedScript'; +import { assertFixedScriptWalletAddress, generateAddress } from './address/fixedScript'; import { ParsedTransaction } from './transaction/types'; import { decodeDescriptorPsbt, decodePsbt, encodeTransaction, stringToBufferTryFormats } from './transaction/decode'; import { fetchKeychains, toBip32Triple, UtxoKeychain } from './keychains'; @@ -716,6 +718,35 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici return true; } + /** + * Locally derive a fixed-script (2-of-3 multisig) wallet receive address from the xpub triple + * and a chain/index, using public keys only (no private keys, no network access). This is the + * inverse of {@link isWalletAddress}, delegating to the same `generateAddress` used by the + * verification path, so derive and verify can never diverge. + * + * The `chain` code selects the script type (e.g. 0/1 = P2SH, 20/21 = P2WSH/bech32, 30/31 = P2TR); + * an optional `format` overrides the address encoding. + * @param params keychains (xpub triple via `pub`), chain, index, and optional format + * @returns the derived address and the chain/index used + */ + async deriveAddress(params: DeriveAddressOptions): Promise { + const { keychains, chain, index, format } = params; + + if (!keychains) { + throw new Error('missing required param keychains'); + } + + const address = generateAddress(this.name, { + // fixed-script (multisig) coins derive from the xpub triple via `pub` + keychains: keychains as { pub: string }[], + chain, + index, + format, + }); + + return { address, chain, index }; + } + /** * @param addressType * @returns true iff coin supports spending from unspentType diff --git a/modules/abstract-utxo/test/unit/address.ts b/modules/abstract-utxo/test/unit/address.ts index 8a66840f64..362d16d3ad 100644 --- a/modules/abstract-utxo/test/unit/address.ts +++ b/modules/abstract-utxo/test/unit/address.ts @@ -5,7 +5,7 @@ import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { assertFixedScriptWalletAddress, generateAddress } from '../../src'; -import { keychainsBase58 } from './util'; +import { getUtxoCoin, keychainsBase58 } from './util'; const keychains = keychainsBase58.map((k) => ({ pub: k.pub })); @@ -202,3 +202,30 @@ describe('assertFixedScriptWalletAddress', function () { }); }); }); + +describe('AbstractUtxoCoin.deriveAddress', function () { + const coin = getUtxoCoin('btc'); + + // Bullish scope: legacy P2SH (chain 0) + bech32 P2WSH (chain 20). + for (const { label, chain } of [ + { label: 'legacy P2SH', chain: 0 }, + { label: 'bech32 P2WSH', chain: 20 }, + ]) { + it(`derives the ${label} address (chain ${chain}) matching generateAddress`, async function () { + const result = await coin.deriveAddress({ keychains, chain, index: 0 }); + result.address.should.equal(generateAddress('btc', { keychains, chain, index: 0 })); + result.chain!.should.equal(chain); + result.index!.should.equal(0); + }); + + it(`round-trips with isWalletAddress for ${label} (chain ${chain})`, async function () { + const { address } = await coin.deriveAddress({ keychains, chain, index: 3 }); + const verified = await coin.isWalletAddress({ address, keychains, chain, index: 3 }); + verified.should.equal(true); + }); + } + + it('throws if keychains are missing', async function () { + await assert.rejects(async () => coin.deriveAddress({ chain: 0, index: 0 }), /keychains/); + }); +}); From 3be25b787a3449a1aaa0d4b92598dec438ffff2c Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Mon, 15 Jun 2026 15:17:13 +0530 Subject: [PATCH 18/24] feat: exclude CVE related to esbuild's Deno distribution for Node.js project Ticket: CECHO-1295 --- .iyarc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.iyarc b/.iyarc index 6e388ef3ce..19ab0f6f42 100644 --- a/.iyarc +++ b/.iyarc @@ -75,3 +75,10 @@ GHSA-2w8x-224x-785m # - The xmp bypass produces live HTML markup in output, but since we discard all tags and use # the result as plain text in Error messages, there is no DOM rendering path and no XSS risk GHSA-rpr9-rxv7-x643 + +# Excluded because: +# - CVE affects esbuild's Deno distribution only: binary downloads without SHA-256 integrity verification +# - BitGoJS is a Node.js project; the Node.js esbuild distribution already includes binaryIntegrityCheck() +# - esbuild is a dev-time build tool (via babylonlabs-io-btc-staking-ts), not runtime production code +# - The attacker-controlled NPM_CONFIG_REGISTRY vector does not apply to our controlled CI environment +GHSA-gv7w-rqvm-qjhr From c54e674c02a5684df204d1f57035f037d857cb43 Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Mon, 15 Jun 2026 20:18:43 +0530 Subject: [PATCH 19/24] fix(sdk-coin-sol): skip checking recipients in case of ATA tx TICKET: CHALO-624 --- modules/sdk-coin-sol/src/sol.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 9f2aaf29fa..c01bbf0894 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -562,9 +562,10 @@ export class Sol extends BaseCoin { } } + const isTokenEnablementTx = txParams.type === 'enabletoken'; // users do not input recipients for consolidation requests as they are generated by the server // Close-ATA txs do not populate explainedTx.outputs; recipients carry ATA addresses for intent only. - if (txParams.recipients !== undefined && !isCloseAssociatedTokenAccountTx) { + if (txParams.recipients !== undefined && !isTokenEnablementTx && !isCloseAssociatedTokenAccountTx) { const filteredRecipients = txParams.recipients?.map((recipient) => _.pick(recipient, ['address', 'amount', 'tokenName']) ); @@ -662,7 +663,7 @@ export class Sol extends BaseCoin { if (memo && memo.value !== explainedTx.memo) { throw new Error('Tx memo does not match with expected txParams recipient memo'); } - if (txParams.recipients && !isCloseAssociatedTokenAccountTx) { + if (txParams.recipients && !isTokenEnablementTx && !isCloseAssociatedTokenAccountTx) { for (const recipients of txParams.recipients) { // totalAmount based on each token const assetName = recipients.tokenName || this.getChain(); From 3f286af39000a58b1b024caef12f2d99ebe16797 Mon Sep 17 00:00:00 2001 From: rajangarg047 Date: Fri, 12 Jun 2026 13:17:20 -0400 Subject: [PATCH 20/24] feat(express): add POST /api/v2/:coin/address/derive endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an offline endpoint that locally derives and returns a wallet receive address from a derivation path, the inverse of iswalletaddress. The handler operates purely on the request body (keychains + chain/index) via coin.deriveAddress — no wallets().get lookup and no network access, so it can run in an air-gapped Express. Stateless: the caller supplies the index; the endpoint never allocates server-side. Pairs with iswalletaddress for a derive->verify round-trip. - typed route schema modules/express/src/typedRoutes/api/v2/deriveAddress.ts - registered in typedRoutes/api/index.ts - handler handleV2DeriveAddress + route registration in clientRoutes.ts - codec + supertest integration tests (UTXO + TSS/MPC, 400s, error surfacing) WCN-914 Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/express/src/clientRoutes.ts | 15 + modules/express/src/typedRoutes/api/index.ts | 9 + .../src/typedRoutes/api/v2/deriveAddress.ts | 96 +++++++ .../test/unit/typedRoutes/deriveAddress.ts | 272 ++++++++++++++++++ 4 files changed, 392 insertions(+) create mode 100644 modules/express/src/typedRoutes/api/v2/deriveAddress.ts create mode 100644 modules/express/test/unit/typedRoutes/deriveAddress.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 5c87e6cbcc..7761fcca75 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -721,6 +721,20 @@ export async function handleV2IsWalletAddress( return await wallet.baseCoin.isWalletAddress(req.decoded as any); } +/** + * handle v2 deriveAddress - locally derive and return a wallet receive address from a + * derivation path, using public key material only. + * + * Offline by design: operates purely on the request body (keychains + chain/index), with no + * `wallets().get` lookup and no network access. The inverse of {@link handleV2IsWalletAddress}. + * @param req + */ +export async function handleV2DeriveAddress(req: ExpressApiRouteRequest<'express.v2.address.derive', 'post'>) { + const bitgo = req.bitgo; + const coin = bitgo.coin(req.decoded.coin); + return await coin.deriveAddress(req.decoded as any); +} + /** * handle v2 approve transaction * @param req @@ -1963,6 +1977,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { prepareBitGo(config), typedPromiseWrapper(handleV2IsWalletAddress), ]); + router.post('express.v2.address.derive', [prepareBitGo(config), typedPromiseWrapper(handleV2DeriveAddress)]); router.post('express.wallet.share', [prepareBitGo(config), typedPromiseWrapper(handleV2ShareWallet)]); app.post( diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index f8e2806c1f..85adc8a2ea 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -58,6 +58,7 @@ import { PostWalletEnableTokens } from './v2/walletEnableTokens'; import { PostWalletSweep } from './v2/walletSweep'; import { PostWalletAccelerateTx } from './v2/walletAccelerateTx'; import { PostIsWalletAddress } from './v2/isWalletAddress'; +import { PostDeriveAddress } from './v2/deriveAddress'; import { GetAccountResources } from './v2/accountResources'; import { GetResourceDelegations } from './v2/resourceDelegations'; import { PostDelegateResources } from './v2/delegateResources'; @@ -235,6 +236,12 @@ export const ExpressV2WalletIsWalletAddressApiSpec = apiSpec({ }, }); +export const ExpressV2AddressDeriveApiSpec = apiSpec({ + 'express.v2.address.derive': { + post: PostDeriveAddress, + }, +}); + export const ExpressV2WalletSendManyApiSpec = apiSpec({ 'express.wallet.sendmany': { post: PostSendMany, @@ -399,6 +406,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressWalletFanoutUnspentsApiSpec & typeof ExpressV2WalletCreateAddressApiSpec & typeof ExpressV2WalletIsWalletAddressApiSpec & + typeof ExpressV2AddressDeriveApiSpec & typeof ExpressKeychainLocalApiSpec & typeof ExpressKeychainChangePasswordApiSpec & typeof ExpressLightningWalletPaymentApiSpec & @@ -444,6 +452,7 @@ export const ExpressApi: ExpressApi = { ...ExpressV2WalletCreateAddressApiSpec, ...ExpressV2WalletConsolidateAccountApiSpec, ...ExpressV2WalletIsWalletAddressApiSpec, + ...ExpressV2AddressDeriveApiSpec, ...ExpressKeychainLocalApiSpec, ...ExpressKeychainChangePasswordApiSpec, ...ExpressLightningWalletPaymentApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/deriveAddress.ts b/modules/express/src/typedRoutes/api/v2/deriveAddress.ts new file mode 100644 index 0000000000..2ba1678117 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/deriveAddress.ts @@ -0,0 +1,96 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; +import { CreateAddressFormat } from '../../schemas/address'; + +/** + * Path parameters for locally deriving a wallet address + */ +export const DeriveAddressParams = { + /** Blockchain identifier (e.g., 'btc', 'eth', 'tbtc', 'teth', 'sol') */ + coin: t.string, +} as const; + +/** + * A keychain entry for local derivation. Public key material only — no private keys. + * Modelled as a union so a keychain must carry at least one of `pub` / `commonKeychain`: + * - `pub` (xpub) for BIP32 multisig coins (UTXO, legacy EVM) + * - `commonKeychain` for TSS/MPC coins (SOL, EVM MPC) — identical across keychains + * + * (A keychain may legitimately carry both; TSS keychains commonly do.) + */ +export const DeriveAddressKeychainCodec = t.union([t.type({ pub: t.string }), t.type({ commonKeychain: t.string })]); + +/** + * Request body for locally deriving a wallet receive address + */ +export const DeriveAddressBody = { + /** + * Keychains for derivation (public key material only). + * BIP32 multisig: the user/backup/bitgo xpub triple via `pub`. + * TSS/MPC: the `commonKeychain`. + */ + keychains: t.array(DeriveAddressKeychainCodec), + /** Derivation index for the address (caller-supplied; the endpoint is stateless) */ + index: t.number, + /** Derivation chain code: UTXO script-type / external(0) vs internal(1) selector */ + chain: optional(t.number), + /** Address format override (e.g. 'p2sh', 'p2wsh' for UTXO; 'cashaddr' / 'base58') */ + format: optional(CreateAddressFormat), + /** Wallet version, to disambiguate derivation strategy (e.g. EVM forwarder vs MPC) */ + walletVersion: optional(t.number), + /** + * Seed from the user keychain's derivedFromParentWithSeed field (SMC TSS wallets); + * makes the derivation path `{prefix}/{index}` instead of `m/{index}`. + */ + derivedFromParentWithSeed: optional(t.string), +} as const; + +/** + * Response for locally deriving a wallet address + */ +export const DeriveAddressResponse = { + /** The derived address and related derivation info */ + 200: t.intersection([ + t.type({ + /** The derived address */ + address: t.string, + /** The derivation index used */ + index: t.number, + }), + t.partial({ + /** The derivation chain code used */ + chain: t.number, + /** Coin-specific address data (e.g. redeemScript/witnessScript for UTXO) */ + coinSpecific: t.UnknownRecord, + /** The HD derivation path actually used */ + derivationPath: t.string, + }), + ]), + /** Invalid request parameters or derivation failed */ + 400: BitgoExpressError, +} as const; + +/** + * Locally derive and return a wallet receive address from a derivation path. + * + * Unlike `iswalletaddress` (which checks a candidate address), this *produces* the address + * offline from public key material only — the xpub triple for BIP32 multisig coins, or the + * commonKeychain for TSS/MPC coins. No private keys, no wallet lookup, and no network access: + * the handler operates purely on the request body and can run in an air-gapped Express. + * + * Pairs with `iswalletaddress` for a derive→verify round-trip: derive the address here, then + * verify it against the same keychains to independently confirm correctness. + * + * @operationId express.v2.address.derive + * @tag Express + */ +export const PostDeriveAddress = httpRoute({ + path: '/api/v2/{coin}/address/derive', + method: 'POST', + request: httpRequest({ + params: DeriveAddressParams, + body: DeriveAddressBody, + }), + response: DeriveAddressResponse, +}); diff --git a/modules/express/test/unit/typedRoutes/deriveAddress.ts b/modules/express/test/unit/typedRoutes/deriveAddress.ts new file mode 100644 index 0000000000..62629e96aa --- /dev/null +++ b/modules/express/test/unit/typedRoutes/deriveAddress.ts @@ -0,0 +1,272 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + DeriveAddressBody, + DeriveAddressParams, + DeriveAddressResponse, + DeriveAddressKeychainCodec, + PostDeriveAddress, +} from '../../../src/typedRoutes/api/v2/deriveAddress'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('DeriveAddress codec tests', function () { + const commonKeychain = + '033b02aac4f038fef5118350b77d302ec6202931ca2e7122aad88994ffefcbc70a6069e662436236abb1619195232c41580204cb202c22357ed8f53e69eac5c69e'; + + describe('DeriveAddressKeychainCodec', function () { + it('should validate a keychain with pub only (BIP32 multisig)', function () { + const decoded = assertDecode(DeriveAddressKeychainCodec, { pub: 'xpub1...' }); + assert.strictEqual((decoded as { pub: string }).pub, 'xpub1...'); + }); + + it('should validate a keychain with commonKeychain only (TSS/MPC)', function () { + const decoded = assertDecode(DeriveAddressKeychainCodec, { commonKeychain }); + assert.strictEqual((decoded as { commonKeychain: string }).commonKeychain, commonKeychain); + }); + + it('should validate a keychain carrying both pub and commonKeychain (TSS keychains commonly do)', function () { + const decoded = assertDecode(DeriveAddressKeychainCodec, { pub: 'user_pub', commonKeychain }); + assert.strictEqual((decoded as { pub: string }).pub, 'user_pub'); + }); + + it('should reject a keychain with neither pub nor commonKeychain', function () { + assert.throws(() => { + assertDecode(DeriveAddressKeychainCodec, { ethAddress: '0xf45dadce751a317957f2a247ff37cb764b97620d' }); + }); + }); + }); + + describe('DeriveAddressParams', function () { + it('should validate params with coin', function () { + const decoded = assertDecode(t.type(DeriveAddressParams), { coin: 'btc' }); + assert.strictEqual(decoded.coin, 'btc'); + }); + + it('should reject params with missing coin', function () { + assert.throws(() => { + assertDecode(t.type(DeriveAddressParams), {}); + }); + }); + }); + + describe('DeriveAddressBody', function () { + it('should validate body with the minimum required fields (keychains and index)', function () { + const validBody = { + keychains: [{ pub: 'xpub1...' }, { pub: 'xpub2...' }, { pub: 'xpub3...' }], + index: 42, + }; + + const decoded = assertDecode(t.type(DeriveAddressBody), validBody); + assert.strictEqual(decoded.keychains.length, 3); + assert.strictEqual(decoded.index, 42); + }); + + it('should validate a UTXO body with chain and format', function () { + const validBody = { + keychains: [{ pub: 'xpub1...' }, { pub: 'xpub2...' }, { pub: 'xpub3...' }], + index: 0, + chain: 20, + format: 'base58', + }; + + const decoded = assertDecode(t.type(DeriveAddressBody), validBody); + assert.strictEqual(decoded.chain, 20); + assert.strictEqual(decoded.format, 'base58'); + }); + + it('should validate a TSS/MPC body with commonKeychain and SMC seed', function () { + const validBody = { + keychains: [{ commonKeychain }, { commonKeychain }, { commonKeychain }], + index: 7, + walletVersion: 6, + derivedFromParentWithSeed: 'my-unique-smc-seed-123', + }; + + const decoded = assertDecode(t.type(DeriveAddressBody), validBody); + assert.strictEqual(decoded.walletVersion, 6); + assert.strictEqual(decoded.derivedFromParentWithSeed, 'my-unique-smc-seed-123'); + }); + + it('should reject body with missing index', function () { + assert.throws(() => { + assertDecode(t.type(DeriveAddressBody), { keychains: [{ pub: 'xpub1...' }] }); + }); + }); + + it('should reject body with missing keychains', function () { + assert.throws(() => { + assertDecode(t.type(DeriveAddressBody), { index: 0 }); + }); + }); + + it('should reject body with a non-numeric index', function () { + assert.throws(() => { + assertDecode(t.type(DeriveAddressBody), { keychains: [{ pub: 'xpub1...' }], index: '0' }); + }); + }); + + it('should reject body with non-array keychains', function () { + assert.throws(() => { + assertDecode(t.type(DeriveAddressBody), { keychains: 'not-an-array', index: 0 }); + }); + }); + }); + + describe('DeriveAddressResponse', function () { + it('should validate a 200 response with the minimum fields', function () { + const decoded = assertDecode(DeriveAddressResponse[200], { address: 'bc1qxyz', index: 0 }); + assert.strictEqual(decoded.address, 'bc1qxyz'); + assert.strictEqual(decoded.index, 0); + }); + + it('should validate a 200 response with chain, coinSpecific and derivationPath', function () { + const decoded = assertDecode(DeriveAddressResponse[200], { + address: 'bc1qxyz', + index: 42, + chain: 20, + coinSpecific: { witnessScript: '00...' }, + derivationPath: 'm/42', + }); + assert.strictEqual(decoded.derivationPath, 'm/42'); + }); + + it('should reject a 200 response missing the address', function () { + assert.throws(() => { + assertDecode(DeriveAddressResponse[200], { index: 0 }); + }); + }); + }); + + describe('PostDeriveAddress route definition', function () { + it('should have the correct path (no wallet id — operates on body only)', function () { + assert.strictEqual(PostDeriveAddress.path, '/api/v2/{coin}/address/derive'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostDeriveAddress.method, 'POST'); + }); + + it('should have the correct response types', function () { + assert.ok(PostDeriveAddress.response[200]); + assert.ok(PostDeriveAddress.response[400]); + }); + }); + + // ========================================== + // SUPERTEST INTEGRATION TESTS + // ========================================== + + describe('Supertest Integration Tests', function () { + const agent = setupAgent(); + + afterEach(function () { + sinon.restore(); + }); + + it('should derive a UTXO address from keychains + chain/index (no wallet lookup)', async function () { + const requestBody = { + keychains: [{ pub: 'xpub1...' }, { pub: 'xpub2...' }, { pub: 'xpub3...' }], + chain: 20, + index: 42, + }; + + const deriveAddressStub = sinon.stub().resolves({ + address: 'bc1qderivedaddress', + chain: 20, + index: 42, + derivationPath: 'm/42', + }); + // Note: the handler calls coin.deriveAddress directly — there is no wallets().get, + // so the mock coin intentionally exposes no wallets() method. + const mockCoin = { deriveAddress: deriveAddressStub }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post('/api/v2/btc/address/derive') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.address, 'bc1qderivedaddress'); + assert.strictEqual(result.body.index, 42); + + sinon.assert.calledOnce(deriveAddressStub); + const callArgs = deriveAddressStub.firstCall.args[0]; + assert.strictEqual(callArgs.index, 42); + assert.strictEqual(callArgs.chain, 20); + }); + + it('should derive a TSS/MPC address and pass derivedFromParentWithSeed through', async function () { + const requestBody = { + keychains: [{ commonKeychain }, { commonKeychain }, { commonKeychain }], + index: 1, + derivedFromParentWithSeed: 'smc-seed-abc', + }; + + const deriveAddressStub = sinon.stub().resolves({ + address: '7YAesfwPk41VChUgr65bm8FEep7ymWqLSW5rpYB5zZPY', + index: 1, + derivationPath: 'm/999999/0/0/1', + }); + const mockCoin = { deriveAddress: deriveAddressStub }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post('/api/v2/sol/address/derive') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.address, '7YAesfwPk41VChUgr65bm8FEep7ymWqLSW5rpYB5zZPY'); + + sinon.assert.calledOnce(deriveAddressStub); + const callArgs = deriveAddressStub.firstCall.args[0]; + assert.strictEqual(callArgs.derivedFromParentWithSeed, 'smc-seed-abc'); + }); + + it('should return 400 for missing index', async function () { + const result = await agent + .post('/api/v2/btc/address/derive') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send({ keychains: [{ pub: 'xpub1...' }] }); + + assert.strictEqual(result.status, 400); + assert.ok(result.body.error); + }); + + it('should return 400 for missing keychains', async function () { + const result = await agent + .post('/api/v2/btc/address/derive') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send({ index: 0 }); + + assert.strictEqual(result.status, 400); + assert.ok(result.body.error); + }); + + it('should surface derivation errors (coin.deriveAddress throws)', async function () { + const deriveAddressStub = sinon.stub().rejects(new Error('deriveAddress is not supported for this coin')); + const mockCoin = { deriveAddress: deriveAddressStub }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post('/api/v2/xrp/address/derive') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send({ keychains: [{ commonKeychain }], index: 0 }); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + }); +}); From 95b7c384d4b49b1ed938b0ed384ff02197e92e9f Mon Sep 17 00:00:00 2001 From: Vibhav Simha G Date: Tue, 2 Jun 2026 17:39:12 +0530 Subject: [PATCH 21/24] feat(sdk-core): add getEddsaMPCv2RecoveryKeyShares helper Adds a standalone helper to decrypt and validate both EdDSA MPCv2 reduced keycards, returning typed Buffer key shares and the derived commonKeyChain. Mirrors the ECDSA getMpcV2RecoveryKeyShares pattern as part of the SJCL-to-Argon2 migration. - Decrypt both keycards in parallel via Promise.all - Use bitgo.decryptAsync (v1 + v2) when a BitGoBase instance is provided; fall back to sjcl.decrypt (v1 only) otherwise - Validate pub and rootChainCode separately with distinct error messages - Wrap getDecodedReducedKeyShare in try-catch to surface a descriptive error for malformed or public-only keycards - Export type for recovery key shares - Add 3 unit tests: v1 happy path, malformed keycard, mismatched keys Ticket: WCI-396 --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 62 +++++++++++ .../src/bitgo/utils/tss/eddsa/types.ts | 6 ++ .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 101 ++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 70f29e81ab..520b73fd7e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import * as pgp from 'openpgp'; +import * as sjcl from '@bitgo/sjcl'; import { NonEmptyString } from 'io-ts-types'; import { EddsaMPCv2KeyGenRound1Request, @@ -41,8 +42,10 @@ import { isV2Envelope, } from '../baseTypes'; import { EncryptionVersion } from '../../../../api'; +import { BitGoBase } from '../../../bitgoBase'; import { BaseEddsaUtils } from './base'; import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; +import { EddsaMPCv2RecoveryKeyShares } from './types'; export class EddsaMPCv2Utils extends BaseEddsaUtils { private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY'; @@ -923,3 +926,62 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { } // #endregion } + +/** + * Get EdDSA MPCv2 recovery key shares from encrypted reduced user and backup keys. + * + * The encrypted inputs are the `reducedEncryptedPrv` values stored on EdDSA MPCv2 + * key cards. They decrypt to CBOR-encoded reduced shares that contain the opaque + * MPS signing key-share bytes plus the common public keychain material. + * + * @param encryptedUserKey encrypted EdDSA MPCv2 reduced user key + * @param encryptedBackupKey encrypted EdDSA MPCv2 reduced backup key + * @param walletPassphrase password for user and backup keys + * @returns EdDSA MPCv2 recovery key shares and common keychain + */ +export async function getEddsaMpcV2RecoveryKeySharesFromReducedKey( + encryptedUserKey: string, + encryptedBackupKey: string, + walletPassphrase?: string, + bitgo?: BitGoBase +): Promise { + const decodeKey = async (encryptedKey: string): Promise => { + const decrypted = bitgo + ? await bitgo.decryptAsync({ input: encryptedKey, password: walletPassphrase }) + : sjcl.decrypt(walletPassphrase, encryptedKey); + let reduced: MPSTypes.EddsaReducedKeyShare; + try { + reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64')); + } catch { + throw new Error( + 'EdDSA MPCv2 recovery: unable to decode reduced key share from keycard material. The encrypted key may be corrupted, malformed, or not an EdDSA MPCv2 reduced key.' + ); + } + if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) { + throw new Error( + 'EdDSA MPCv2 recovery: reduced key share is missing keyShare, pub, or rootChainCode. This keycard may be public-only and cannot be used for recovery.' + ); + } + return reduced; + }; + + const [userReduced, backupReduced] = await Promise.all([decodeKey(encryptedUserKey), decodeKey(encryptedBackupKey)]); + + const userPub = Buffer.from(userReduced.pub).toString('hex'); + const backupPub = Buffer.from(backupReduced.pub).toString('hex'); + if (userPub !== backupPub) { + throw new Error('EdDSA MPCv2 recovery: user and backup pub keys do not match'); + } + + const userChainCode = Buffer.from(userReduced.rootChainCode).toString('hex'); + const backupChainCode = Buffer.from(backupReduced.rootChainCode).toString('hex'); + if (userChainCode !== backupChainCode) { + throw new Error('EdDSA MPCv2 recovery: user and backup rootChainCodes do not match'); + } + + return { + userKeyShare: Buffer.from(userReduced.keyShare), + backupKeyShare: Buffer.from(backupReduced.keyShare), + commonKeyChain: userPub + userChainCode, + }; +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts index 4f441eec04..7b87925c57 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/types.ts @@ -18,6 +18,12 @@ export type CreateEddsaKeychainParams = CreateKeychainParamsBase & { backupKeyShare: EDDSA.KeyShare; }; +export interface EddsaMPCv2RecoveryKeyShares { + userKeyShare: Buffer; + backupKeyShare: Buffer; + commonKeyChain: string; +} + export type CreateEddsaBitGoKeychainParams = Omit; // For backward compatibility diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index a2d6e6bb36..5c3924db49 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -17,6 +17,7 @@ import { CustomEddsaMPCv2SigningRound1GeneratingFunction, CustomEddsaMPCv2SigningRound2GeneratingFunction, CustomEddsaMPCv2SigningRound3GeneratingFunction, + EDDSAUtils, EddsaMPCv2Utils, IBaseCoin, IWallet, @@ -341,6 +342,106 @@ describe('EdDSA MPS DSG helper functions', async () => { }); }); +describe('getEddsaMPCv2RecoveryKeyShares', () => { + const walletPassphrase = 'testPass'; + + const encryptKey = (keyShare: Buffer): string => sjcl.encrypt(walletPassphrase, keyShare.toString('base64')); + + it('should return recovery key shares from v1-encrypted reduced keys (no bitgo instance)', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const result = await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + encryptKey(userDkg.getReducedKeyShare()), + encryptKey(backupDkg.getReducedKeyShare()), + walletPassphrase + ); + + assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); + assert.deepStrictEqual(result.backupKeyShare, backupDkg.getKeyShare()); + assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain()); + }); + + it('should route decryption through bitgo.decryptAsync when a bitgo instance is provided', async () => { + // sdk-core has no devDependency on sdk-api or argon2, so we cannot encrypt with a real v2 envelope here. + // The stub verifies that the function delegates to bitgo.decryptAsync (which supports v1 + v2 in + // production) rather than falling back to sjcl.decrypt. + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const userKeyBase64 = userDkg.getReducedKeyShare().toString('base64'); + const backupKeyBase64 = backupDkg.getReducedKeyShare().toString('base64'); + + const mockBitgo = { + decryptAsync: sinon.stub().onFirstCall().resolves(userKeyBase64).onSecondCall().resolves(backupKeyBase64), + } as unknown as BitGoBase; + + const result = await EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + 'encrypted-user-key', + 'encrypted-backup-key', + walletPassphrase, + mockBitgo + ); + + sinon.assert.calledTwice(mockBitgo.decryptAsync as sinon.SinonStub); + assert.deepStrictEqual(result.userKeyShare, userDkg.getKeyShare()); + assert.deepStrictEqual(result.backupKeyShare, backupDkg.getKeyShare()); + assert.strictEqual(result.commonKeyChain, userDkg.getCommonKeychain()); + }); + + it('should reject a malformed keycard with a descriptive error', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const malformedKey = sjcl.encrypt(walletPassphrase, randomBytes(64).toString('base64')); + await assert.rejects( + EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + malformedKey, + encryptKey(userDkg.getReducedKeyShare()), + walletPassphrase + ), + /unable to decode reduced key share/ + ); + }); + + it('should reject reduced keys from different wallets', async () => { + const [userDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const [, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + await assert.rejects( + EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + encryptKey(userDkg.getReducedKeyShare()), + encryptKey(backupDkg.getReducedKeyShare()), + walletPassphrase + ), + /pub keys do not match/ + ); + }); + + it('should reject reduced keys with matching pub but mismatched rootChainCodes', async () => { + const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares(); + const userReducedKeyShare = userDkg.getReducedKeyShare(); + const backupReducedKeyShare = backupDkg.getReducedKeyShare(); + const getDecodedReducedKeyShare = MPSTypes.getDecodedReducedKeyShare; + const decodeStub = sinon.stub(MPSTypes, 'getDecodedReducedKeyShare').callsFake((buf) => { + const reduced = getDecodedReducedKeyShare(buf); + if (Buffer.from(buf).equals(backupReducedKeyShare)) { + return { + ...reduced, + rootChainCode: Array.from(randomBytes(32)), + }; + } + return reduced; + }); + + try { + await assert.rejects( + EDDSAUtils.getEddsaMpcV2RecoveryKeySharesFromReducedKey( + encryptKey(userReducedKeyShare), + encryptKey(backupReducedKeyShare), + walletPassphrase + ), + /rootChainCodes do not match/ + ); + } finally { + decodeStub.restore(); + } + }); +}); + describe('EddsaMPCv2Utils.createOfflineRound1Share', () => { let eddsaMPCv2Utils: EddsaMPCv2Utils; let mockBitgo: BitGoBase; From 0d63d7e218234ef1bd9123acab0fdf73f00b6d4a Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 16 Jun 2026 01:09:54 +0530 Subject: [PATCH 22/24] feat: add exclusions for new CVEs affecting dependencies and clarify usage context Ticket: CECHO-1353 --- .iyarc | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.iyarc b/.iyarc index 19ab0f6f42..0f9d12217f 100644 --- a/.iyarc +++ b/.iyarc @@ -82,3 +82,36 @@ GHSA-rpr9-rxv7-x643 # - esbuild is a dev-time build tool (via babylonlabs-io-btc-staking-ts), not runtime production code # - The attacker-controlled NPM_CONFIG_REGISTRY vector does not apply to our controlled CI environment GHSA-gv7w-rqvm-qjhr + +# Excluded because: +# - ws: Memory exhaustion DoS by sending many tiny fragments/data chunks to exhaust server memory +# - Transitive dependency via @cosmjs/socket, @ethersproject/providers, @polkadot/rpc-provider, +# jayson, rpc-websockets (via @solana/web3.js), and avalanche — all requiring ws <8.21.0 +# - Our usage is exclusively as a WebSocket CLIENT for blockchain RPC connections, not as a server +# - The DoS vector requires an attacker to send crafted frames to a ws server we control; we do not +# expose any ws server surfaces in production +GHSA-96hv-2xvq-fx4p + +# Excluded because: +# - form-data: CRLF injection via unescaped multipart field names and filenames +# - Transitive dependency via superagent (abstract-cosmos, express, supertest) and @aptos-labs/ts-sdk +# - The injection requires attacker-controlled field names or filenames in multipart requests +# - All form-data field names and filenames in our codebase are code-controlled constants, +# not derived from user input — no untrusted data flows into form field names or filenames +GHSA-hmw2-7cc7-3qxx + +# Excluded because: +# - protobufjs: DoS through unbounded Any expansion during JSON conversion (parseAny recursion) +# - Transitive dependency via @cosmjs (abstract-cosmos, babylonlabs-io-btc-staking-ts) and +# @hashgraph/proto, @hashgraph/sdk (sdk-coin-hbar) — all requiring protobufjs <=7.5.x +# - Input to protobuf decoding comes from trusted blockchain RPC responses, not arbitrary user data +# - Patched version (7.6.1) requires upstream @cosmjs and @hashgraph dependency updates +GHSA-wcpc-wj8m-hjx6 + +# Excluded because: +# - tmp: path traversal via type-confusion in _assertPath (non-string prefix/postfix/template) +# - Transitive dependency via cypress (web-demo), karma (bitgo module), and lerna/nx (dev tooling) +# - All usages are dev-time only; tmp is never used in production or runtime code +# - The prefix/postfix/template args are all hard-coded string constants in calling code, +# not user-supplied — the type-confusion vector does not apply +GHSA-7c78-jf6q-g5cm From ff0cbb70dbeea56c357d4b44800c12a309888d91 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Mon, 15 Jun 2026 17:25:24 +0000 Subject: [PATCH 23/24] feat(sdk-api): pass recipient addresses to v1 billing fee endpoint Forward recipient addresses from the SDK to the server's GET /api/v1/wallet/:id/billing/fee endpoint as recipients[] query params. This allows the server to waive the PayGo fee when all recipients are v2 PayGo wallets (v1-to-v2 migration scenario). Backward-compatible: existing calls without recipients are unchanged. Ticket: T1-3579 Session-Id: 9bcb0f58-d633-492c-a7a4-70bcd09ce08b Task-Id: bf7868aa-6a4c-4ee7-8520-1e6b61551a64 --- modules/sdk-api/src/v1/transactionBuilder.ts | 20 ++++-- modules/sdk-api/src/v1/wallet.ts | 9 ++- modules/sdk-api/test/unit/v1/wallet.ts | 67 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/modules/sdk-api/src/v1/transactionBuilder.ts b/modules/sdk-api/src/v1/transactionBuilder.ts index 9bfca327af..e819d76831 100644 --- a/modules/sdk-api/src/v1/transactionBuilder.ts +++ b/modules/sdk-api/src/v1/transactionBuilder.ts @@ -288,13 +288,19 @@ exports.createTransaction = function (params) { if (bitgoFeeInfo) { return; } - return params.wallet.getBitGoFee({ amount: totalOutputAmount, instant: params.instant }).then(function (result) { - if (result && result.fee > 0) { - bitgoFeeInfo = { - amount: result.fee, - }; - } - }); + return params.wallet + .getBitGoFee({ + amount: totalOutputAmount, + instant: params.instant, + recipients: params.recipients?.map((r: any) => r.address).filter(Boolean) ?? [], + }) + .then(function (result) { + if (result && result.fee > 0) { + bitgoFeeInfo = { + amount: result.fee, + }; + } + }); }).then(function () { if (bitgoFeeInfo && bitgoFeeInfo.amount > 0) { totalAmount += bitgoFeeInfo.amount; diff --git a/modules/sdk-api/src/v1/wallet.ts b/modules/sdk-api/src/v1/wallet.ts index 59405802fb..c25dab1e8d 100644 --- a/modules/sdk-api/src/v1/wallet.ts +++ b/modules/sdk-api/src/v1/wallet.ts @@ -2555,9 +2555,12 @@ Wallet.prototype.getBitGoFee = function (params, callback) { if (params.instant && !_.isBoolean(params.instant)) { throw new Error('invalid instant argument'); } - return Promise.resolve(this.bitgo.get(this.url('/billing/fee')).query(params).result()) - .then(callback) - .catch(callback); + const { recipients, ...baseParams } = params; + let req = this.bitgo.get(this.url('/billing/fee')).query(baseParams); + if (Array.isArray(recipients) && recipients.length > 0) { + req = req.query({ 'recipients[]': recipients }); + } + return Promise.resolve(req.result()).then(callback).catch(callback); }; /* diff --git a/modules/sdk-api/test/unit/v1/wallet.ts b/modules/sdk-api/test/unit/v1/wallet.ts index 84c24425b4..5836e39080 100644 --- a/modules/sdk-api/test/unit/v1/wallet.ts +++ b/modules/sdk-api/test/unit/v1/wallet.ts @@ -1823,4 +1823,71 @@ describe('Wallet Prototype Methods', function () { }); }); }); + + describe('getBitGoFee', function () { + let bgUrl: string; + let wallet: any; + + before(function () { + nock.pendingMocks().should.be.empty(); + const prodBitgo = new BitGoAPI({ env: 'prod', clientConstants: { constants: {} } }); + bgUrl = common.Environments[prodBitgo.getEnv()].uri; + wallet = new Wallet(prodBitgo, { + id: '2NCoSfHH6Ls4CdTS5QahgC9k7x9RfXeSwY4', + private: { keychains: [userKeypair, backupKeypair, bitgoKey] }, + }); + }); + + afterEach(function () { + nock.cleanAll(); + }); + + it('sends amount and instant without recipients when recipients array is empty', async function () { + const scope = nock(bgUrl) + .get(`/api/v1/wallet/${wallet.id()}/billing/fee`) + .query({ amount: '100000', instant: 'false' }) + .reply(200, { fee: 1000 }); + + const result = await wallet.getBitGoFee({ amount: 100000, instant: false }); + result.fee.should.equal(1000); + scope.isDone().should.be.true(); + }); + + it('sends recipients[] query params when recipients are provided', async function () { + const addr1 = '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'; + const addr2 = '3FZbgi29cpjq2GjdwV8eyHuJJnkLtktZc5'; + + const scope = nock(bgUrl) + .get(`/api/v1/wallet/${wallet.id()}/billing/fee`) + .query((query) => { + const recipientsParam = query['recipients[]']; + const recipientsList = Array.isArray(recipientsParam) ? recipientsParam : [recipientsParam]; + return query.amount === '100000' && recipientsList.includes(addr1) && recipientsList.includes(addr2); + }) + .reply(200, { fee: 0 }); + + const result = await wallet.getBitGoFee({ amount: 100000, recipients: [addr1, addr2] }); + result.fee.should.equal(0); + scope.isDone().should.be.true(); + }); + + it('omits recipients[] when recipients array is empty', async function () { + const scope = nock(bgUrl) + .get(`/api/v1/wallet/${wallet.id()}/billing/fee`) + .query((query) => query.amount === '200000' && !('recipients[]' in query)) + .reply(200, { fee: 500 }); + + const result = await wallet.getBitGoFee({ amount: 200000, recipients: [] }); + result.fee.should.equal(500); + scope.isDone().should.be.true(); + }); + + it('throws when amount is not a number', function () { + (() => wallet.getBitGoFee({ amount: 'bad' })).should.throw('invalid amount argument'); + }); + + it('throws when instant is not a boolean', function () { + (() => wallet.getBitGoFee({ amount: 100, instant: 'yes' })).should.throw('invalid instant argument'); + }); + }); }); From 3d5723d1570b8bc994b80c7d4bd7b8dec303d53d Mon Sep 17 00:00:00 2001 From: Alok Baltiyal Date: Tue, 16 Jun 2026 14:15:58 +0530 Subject: [PATCH 24/24] feat(statics): onboard GoUSD and SCAASACME tokens Add GoUSD stablecoin on Solana (prod/testnet/staging) and Tempo (prod/testnet/staging), and SCAASACME (Acme USD) demo token on Hoodi ETH, BSC testnet, and Tempo testnet/staging. All tokens include on-chain and OFC counterparts. - GoUSD: 6 decimals, Token-2022 program on Solana - SCAASACME: 18 decimals on EVM chains, 6 on Tempo - No production entries for SCAASACME (staging/test only) TICKET: SCAAS-9540, SCAAS-9748 --- .../sdk-coin-tempo/test/resources/tempo.ts | 16 +++++ modules/statics/src/allCoinsAndTokens.ts | 70 +++++++++++++++++++ modules/statics/src/base.ts | 12 ++++ modules/statics/src/coins/bscTokens.ts | 18 +++++ modules/statics/src/coins/erc20Coins.ts | 24 +++++++ modules/statics/src/coins/ofcCoins.ts | 67 ++++++++++++++++++ modules/statics/src/coins/ofcErc20Coins.ts | 28 ++++++++ modules/statics/src/coins/solTokens.ts | 33 +++++++++ .../test/unit/tokenNamingConvention.ts | 2 + 9 files changed, 270 insertions(+) diff --git a/modules/sdk-coin-tempo/test/resources/tempo.ts b/modules/sdk-coin-tempo/test/resources/tempo.ts index 66dc9ca17b..afa64607a7 100644 --- a/modules/sdk-coin-tempo/test/resources/tempo.ts +++ b/modules/sdk-coin-tempo/test/resources/tempo.ts @@ -31,6 +31,22 @@ export const TESTNET_TOKENS = { address: '0x20c000000000000000000000e4662b69291ab60a', name: 'ttempo:stgusd1', }, + gousd: { + address: '0x20c000000000000000000000abbd755acb373233', + name: 'ttempo:gousd', + }, + stgGoUSD: { + address: '0x20c0000000000000000000004005ba0e59b1e1e5', + name: 'ttempo:stggousd', + }, + scaasacme: { + address: '0x20c0000000000000000000007b20a5729bbc4f2d', + name: 'ttempo:scaasacme', + }, + stgScaasacme: { + address: '0x20c000000000000000000000d846672b70a3dbf9', + name: 'ttempo:stgscaasacme', + }, }; // Valid checksummed test recipient address diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index 86896a139b..7291d2f0d6 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -3550,6 +3550,20 @@ export const allCoinsAndTokens = [ CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, ] ), + tip20Token( + '95acea50-87a0-4e7f-b84e-e98ad27ee757', + 'tempo:gousd', + 'goUSD', + 6, + '0x20c0000000000000000000006d194f9810e6f886', + UnderlyingAsset['tempo:gousd'], + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] + ), tip20Token( '9dd63f8e-3f35-4d10-a623-fe7358ad66a4', 'tempo:usdt0', @@ -3649,6 +3663,62 @@ export const allCoinsAndTokens = [ CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, ] ), + ttip20Token( + '07cd2cf3-490d-4776-84cf-e1bb47cf3cc0', + 'ttempo:gousd', + 'Testnet goUSD', + 6, + '0x20c000000000000000000000abbd755acb373233', + UnderlyingAsset['ttempo:gousd'], + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] + ), + ttip20Token( + '0ac48bf9-e8dd-442e-ba8e-a800b525f68a', + 'ttempo:stggousd', + 'Testnet goUSD', + 6, + '0x20c0000000000000000000004005ba0e59b1e1e5', + UnderlyingAsset['ttempo:stggousd'], + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] + ), + ttip20Token( + '79428fe9-cfdb-43b2-8b95-9d0ca2c04a8d', + 'ttempo:stgscaasacme', + 'Testnet Acme USD', + 6, + '0x20c000000000000000000000d846672b70a3dbf9', + UnderlyingAsset['ttempo:stgscaasacme'], + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] + ), + ttip20Token( + '250efa18-66a4-43c7-a01c-c037505d0d19', + 'ttempo:scaasacme', + 'Testnet Acme USD', + 6, + '0x20c0000000000000000000007b20a5729bbc4f2d', + UnderlyingAsset['ttempo:scaasacme'], + [ + ...TEMPO_FEATURES, + CoinFeature.STABLECOIN, + CoinFeature.EVM_NON_BITGO_RECOVERY, + CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + ] + ), canton( '07385320-5a4f-48e9-97a5-86d4be9f24b0', 'canton', diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 2766be0ca1..da462c857a 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -1222,6 +1222,9 @@ export enum UnderlyingAsset { 'tsol:stggospcx' = 'tsol:stggospcx', 'sol:gospcx' = 'sol:gospcx', 'sol:usd1' = 'sol:usd1', + 'sol:gousd' = 'sol:gousd', + 'tsol:gousd' = 'tsol:gousd', + 'tsol:stggousd' = 'tsol:stggousd', 'sol:usdm1' = 'sol:usdm1', 'tsol:slnd' = 'tsol:slnd', 'tsol:orca' = 'tsol:orca', @@ -1923,6 +1926,8 @@ export enum UnderlyingAsset { 'hteth:stgsofid' = 'hteth:stgsofid', 'hteth:usd1' = 'hteth:usd1', 'hteth:stgusd1' = 'hteth:stgusd1', + 'hteth:stgscaasacme' = 'hteth:stgscaasacme', + 'hteth:scaasacme' = 'hteth:scaasacme', 'hteth:cusd' = 'hteth:cusd', 'hteth:fyusd' = 'hteth:fyusd', 'hteth:stgcusd' = 'hteth:stgcusd', @@ -3111,6 +3116,8 @@ export enum UnderlyingAsset { 'tbsc:busd' = 'tbsc:busd', 'tbsc:usd1' = 'tbsc:usd1', 'tbsc:stgusd1' = 'tbsc:stgusd1', + 'tbsc:stgscaasacme' = 'tbsc:stgscaasacme', + 'tbsc:scaasacme' = 'tbsc:scaasacme', 'bsc:city' = 'bsc:city', 'bsc:fdusd' = 'bsc:fdusd', 'bsc:floki' = 'bsc:floki', @@ -4061,6 +4068,7 @@ export enum UnderlyingAsset { 'tempo:usdc' = 'tempo:usdc', 'tempo:usd1' = 'tempo:usd1', 'tempo:usdt0' = 'tempo:usdt0', + 'tempo:gousd' = 'tempo:gousd', // Tempo testnet tokens 'ttempo:pathusd' = 'ttempo:pathusd', @@ -4069,6 +4077,10 @@ export enum UnderlyingAsset { 'ttempo:thetausd' = 'ttempo:thetausd', 'ttempo:usd1' = 'ttempo:usd1', 'ttempo:stgusd1' = 'ttempo:stgusd1', + 'ttempo:gousd' = 'ttempo:gousd', + 'ttempo:stggousd' = 'ttempo:stggousd', + 'ttempo:stgscaasacme' = 'ttempo:stgscaasacme', + 'ttempo:scaasacme' = 'ttempo:scaasacme', // Prividium Ethereum testnet tokens 'tprividiumeth:USB-ESCROW-D' = 'tprividiumeth:USB-ESCROW-D', diff --git a/modules/statics/src/coins/bscTokens.ts b/modules/statics/src/coins/bscTokens.ts index f680665534..6a05397a07 100644 --- a/modules/statics/src/coins/bscTokens.ts +++ b/modules/statics/src/coins/bscTokens.ts @@ -1715,4 +1715,22 @@ export const bscTokens = [ UnderlyingAsset['bsc:hybond'], BSC_TOKEN_FEATURES ), + tbscToken( + '2d4e4f61-1acd-416c-9d38-3d80d95a38d4', + 'tbsc:stgscaasacme', + 'Testnet Acme USD', + 6, + '0x3273bfa01f481b13b5fb31f3d47252ea3da2308a', + UnderlyingAsset['tbsc:stgscaasacme'], + [...BSC_TOKEN_FEATURES, CoinFeature.STABLECOIN] + ), + tbscToken( + '661d9dfc-a267-47e9-ba45-cfb598c58eb3', + 'tbsc:scaasacme', + 'Testnet Acme USD', + 6, + '0x1494a12f1a60ed1d21ab98f00eb629e5cd16bc17', + UnderlyingAsset['tbsc:scaasacme'], + [...BSC_TOKEN_FEATURES, CoinFeature.STABLECOIN] + ), ]; diff --git a/modules/statics/src/coins/erc20Coins.ts b/modules/statics/src/coins/erc20Coins.ts index 8d7fa8b5bb..e37df1ee02 100644 --- a/modules/statics/src/coins/erc20Coins.ts +++ b/modules/statics/src/coins/erc20Coins.ts @@ -12857,6 +12857,30 @@ export const erc20Coins = [ undefined, Networks.test.hoodi ), + terc20( + '5add4bd7-9889-4639-b130-083d083ebbe4', + 'hteth:stgscaasacme', + 'Testnet Acme USD', + 6, + '0x3273bfa01f481b13b5fb31f3d47252ea3da2308a', + UnderlyingAsset['hteth:stgscaasacme'], + [...ACCOUNT_COIN_DEFAULT_FEATURES, CoinFeature.STABLECOIN], + undefined, + undefined, + Networks.test.hoodi + ), + terc20( + '3ecfecc1-c910-483f-af54-b737452ceb00', + 'hteth:scaasacme', + 'Testnet Acme USD', + 6, + '0xadebc52bb71dbc09729d6c0727a507fd42b2944e', + UnderlyingAsset['hteth:scaasacme'], + [...ACCOUNT_COIN_DEFAULT_FEATURES, CoinFeature.STABLECOIN], + undefined, + undefined, + Networks.test.hoodi + ), terc20( '8e4f9c4c-2b03-4ad4-8019-ace4bbda3acd', 'hteth:sofid', diff --git a/modules/statics/src/coins/ofcCoins.ts b/modules/statics/src/coins/ofcCoins.ts index 8d8c9bcd5e..b91c5bf3ea 100644 --- a/modules/statics/src/coins/ofcCoins.ts +++ b/modules/statics/src/coins/ofcCoins.ts @@ -1614,6 +1614,10 @@ export const ofcCoins = [ ...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN, ]), + ofcsolToken('4cde3d6b-1b58-49f2-a689-6242d819ed3d', 'ofcsol:gousd', 'goUSD', 6, UnderlyingAsset['sol:gousd'], [ + ...SOL_TOKEN_FEATURES, + CoinFeature.STABLECOIN, + ]), ofcsolToken('d398e9e1-1a3e-4307-9e31-e1dbc03aa0f0', 'ofcsol:sofid', 'SoFiUSD', 6, UnderlyingAsset['sol:sofid'], [ ...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN, @@ -2024,6 +2028,22 @@ export const ofcCoins = [ UnderlyingAsset['sol:usd1'], [...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN] ), + tofcsolToken( + 'cc4c424f-d4e1-4674-8c5f-a1f6754ebde5', + 'ofctsol:gousd', + 'Testnet goUSD', + 6, + UnderlyingAsset['tsol:gousd'], + [...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN] + ), + tofcsolToken( + '0ed96aeb-fdb5-4426-81b2-05791d4c21d8', + 'ofctsol:stggousd', + 'Testnet goUSD', + 6, + UnderlyingAsset['tsol:stggousd'], + [...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN] + ), tofcsolToken( 'c9ee21a5-d000-45b6-a045-cb307810434b', 'ofctsol:trump', @@ -3218,6 +3238,24 @@ export const ofcCoins = [ undefined, [CoinFeature.STABLECOIN] ), + tofcBscToken( + 'b94eff50-b8aa-4503-9b56-c6f7fb32bcde', + 'ofctbsc:stgscaasacme', + 'Testnet Acme USD', + 6, + UnderlyingAsset['tbsc:stgscaasacme'], + undefined, + [CoinFeature.STABLECOIN] + ), + tofcBscToken( + '6e4c653e-a614-4a68-8a6b-eba76fb37515', + 'ofctbsc:scaasacme', + 'Testnet Acme USD', + 6, + UnderlyingAsset['tbsc:scaasacme'], + undefined, + [CoinFeature.STABLECOIN] + ), ofcPolygonErc20( '547ce68f-cb4c-4618-bef3-9a0ebe9facd2', 'ofcpolygon:sbc', @@ -5104,6 +5142,7 @@ export const ofcCoins = [ ), ofcTempoToken('c9a90ee0-6546-413c-9cbe-94fdc14985c5', 'ofctempo:usdc', 'USDC', 6, UnderlyingAsset['tempo:usdc']), ofcTempoToken('05ac1283-5e72-4cba-8b0f-38cbd23a25c6', 'ofctempo:usd1', 'USD1', 6, UnderlyingAsset['tempo:usd1']), + ofcTempoToken('11894b40-38da-4c52-80c3-a824bb452e69', 'ofctempo:gousd', 'goUSD', 6, UnderlyingAsset['tempo:gousd']), ofcTempoToken('554f9084-4ac8-466a-8675-3de33ffd47d7', 'ofctempo:usdt0', 'USDT0', 6, UnderlyingAsset['tempo:usdt0']), // Tempo testnet OFC tokens tofcTempoToken( @@ -5148,6 +5187,34 @@ export const ofcCoins = [ 6, UnderlyingAsset['ttempo:stgusd1'] ), + tofcTempoToken( + '7cbcd988-0cec-4d42-849c-d6a00591d862', + 'ofcttempo:gousd', + 'Testnet goUSD', + 6, + UnderlyingAsset['ttempo:gousd'] + ), + tofcTempoToken( + '3c728a90-7dc9-4ae9-962f-e18417dffdc2', + 'ofcttempo:stggousd', + 'Testnet goUSD', + 6, + UnderlyingAsset['ttempo:stggousd'] + ), + tofcTempoToken( + '8876a3e5-5b6d-4184-8c26-0520b5a4a89b', + 'ofcttempo:stgscaasacme', + 'Testnet Acme USD', + 6, + UnderlyingAsset['ttempo:stgscaasacme'] + ), + tofcTempoToken( + '3407563d-4e99-46d5-8d87-16ef1adeb9b3', + 'ofcttempo:scaasacme', + 'Testnet Acme USD', + 6, + UnderlyingAsset['ttempo:scaasacme'] + ), ofc('6f0246cf-b792-483a-b720-9755b158c614', 'ofcunieth', 'Unichain', 18, UnderlyingAsset.UNIETH, CoinKind.CRYPTO), tofc( '4efb1377-8439-410a-b460-2aeeff944fb2', diff --git a/modules/statics/src/coins/ofcErc20Coins.ts b/modules/statics/src/coins/ofcErc20Coins.ts index 19fb115e0d..392bff07e7 100644 --- a/modules/statics/src/coins/ofcErc20Coins.ts +++ b/modules/statics/src/coins/ofcErc20Coins.ts @@ -4568,6 +4568,34 @@ export const tOfcErc20Coins = [ undefined, 'hteth' ), + tofcerc20( + '59a31295-88d3-4142-9e88-c8e500888aef', + 'ofchteth:stgscaasacme', + 'Testnet Acme USD', + 6, + UnderlyingAsset['hteth:stgscaasacme'], + undefined, + [CoinFeature.STABLECOIN], + undefined, + undefined, + undefined, + undefined, + 'hteth' + ), + tofcerc20( + '03ebbbe2-6ba3-45c9-ad82-4f86ae48be69', + 'ofchteth:scaasacme', + 'Testnet Acme USD', + 6, + UnderlyingAsset['hteth:scaasacme'], + undefined, + [CoinFeature.STABLECOIN], + undefined, + undefined, + undefined, + undefined, + 'hteth' + ), tofcerc20( '145b2e09-453d-4861-8f54-5791d295bd96', 'ofchteth:stgsofid', diff --git a/modules/statics/src/coins/solTokens.ts b/modules/statics/src/coins/solTokens.ts index 776e8520f6..e3c52b7496 100644 --- a/modules/statics/src/coins/solTokens.ts +++ b/modules/statics/src/coins/solTokens.ts @@ -4099,4 +4099,37 @@ export const solTokens = [ SOL_TOKEN_FEATURES, ProgramID.Token2022ProgramId ), + solToken( + '7b7edb2a-3952-4083-8df9-cf16baa8d82c', + 'sol:gousd', + 'goUSD', + 6, + '7vXcrS2iHjBhQwP6RhAPhZaTGKjodfGPr2H3GDaqmnPN', + '7vXcrS2iHjBhQwP6RhAPhZaTGKjodfGPr2H3GDaqmnPN', + UnderlyingAsset['sol:gousd'], + [...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN], + ProgramID.Token2022ProgramId + ), + tsolToken( + '5f4de23d-67b5-4a8f-bb31-9c1c72f62489', + 'tsol:gousd', + 'Testnet goUSD', + 6, + 'BtY9vAphvWWtwFoThckHP9ciY9Mvy2nLyhiYKeRhb3Ds', + 'BtY9vAphvWWtwFoThckHP9ciY9Mvy2nLyhiYKeRhb3Ds', + UnderlyingAsset['tsol:gousd'], + [...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN], + ProgramID.Token2022ProgramId + ), + tsolToken( + '0b18da3c-a072-42cb-a3bf-3818af269287', + 'tsol:stggousd', + 'Testnet goUSD', + 6, + 'Ec7mXJZtXnbn34Mt9LaLZe3DRmEZmWCxcNrAmvK4HKaS', + 'Ec7mXJZtXnbn34Mt9LaLZe3DRmEZmWCxcNrAmvK4HKaS', + UnderlyingAsset['tsol:stggousd'], + [...SOL_TOKEN_FEATURES, CoinFeature.STABLECOIN], + ProgramID.Token2022ProgramId + ), ]; diff --git a/modules/statics/test/unit/tokenNamingConvention.ts b/modules/statics/test/unit/tokenNamingConvention.ts index 203ef7d254..3dda883fbd 100644 --- a/modules/statics/test/unit/tokenNamingConvention.ts +++ b/modules/statics/test/unit/tokenNamingConvention.ts @@ -63,6 +63,8 @@ describe('Token Naming Convention Tests', function () { 'hteth:qxmp', 'hteth:stgqxmp', 'hteth:usd1', + 'hteth:stgscaasacme', + 'hteth:scaasacme', 'hteth:amstest', 'hterc18dp', 'hteth:bgerchv2',