From c6e5e3d79db5880039950fe5dd3fe55183ad094a Mon Sep 17 00:00:00 2001 From: Kamlesh Mugdiya Date: Wed, 26 Aug 2026 12:24:04 +0530 Subject: [PATCH 1/4] feat(sdk-core): add wrap() and unwrap() to DefiVault Expose ETH -> WETH wrap and WETH -> ETH unwrap on wallet.defi so the UI can call them directly. Both are thin orchestrators over a single wallet.sendMany, modelled on the existing withdrawFromVault; WP builds the WETH9 calldata and resolves the contract address from the vault binding. - iDefiVault: WrapOptions / WrapResult, plus wrap/unwrap on IDefiVault. operationId is declared optional now even though nothing populates it, so that adding operation tracking in M5 is not a breaking change to a published SDK type. - defiVault: wrap/unwrap share sendWrapIntent, which deliberately does not call extractOperationId - no operation is minted in v1. - wallet: wrapNative / unwrapNative cases map to the wrap-native / unwrap-native intents, decoding defiParams with decodeWithCodec rather than a bare cast so an 18dp amount stays a string. - mpcUtils: both sites - the recipients-required exemption list and the EVM intent-shape switch. Omitting the first makes the recipients assertion throw before the switch is reached. - recipientUtils: register both the camelCase and kebab-case spellings. NO_RECIPIENT_TX_TYPES is matched against txParams.type (camelCase, from buildParams) and against intent.intentType (kebab-case, as WP persists it); signing paths that carry no txParams only ever see the latter. @bitgo/public-types is intentionally left at 6.58.0: sdk-core imports no intent codec from it, so the 6.60.0 publish carrying WrapNativeIntent / UnwrapNativeIntent is only needed by wallet-platform. DEFI-661 --- examples/ts/defi-vault-wrap.ts | 73 +++++++++ modules/bitgo/test/v2/unit/wallet.ts | 23 +++ modules/sdk-core/src/bitgo/defi/defiVault.ts | 61 ++++++++ modules/sdk-core/src/bitgo/defi/iDefiVault.ts | 17 +++ modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 14 ++ .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 2 +- .../src/bitgo/utils/tss/recipientUtils.ts | 12 ++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 20 +++ .../test/unit/bitgo/defi/defiVault.ts | 139 ++++++++++++++++++ .../unit/bitgo/utils/tss/recipientUtils.ts | 35 +++++ 10 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 examples/ts/defi-vault-wrap.ts diff --git a/examples/ts/defi-vault-wrap.ts b/examples/ts/defi-vault-wrap.ts new file mode 100644 index 0000000000..b926794a60 --- /dev/null +++ b/examples/ts/defi-vault-wrap.ts @@ -0,0 +1,73 @@ +/** + * Wrap native ETH into WETH (and unwrap it back) on staging. + * + * Wrap issues a single WETH9 `deposit()` call; unwrap issues `withdraw(uint256)`. + * The wallet-platform builds the calldata and resolves the WETH9 address from the + * vault binding — the SDK only forwards vaultId and amount. + * + * Set DEFI_WRAP_DIRECTION=unwrap to run the reverse direction. + * + * Wrap does not need to be awaited before depositing: the client is free to call + * depositToVault() without waiting for the wrap to confirm. + * + * Usage: + * STAGING_ACCESS_TOKEN= \ + * STAGING_WALLET_ID= \ + * STAGING_WALLET_PASSPHRASE= \ + * DEFI_VAULT_ID= \ + * DEFI_WRAP_AMOUNT= \ + * DEFI_WRAP_DIRECTION= \ + * npx ts-node examples/ts/defi-vault-wrap.ts + * + * Copyright 2026, BitGo, Inc. All Rights Reserved. + */ +import { BitGo } from 'bitgo'; + +require('dotenv').config({ path: '../../.env' }); + +const config = { + accessToken: '', + env: 'staging', + walletId: '', + vaultId: 'tbaseeth-weth-test', + amount: '1000000000000000000', // 1 ETH — 18dp base units, kept as a string + direction: 'wrap' as 'wrap' | 'unwrap', + passphrase: '', + coin: 'tbaseeth', + otp: '000000', +}; + +const bitgoTest = new BitGo({ + env: 'staging', +}); + +async function main() { + console.log('Connecting to staging...'); + bitgoTest.authenticateWithAccessToken({ accessToken: config.accessToken }); + //await bitgoTest.unlock({ otp: config.otp, duration: 3600 }); + const wallet = await bitgoTest.coin(config.coin).wallets().get({ id: config.walletId }); + console.log('Wallet ID :', wallet.id()); + console.log('Vault ID :', config.vaultId); + console.log('Direction :', config.direction); + console.log('Amount :', config.amount, config.direction === 'wrap' ? '(ETH base units)' : '(WETH base units)'); + + const params = { + vaultId: config.vaultId, + amount: config.amount, + ...(config.passphrase ? { walletPassphrase: config.passphrase } : {}), + }; + + console.log(`\nStarting ${config.direction}...`); + const result = config.direction === 'wrap' ? await wallet.defi.wrap(params) : await wallet.defi.unwrap(params); + + console.log(`\n${config.direction} submitted:`); + console.log(' txRequestId :', result.txRequestId); + // operationId is reserved for milestone M5 and is undefined today. + console.log('\nFull result:', JSON.stringify(result, null, 2)); +} + +main().catch((e) => { + console.error('Error:', e.message); + if (e.stack) console.error(e.stack); + process.exit(1); +}); diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 59b9508efe..a7ec7619e9 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -3837,6 +3837,29 @@ describe('V2 Wallet:', function () { intent.feeOptions!.should.not.have.property('feeToken'); }); + ['wrap-native', 'unwrap-native'].forEach(function (intentType) { + it(`populate intent should return a valid ${intentType} intent without recipients`, async function () { + const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth')); + + // Two independent sites in populateIntent must know about this intentType: + // the recipients-required exemption list, and the EVM intent-shape switch. + // Missing the first makes this call throw on the recipients assertion + // before the switch is ever reached. + const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), { + reqId, + intentType, + defiParams: { vaultId: 'hteth-weth-test', amount: '1000000000000000000' }, + }); + + intent.intentType.should.equal(intentType); + intent.should.have.property('recipients', undefined); + intent.vaultId!.should.equal('hteth-weth-test'); + // A plain `amount`, not the `shareTokenAmount` defi-withdraw uses for shares. + intent.amount!.should.equal('1000000000000000000'); + intent.should.not.have.property('shareTokenAmount'); + }); + }); + it('populate intent should return valid coredao acceleration intent', async function () { const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('coredao')); diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts index 2b733d85aa..228c3a4ded 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -17,6 +17,8 @@ import { ResumeDepositOptions, WithdrawFromVaultOptions, WithdrawResult, + WrapOptions, + WrapResult, } from './iDefiVault'; import { IWallet } from '../wallet'; import { BitGoBase } from '../bitgoBase'; @@ -323,8 +325,67 @@ export class DefiVault implements IDefiVault { return { operationId, txRequestId }; } + /** + * Wrap native currency into its canonical wrapped-native ERC-20 + * (ETH → WETH via the WETH9 `deposit()` call). + * + * A thin orchestrator over a single sendMany, like {@link withdrawFromVault}. + * WP builds the calldata and resolves the WETH9 address server-side from the + * vault binding; the SDK only forwards vaultId and amount. + * + * @param params.vaultId - DeFi-service vault identifier. Required in v1: binding + * the wrap to a vault is what supplies the per-enterprise authorization gate + * and the address-whitelist path server-side (TDD §3.6). M7 makes it optional, + * which is backward-compatible. + * @param params.amount - amount in base units of the native coin (18dp for ETH) + * @param params.walletPassphrase - required for hot wallets, omit for custody + */ + async wrap(params: WrapOptions): Promise { + return this.sendWrapIntent('wrapNative', params); + } + + /** + * Unwrap the canonical wrapped-native ERC-20 back to native currency + * (WETH → ETH via the WETH9 `withdraw(uint256)` call). + * + * @param params.vaultId - DeFi-service vault identifier (see {@link wrap}) + * @param params.amount - amount in base units of the wrapped token (18dp for WETH) + * @param params.walletPassphrase - required for hot wallets, omit for custody + */ + async unwrap(params: WrapOptions): Promise { + return this.sendWrapIntent('unwrapNative', params); + } + // ── Internal helpers ──────────────────────────────────────────────── + /** + * Shared body of {@link wrap} and {@link unwrap} — the two differ only in the + * sendMany type they issue. + * + * Deliberately does not call {@link extractOperationId}: no operation is minted + * for wrap/unwrap in v1, so it would only ever return undefined. Operation + * tracking arrives in milestone M5. + */ + private async sendWrapIntent(type: 'wrapNative' | 'unwrapNative', params: WrapOptions): Promise { + if (!params.vaultId) { + throw new Error('vaultId is required'); + } + if (!params.amount) { + throw new Error('amount is required'); + } + + const result = await this.wallet.sendMany({ + type, + defiParams: { + vaultId: params.vaultId, + amount: params.amount, + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + }); + + return { txRequestId: this.extractTxRequestId(result) }; + } + /** * Extract txRequestId from a sendMany result. * sendMany returns different shapes depending on wallet type: diff --git a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts index 123c9ccd3b..4cc4e7bc8f 100644 --- a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts @@ -80,6 +80,21 @@ export interface WithdrawResult { txRequestId: string; } +export interface WrapOptions { + /** DeFi-service vault identifier — required in v1, see note below */ + vaultId: string; + /** Amount in base units (18dp for ETH/WETH) */ + amount: string; + /** Wallet passphrase — required for hot wallets, omit for custody */ + walletPassphrase?: string; +} + +export interface WrapResult { + txRequestId: string; + /** Reserved — populated from milestone M5 onward, absent in v1 */ + operationId?: string; +} + export interface IDefiVault { depositToVault(params: DepositToVaultOptions): Promise; resumeDeposit(params: ResumeDepositOptions): Promise; @@ -88,4 +103,6 @@ export interface IDefiVault { getVaultConfig(params: GetVaultConfigOptions): Promise; getVaultProtocol(params: GetVaultConfigOptions): Promise; withdrawFromVault(params: WithdrawFromVaultOptions): Promise; + wrap(params: WrapOptions): Promise; + unwrap(params: WrapOptions): Promise; } diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index ed19e12f56..58a0bb3d8b 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -222,6 +222,8 @@ export abstract class MpcUtils { 'defi-approve', 'defi-deposit', 'defi-withdraw', + 'wrap-native', + 'unwrap-native', ].includes(params.intentType) ) { assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`); @@ -334,6 +336,18 @@ export abstract class MpcUtils { shareTokenAmount: params.defiParams.amount, }; } + case 'wrap-native': + case 'unwrap-native': { + assert(params.defiParams, `'defiParams' is required for ${params.intentType} intent`); + // WrapNativeIntent / UnwrapNativeIntent carry a plain `amount` (base units + // of the native coin when wrapping, of the wrapped token when unwrapping), + // not the `shareTokenAmount` that defi-withdraw uses for vault shares. + return { + ...baseIntent, + vaultId: params.defiParams.vaultId, + amount: params.defiParams.amount, + }; + } default: throw new Error(`Unsupported intent type ${params.intentType}`); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index a1f05d48fe..f7918ea98c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -375,7 +375,7 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase feeToken?: string; /** Canton-specific params for the cantonCommand intent. */ cantonCommandParams?: CantonCommandParams; - /** DeFi vault intent fields for defi-approve / defi-deposit intents. */ + /** DeFi vault intent fields for defi-* and wrap-native / unwrap-native intents. */ defiParams?: DefiIntentParams; /** Canton party ID of the end investor to onboard (cantonEndInvestorOnboardingOffer intent). */ endInvestorPartyId?: string; diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index 0f6c6ac9e5..ff00707fa6 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -31,6 +31,18 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'defiApprove', 'defiDeposit', 'defiWithdraw', + // Native wrap/unwrap (WETH9 deposit()/withdraw()) — calldata and the WETH9 + // address are resolved server-side from the vault binding, so no recipients. + // Registered in BOTH spellings on purpose: this set is matched against + // txParams.type, which is buildParams.type (camelCase, from wallet.sendMany), + // AND against intent.intentType (kebab-case, as WP persists it). Signing paths + // that carry no txParams — notably pendingApproval.approve() → + // recreateTxRequest() → signTxRequest() with no txParams — only ever see the + // kebab-case spelling. + 'wrapNative', + 'wrap-native', + 'unwrapNative', + 'unwrap-native', // ERC-7984 shielding: approve calldata is built server-side from the wrap intent 'wrapApprove', // Smart contract invocations with no explicit SDK-level recipients diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 00e9557f56..9ef7454c46 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -4783,6 +4783,26 @@ export class Wallet implements IWallet { ); break; } + case 'wrapNative': + case 'unwrapNative': { + // WETH9 amounts are 18dp and exceed Number.MAX_SAFE_INTEGER, so amount is + // decoded as a numeric string and handed on as a string, never a number. + const wrapNativeParams = decodeWithCodec( + t.type({ vaultId: t.string, amount: BigIntFromString }), + params.defiParams, + `${params.type}.defiParams` + ); + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: params.type === 'wrapNative' ? 'wrap-native' : 'unwrap-native', + defiParams: { ...wrapNativeParams, amount: wrapNativeParams.amount.toString() }, + }, + apiVersion, + params.preview + ); + break; + } default: throw new Error(`transaction type not supported: ${params.type}`); } diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index 3338928958..caa1fd00d9 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -717,6 +717,145 @@ describe('DefiVault', function () { }); }); + // wrap and unwrap are the same orchestrator with a different sendMany type, so + // the suite is generated over both to keep the two from drifting apart. + ( + [ + ['wrap', 'wrapNative'], + ['unwrap', 'unwrapNative'], + ] as const + ).forEach(function ([method, sendManyType]) { + describe(method, function () { + it(`should call sendMany once with ${sendManyType} type and return txRequestId`, async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: `txreq-${method}-1` } }); + + const result = await defiVault[method]({ + vaultId: 'vlt-galaxy-weth', + amount: '1000000000000000000', + }); + + result.txRequestId.should.equal(`txreq-${method}-1`); + + sendManyStub.calledOnce.should.be.true(); + const args: any = sendManyStub.firstCall.args[0]; + args.type.should.equal(sendManyType); + args.defiParams.should.deepEqual({ + vaultId: 'vlt-galaxy-weth', + amount: '1000000000000000000', + }); + }); + + it('should extract txRequestId from the lite sendMany response shape', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequestId: `txreq-${method}-lite` }); + + const result = await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }); + + result.txRequestId.should.equal(`txreq-${method}-lite`); + }); + + it('should throw when txRequestId is absent from the sendMany response', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: {} }); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }), { + message: 'txRequestId not found in sendMany response', + }); + }); + + it('should leave operationId undefined in v1', async function () { + // No operation is minted for wrap/unwrap until M5. Assert it stays absent + // even when the response happens to carry one, so nobody "fixes" this by + // wiring up extractOperationId. + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: `txreq-${method}-noop`, + transactions: [{ unsignedTx: { coinSpecific: { operationId: 'op-should-be-ignored' } } }], + }, + }); + + const result = await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }); + + assert.strictEqual(result.operationId, undefined); + }); + + it('should forward walletPassphrase when provided', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: `txreq-${method}-hot` } }); + + await defiVault[method]({ + vaultId: 'vlt-galaxy-weth', + amount: '5000', + walletPassphrase: 'test-passphrase', + }); + + const args: any = sendManyStub.firstCall.args[0]; + args.walletPassphrase.should.equal('test-passphrase'); + }); + + it('should omit walletPassphrase entirely when absent (custody path)', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: `txreq-${method}-custody` } }); + + await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }); + + const args: any = sendManyStub.firstCall.args[0]; + args.should.not.have.property('walletPassphrase'); + }); + + it('should throw if vaultId is missing, without any network call', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: '', amount: '5000' }), { + message: 'vaultId is required', + }); + sendManyStub.called.should.be.false(); + }); + + it('should throw if amount is missing, without any network call', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '' }), { + message: 'amount is required', + }); + sendManyStub.called.should.be.false(); + }); + + describe(`prebuildTransactionTxRequests ${sendManyType} defiParams validation`, function () { + it('should throw when defiParams is missing', async function () { + await assert.rejects( + () => (wallet as any).prebuildTransactionTxRequests({ type: sendManyType }), + new RegExp(`${sendManyType}\\.defiParams`) + ); + }); + + it('should throw when vaultId is not a string', async function () { + await assert.rejects( + () => + (wallet as any).prebuildTransactionTxRequests({ + type: sendManyType, + defiParams: { vaultId: 123, amount: '5000' }, + }), + new RegExp(`${sendManyType}\\.defiParams`) + ); + }); + + it('should throw when amount is not a numeric string', async function () { + await assert.rejects( + () => + (wallet as any).prebuildTransactionTxRequests({ + type: sendManyType, + defiParams: { vaultId: 'vlt-1', amount: 5000 }, + }), + new RegExp(`${sendManyType}\\.defiParams`) + ); + }); + }); + }); + }); + describe('wallet.defi getter', function () { it('should return a DefiVault instance', function () { const defi = wallet.defi; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts index 34ed7b977f..5ffbdb5674 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -30,6 +30,11 @@ describe('recipientUtils', function () { 'defiApprove', 'defiDeposit', 'defiWithdraw', + // Native wrap/unwrap — registered in both spellings on purpose + 'wrapNative', + 'wrap-native', + 'unwrapNative', + 'unwrap-native', 'wrapApprove', 'contractCall', // Staking — 'delegate' also covers SOL solDelegateIntent @@ -141,6 +146,36 @@ describe('recipientUtils', function () { } }); + describe('native wrap/unwrap intents', function () { + // Regression test for the camelCase/kebab-case asymmetry. This set is matched + // against BOTH txParams.type (buildParams.type — camelCase, from wallet.sendMany) + // and intent.intentType (kebab-case, as WP persists it). Signing paths that carry + // no txParams — pendingApproval.approve() → recreateTxRequest() → signTxRequest() + // — only ever see the kebab-case spelling, so both must be registered. + it('does not throw when the type comes from buildParams.type (camelCase)', function () { + for (const txType of ['wrapNative', 'unwrapNative']) { + const txRequest = makeTxRequest(); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType })); + } + }); + + it('does not throw when the type comes only from intent.intentType (kebab-case)', function () { + for (const intentType of ['wrap-native', 'unwrap-native']) { + const txRequest = makeTxRequest({ intent: { intentType } as any }); + const result = resolveEffectiveTxParams(txRequest, {}); + assert.strictEqual(result.type, intentType); + assert.strictEqual(result.recipients, undefined); + } + }); + + it('does not throw when txParams is undefined entirely (pendingApproval re-sign path)', function () { + for (const intentType of ['wrap-native', 'unwrap-native']) { + const txRequest = makeTxRequest({ intent: { intentType, vaultId: 'vlt-weth-1', amount: '10' } as any }); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, undefined)); + } + }); + }); + it('does not throw when buildParams.type is PascalCase but intent.intentType is lowercase', function () { // signTransactionTss passes txPrebuild.buildParams as txParams. Prebuild uses // type: 'Import' while WP stores intentType: 'import' on the txRequest. From 76995020de07b0956f29d59513b345e5ac6ae2b5 Mon Sep 17 00:00:00 2001 From: Kamlesh Mugdiya Date: Mon, 31 Aug 2026 22:47:24 +0530 Subject: [PATCH 2/4] fix(sdk-core): fail fast on wrap/unwrap for non-EVM coins wallet.defi is constructed for any wallet regardless of coin, so calling wrap/unwrap on a non-EVM wallet (e.g. BTC) previously reached wallet-platform and came back as an opaque prebuild error. Reuse the existing CoinFeature.EVM_COIN flag (already set on every EVM-family coin in @bitgo/statics) as the support guard and throw a clear client-side error before issuing any network call. DEFI-661 --- modules/sdk-core/src/bitgo/defi/defiVault.ts | 6 ++++++ modules/sdk-core/test/unit/bitgo/defi/defiVault.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts index 228c3a4ded..a42f664f67 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -2,6 +2,7 @@ * @prettier */ import * as t from 'io-ts'; +import { CoinFeature } from '@bitgo/statics'; import { GetVaultResponse, VaultProtocol, VaultProtocolType } from '@bitgo/public-types'; import { ConcreteDepositResult, @@ -373,6 +374,11 @@ export class DefiVault implements IDefiVault { if (!params.amount) { throw new Error('amount is required'); } + // Wrapped-native vaults (WETH9 deposit()/withdraw()) only exist on EVM chains. Fail fast here + // instead of letting an unsupported coin reach wallet-platform and return an opaque prebuild error. + if (!this.wallet.baseCoin.getConfig().features.includes(CoinFeature.EVM_COIN)) { + throw new Error(`wrap/unwrap is not supported for ${this.wallet.baseCoin.getFamily()} wallets`); + } const result = await this.wallet.sendMany({ type, diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index caa1fd00d9..8f5a07546a 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -1,6 +1,7 @@ import sinon from 'sinon'; import assert from 'assert'; import 'should'; +import { CoinFeature } from '@bitgo/statics'; import { VaultProtocol } from '@bitgo/public-types'; import { ActiveOperationExistsError, DefiVault, Wallet } from '../../../../src'; @@ -73,6 +74,7 @@ describe('DefiVault', function () { mockBaseCoin = { getFamily: sinon.stub().returns('eth'), + getConfig: sinon.stub().returns({ features: [CoinFeature.EVM_COIN] }), url: sinon.stub(), keychains: sinon.stub(), supportsTss: sinon.stub().returns(true), @@ -823,6 +825,17 @@ describe('DefiVault', function () { sendManyStub.called.should.be.false(); }); + it('should throw a clear client-side error for a non-EVM coin, without any network call', async function () { + mockBaseCoin.getFamily.returns('btc'); + mockBaseCoin.getConfig.returns({ features: [] }); + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '5000' }), { + message: 'wrap/unwrap is not supported for btc wallets', + }); + sendManyStub.called.should.be.false(); + }); + describe(`prebuildTransactionTxRequests ${sendManyType} defiParams validation`, function () { it('should throw when defiParams is missing', async function () { await assert.rejects( From 23c23e5ed2e98b5b14be04c7064405b4a85514e4 Mon Sep 17 00:00:00 2001 From: Kamlesh Mugdiya Date: Mon, 31 Aug 2026 22:53:41 +0530 Subject: [PATCH 3/4] test(sdk-core): pin wrap/unwrap no-recipients wiring end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing resolveEffectiveTxParams tests only cover set membership in isolation. Add an end-to-end regression test through the real signRequestBase -> resolveEffectiveTxParams -> verifyTransaction path, using a TxRequest whose intentType is only known in kebab-case ('wrap-native' / 'unwrap-native') and no txParams at all — mirroring pendingApproval.approve() -> recreateTxRequest() -> signTxRequest(), the one signing path that never sees the camelCase spelling. Asserts verifyTransaction is actually reached (proving the no-recipients bypass didn't throw) and receives the expected type with no recipients, so future drift in the camelCase/kebab-case wiring is caught here instead of only at runtime. Verified the test fails if the kebab-case registration is removed from NO_RECIPIENT_TX_TYPES, and reverted that break before committing. DEFI-661 --- .../unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index cca0a733a4..4068a06452 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -748,6 +748,86 @@ describe('ECDSA MPC v2', async () => { ); }); + // Regression test for CGD-1815 / DEFI-661: resolveEffectiveTxParams() special-cases wrap-native + // and unwrap-native so the no-recipients guard in verifyTssTransaction doesn't reject them. That + // bypass is security-sensitive (it's what lets a DeFi vault wrap/unwrap tx skip recipient + // verification), so pin it through the real signRequestBase() -> resolveEffectiveTxParams() -> + // verifyTransaction() wiring instead of only unit-testing resolveEffectiveTxParams() in isolation. + // pendingApproval.approve() -> recreateTxRequest() -> signTxRequest() carries no txParams at all, + // so intentType is the ONLY source of the type here — exactly the path that only ever sees the + // kebab-case spelling ('wrap-native' / 'unwrap-native'), never the camelCase 'wrapNative'/'unwrapNative'. + ['wrap-native', 'unwrap-native'].forEach((intentType) => { + it(`signRequestBase should verify a no-txParams ${intentType} intent without requiring recipients`, async () => { + const serializedTxHex = 'f86c808504a817c80082520894' + '00'.repeat(20) + '80808080'; + const signableHex = serializedTxHex; + const derivationPath = 'm/0'; + + const mockBgWithPost = {} as BitGoBase; + mockBgWithPost.getEnv = sinon.stub().returns('test'); + mockBgWithPost.setRequestTracer = sinon.stub(); + mockBgWithPost.encrypt = sinon.stub().resolves('encrypted'); + mockBgWithPost.decrypt = sinon.stub().resolves('decrypted'); + mockBgWithPost.post = sinon.stub().returns({ + send: sinon.stub().returnsThis(), + set: sinon.stub().returnsThis(), + result: sinon.stub().rejects(new Error('mock: HTTP not available')), + }); + + const verifyTransactionSpy = sinon.stub().resolves(true); + const mockCoinForWrap = { + getHashFunction: sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash), + verifyTransaction: verifyTransactionSpy, + getMPCAlgorithm: sinon.stub().returns('ecdsa'), + getConfig: sinon.stub().returns({ family: 'hteth' }), + } as unknown as IBaseCoin; + + const mockWallet = { + id: sinon.stub().returns(walletID), + multisigType: sinon.stub().returns('tss'), + multisigTypeVersion: sinon.stub().returns('MPCv2'), + }; + + const wrapUtils = new EcdsaMPCv2Utils(mockBgWithPost, mockCoinForWrap, mockWallet as any); + sinon.stub(wrapUtils as any, 'pickBitgoPubGpgKeyForSigning').resolves(bitgoGpgKey.public); + + // No recipients anywhere (neither txParams.recipients nor intent.recipients) — the wrap-native/ + // unwrap-native calldata is built server-side from defiParams, per DefiVault.sendWrapIntent. + const txRequest = { + txRequestId: `wrap-native-test-${intentType}`, + apiVersion: 'full', + walletId: walletID, + intent: { intentType, defiParams: { vaultId: 'vlt-galaxy-weth', amount: '1000000000000000000' } }, + transactions: [ + { + unsignedTx: { derivationPath, signableHex, serializedTxHex }, + signatureShares: [], + }, + ], + } as unknown as TxRequest; + + try { + await wrapUtils.signTxRequest({ + txRequest, + // No txParams at all — mirrors pendingApproval.approve() -> recreateTxRequest() -> + // signTxRequest(), the one signing path that only ever sees the kebab-case intentType. + txParams: undefined, + prv: userShare.toString('base64'), + reqId: { inc: sinon.stub(), toString: sinon.stub().returns('test-req') } as any, + }); + } catch (e) {} + + assert.strictEqual( + verifyTransactionSpy.callCount, + 1, + 'verifyTransaction must be reached: resolveEffectiveTxParams must not throw for a no-recipients ' + + `${intentType} intent` + ); + const verifyCallArgs = verifyTransactionSpy.firstCall.args[0]; + assert.strictEqual(verifyCallArgs.txParams.type, intentType); + assert.strictEqual(verifyCallArgs.txParams.recipients, undefined); + }); + }); + it('should still apply keccak256 for regular FLR EVM transactions', async () => { // Regular EVM transaction on FLR (e.g. token transfer, not cross-chain). // serializedTxHex starts with 'f8' (RLP prefix), NOT '0000'. From 8d73c5b1d9276c98cf24e6b1bc3f55afa91d0f2a Mon Sep 17 00:00:00 2001 From: Kamlesh Mugdiya Date: Mon, 31 Aug 2026 22:57:52 +0530 Subject: [PATCH 4/4] fix(sdk-core): validate wrap/unwrap amount and trim vaultId The public wrap()/unwrap() boundary only rejected falsy values, while the downstream BigIntFromString codec (wallet.ts) accepts anything JS's BigInt() constructor does: negative amounts, hex strings like '0xabc', and zero. Since that amount forwards straight into a value-moving WETH9 deposit()/withdraw() call, require a positive unsigned decimal integer string in sendWrapIntent. Zero is explicitly rejected too: a zero-amount wrap/unwrap has no on-chain effect but would still spend gas. Also trim vaultId before validating/forwarding it, so a whitespace- only value is rejected instead of silently reaching wallet-platform, and incidental surrounding whitespace doesn't create a vaultId mismatch server-side. DEFI-661 --- modules/sdk-core/src/bitgo/defi/defiVault.ts | 14 +++-- .../test/unit/bitgo/defi/defiVault.ts | 62 ++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts index a42f664f67..6357962c08 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -368,11 +368,17 @@ export class DefiVault implements IDefiVault { * tracking arrives in milestone M5. */ private async sendWrapIntent(type: 'wrapNative' | 'unwrapNative', params: WrapOptions): Promise { - if (!params.vaultId) { + const vaultId = params.vaultId?.trim(); + if (!vaultId) { throw new Error('vaultId is required'); } - if (!params.amount) { - throw new Error('amount is required'); + // The downstream BigIntFromString codec (wallet.ts) accepts anything JS's BigInt() constructor + // does - negative amounts, hex strings like '0xabc', and zero - and this amount forwards + // straight into a value-moving WETH9 deposit()/withdraw() call. Require a positive unsigned + // decimal integer string here; zero is deliberately rejected too, since a zero-amount wrap/ + // unwrap has no on-chain effect but would still spend gas. + if (!params.amount || !/^\d+$/.test(params.amount) || BigInt(params.amount) === 0n) { + throw new Error('amount must be a positive unsigned decimal integer string'); } // Wrapped-native vaults (WETH9 deposit()/withdraw()) only exist on EVM chains. Fail fast here // instead of letting an unsupported coin reach wallet-platform and return an opaque prebuild error. @@ -383,7 +389,7 @@ export class DefiVault implements IDefiVault { const result = await this.wallet.sendMany({ type, defiParams: { - vaultId: params.vaultId, + vaultId, amount: params.amount, }, ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index 8f5a07546a..9e9cf0e0e4 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -820,11 +820,71 @@ describe('DefiVault', function () { const sendManyStub = sinon.stub(wallet, 'sendMany'); await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount: '' }), { - message: 'amount is required', + message: 'amount must be a positive unsigned decimal integer string', }); sendManyStub.called.should.be.false(); }); + describe('amount boundary validation', function () { + // The downstream BigIntFromString codec accepts all of these via JS's BigInt() constructor, + // so the client-side guard is the only thing standing between a malformed/malicious amount + // and a value-moving WETH9 deposit()/withdraw() call. + const rejectedAmounts = [ + ['-1', 'negative'], + ['0', 'zero'], + ['0xabc', 'hexadecimal'], + ['1.5', 'decimal point'], + ['1e18', 'scientific notation'], + ['+100', 'explicit sign'], + [' 100', 'leading whitespace'], + ['100 ', 'trailing whitespace'], + ['abc', 'non-numeric'], + ] as const; + rejectedAmounts.forEach(([amount, why]) => { + it(`should reject a ${why} amount (${JSON.stringify(amount)}), without any network call`, async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount }), { + message: 'amount must be a positive unsigned decimal integer string', + }); + sendManyStub.called.should.be.false(); + }); + }); + + ['1', '1000000000000000000', '007'].forEach((amount) => { + it(`should accept amount ${JSON.stringify(amount)} and forward it verbatim`, async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: 'txreq-boundary' } }); + + await defiVault[method]({ vaultId: 'vlt-galaxy-weth', amount }); + + const args: any = sendManyStub.firstCall.args[0]; + args.defiParams.amount.should.equal(amount); + }); + }); + }); + + describe('vaultId trimming', function () { + it('should throw for a whitespace-only vaultId, without any network call', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + + await assert.rejects(() => defiVault[method]({ vaultId: ' ', amount: '5000' }), { + message: 'vaultId is required', + }); + sendManyStub.called.should.be.false(); + }); + + it('should trim surrounding whitespace from vaultId before calling sendMany', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ txRequest: { txRequestId: 'txreq-trim' } }); + + await defiVault[method]({ vaultId: ' vlt-galaxy-weth ', amount: '5000' }); + + const args: any = sendManyStub.firstCall.args[0]; + args.defiParams.vaultId.should.equal('vlt-galaxy-weth'); + }); + }); + it('should throw a clear client-side error for a non-EVM coin, without any network call', async function () { mockBaseCoin.getFamily.returns('btc'); mockBaseCoin.getConfig.returns({ features: [] });