diff --git a/.iyarc b/.iyarc index 6e388ef3ce..0f9d12217f 100644 --- a/.iyarc +++ b/.iyarc @@ -75,3 +75,43 @@ 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 + +# 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 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/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/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/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/); + }); +}); 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'); + }); + }); +}); 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/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/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 () { 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'); + }); + }); }); 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/); + }); + }); }); }); 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; diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index cb785d94b0..cf8b3f3cf6 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -44,7 +44,8 @@ }, "devDependencies": { "@bitgo/sdk-api": "^1.85.0", - "@bitgo/sdk-test": "^9.1.50" + "@bitgo/sdk-test": "^9.1.50", + "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..e25ff59553 100644 --- a/modules/sdk-coin-flrp/src/flrp.ts +++ b/modules/sdk-coin-flrp/src/flrp.ts @@ -19,9 +19,13 @@ import { BaseTransaction, SigningError, MethodNotImplementedError, + SignableTransaction, + isAvalancheAtomicTx, } from '@bitgo/sdk-core'; import * as FlrpLib from './lib'; import { + CreatePairedWalletParams, + CreatePairedWalletResponse, FlrpEntry, FlrpExplainTransactionOptions, FlrpSignTransactionOptions, @@ -440,8 +444,25 @@ 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(); } + + 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..9ecfae0d65 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'; @@ -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'; @@ -1053,6 +1055,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). @@ -1086,4 +1099,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)'); + }); + }); }); diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 9f2aaf29fa..6b3daf20c7 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'; @@ -562,9 +565,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 +666,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(); @@ -714,6 +718,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'; 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)); 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/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 () { 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..dfc51997b7 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 = { @@ -210,6 +211,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 +669,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; @@ -657,6 +709,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/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/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/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/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/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/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/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(); + }); + }); +}); 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/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; 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); + }); +}); 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'); + }); + }); +}); 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, 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/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..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', @@ -1439,4 +1455,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 + ), ]; 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/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] ), 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', 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 34cbdbad33..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": @@ -17591,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==