From 8a805fa59d898801ecb5486b28b0c08368f5b212 Mon Sep 17 00:00:00 2001 From: Prabhsharan Singh Date: Tue, 25 Aug 2026 12:35:36 +0530 Subject: [PATCH] feat(sdk-core): wire wrapApprove sendMany intent Ticket: CHALO-1340 Co-authored-by: Cursor --- modules/bitgo/test/v2/unit/wallet.ts | 93 +++++++++++++++++++ modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 21 +++++ .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 8 ++ .../src/bitgo/utils/tss/recipientUtils.ts | 5 +- .../sdk-core/src/bitgo/wallet/BuildParams.ts | 2 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 15 +++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 22 ++++- .../unit/bitgo/utils/mpcUtils.wrapApprove.ts | 89 ++++++++++++++++++ .../unit/bitgo/utils/tss/recipientUtils.ts | 16 ++++ .../test/unit/bitgo/wallet/BuildParams.ts | 16 ++++ 10 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/utils/mpcUtils.wrapApprove.ts diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index b86c51c49f..cbc6e07e42 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -3626,6 +3626,52 @@ describe('V2 Wallet:', function () { args[1]!.should.equal('full'); }); + it('should call prebuildTxWithIntent with the correct params for wrapApprove', async function () { + const feeOptions = { + maxFeePerGas: 3000000000, + maxPriorityFeePerGas: 2000000000, + }; + const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' }; + + const prebuildTxWithIntent = sandbox.stub(ECDSAUtils.EcdsaUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequestFull); + + await tssEthWallet.prebuildTransaction({ + reqId, + type: 'wrapApprove', + shieldParams, + feeOptions, + }); + + sinon.assert.calledOnce(prebuildTxWithIntent); + const args = prebuildTxWithIntent.args[0]; + args[0]!.should.not.have.property('recipients'); + args[0]!.intentType.should.equal('wrapApprove'); + args[0]!.shieldParams!.should.deepEqual(shieldParams); + args[0]!.feeOptions!.should.deepEqual(feeOptions); + args[1]!.should.equal('full'); + }); + + it('should call prebuildTxWithIntent with the correct params for wrap', async function () { + const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' }; + + const prebuildTxWithIntent = sandbox.stub(ECDSAUtils.EcdsaUtils.prototype, 'prebuildTxWithIntent'); + prebuildTxWithIntent.resolves(txRequestFull); + + await tssEthWallet.prebuildTransaction({ + reqId, + type: 'wrap', + shieldParams, + }); + + sinon.assert.calledOnce(prebuildTxWithIntent); + const args = prebuildTxWithIntent.args[0]; + args[0]!.should.not.have.property('recipients'); + args[0]!.intentType.should.equal('wrap'); + args[0]!.shieldParams!.should.deepEqual(shieldParams); + args[1]!.should.equal('full'); + }); + it('should call prebuildTxWithIntent with the correct params for eth fillNonce for receive address nonce filling tx', async function () { const feeOptions = { maxFeePerGas: 3000000000, @@ -4018,6 +4064,53 @@ describe('V2 Wallet:', function () { intent.intentType.should.equal('tokenApproval'); }); + it('populate intent should return valid eth wrapApprove intent from shieldParams', async function () { + const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth')); + const feeOptions = { + maxFeePerGas: 3000000000, + maxPriorityFeePerGas: 2000000000, + }; + const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' }; + + const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), { + reqId, + intentType: 'wrapApprove', + shieldParams, + feeOptions, + }); + + intent.should.have.property('recipients', undefined); + intent.feeOptions!.should.deepEqual(feeOptions); + intent.tokenName!.should.equal(shieldParams.tokenName); + intent.amount!.should.equal(shieldParams.amount); + intent.intentType.should.equal('wrapApprove'); + }); + + it('populate intent should return valid eth wrap intent from shieldParams', async function () { + const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth')); + const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' }; + + const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), { + reqId, + intentType: 'wrap', + shieldParams, + }); + + intent.should.have.property('recipients', undefined); + intent.tokenName!.should.equal(shieldParams.tokenName); + intent.amount!.should.equal(shieldParams.amount); + intent.intentType.should.equal('wrap'); + }); + + it('populate intent should require shieldParams for wrapApprove', async function () { + const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth')); + (() => + mpcUtils.populateIntent(bitgo.coin('hteth'), { + reqId, + intentType: 'wrapApprove', + })).should.throw(/shieldParams/); + }); + it('should populate intent with custodianTransactionId', async function () { const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth')); const feeOptions = { diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index ed19e12f56..4199add5b1 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', + 'wrapApprove', + 'wrap', ].includes(params.intentType) ) { assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`); @@ -334,6 +336,25 @@ export abstract class MpcUtils { shareTokenAmount: params.defiParams.amount, }; } + case 'wrapApprove': + case 'wrap': { + assert(params.shieldParams, `'shieldParams' is required for ${params.intentType} intent`); + assert( + typeof params.shieldParams.tokenName === 'string' && params.shieldParams.tokenName.length > 0, + `'shieldParams.tokenName' is required for ${params.intentType} intent` + ); + assert( + typeof params.shieldParams.amount === 'string' && /^[1-9]\d*$/.test(params.shieldParams.amount), + `'shieldParams.amount' must be a positive integer string for ${params.intentType} intent` + ); + return { + ...baseIntent, + tokenName: params.shieldParams.tokenName, + amount: params.shieldParams.amount, + feeOptions: params.feeOptions, + feeToken: params.feeToken, + }; + } 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 96c4d14d88..97fc75a3f4 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -299,6 +299,12 @@ export interface DefiIntentParams { operationId?: string; } +/** ERC-7984 wrap / wrapApprove parameters (input container for shieldParams). */ +export interface ShieldIntentParams { + tokenName: string; + amount: string; +} + export interface IntentOptionsForMessage extends IntentOptionsBase { messageRaw: string; messageEncoded?: string; @@ -375,6 +381,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase cantonCommandParams?: CantonCommandParams; /** DeFi vault intent fields for defi-approve / defi-deposit intents. */ defiParams?: DefiIntentParams; + /** ERC-7984 wrap / wrapApprove fields flattened onto the WP intent. */ + shieldParams?: ShieldIntentParams; /** Canton party ID of the end investor to onboard (cantonEndInvestorOnboardingOffer intent). */ endInvestorPartyId?: string; /** Reason for rejecting the onboarding offer (cantonEndInvestorOnboardingReject intent). */ diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index 0f6c6ac9e5..d9169cb436 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -9,7 +9,7 @@ import { PopulatedIntent, TxRequest } from './baseTypes'; * Mirrors the bypass list in abstractEthLikeNewCoins.ts verifyTssTransaction. * * ECDSA types: acceleration, fillNonce, transferToken, tokenApproval, consolidate, - * bridgeFunds, enableToken, customTx, wrapApprove, contractCall + * bridgeFunds, enableToken, customTx, wrapApprove, wrap, contractCall * BSC/BNB delegation-based staking: delegate, undelegate, switchValidator * CELO/ETH lock-based staking: stake, unstake, stakeWithCallData, unstakeWithCallData, * transferStake, increaseStake, goUnstake @@ -31,8 +31,9 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'defiApprove', 'defiDeposit', 'defiWithdraw', - // ERC-7984 shielding: approve calldata is built server-side from the wrap intent + // ERC-7984 shielding: approve/wrap calldata is built server-side from the wrap intent 'wrapApprove', + 'wrap', // Smart contract invocations with no explicit SDK-level recipients 'contractCall', diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 5eb62cec6f..aba2323295 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -161,6 +161,8 @@ export const BuildParams = t.exact( // Bridging parameters for cross-chain operations (e.g., BTC to sBTC) bridgingParams: t.unknown, defiParams: t.unknown, + // ERC-7984 wrap / wrapApprove: { tokenName, amount } passthrough to WP + shieldParams: t.unknown, // WebAuthn attestation for the withdrawal intent (WCN-539) — pass-through only. attestation: AttestationPayload, }), diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 06fca6da8a..031b205e7c 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -296,6 +296,14 @@ export interface PrebuildTransactionOptions { * Used with type: 'bridging' for cross-chain bridging operations. */ bridgingParams?: BridgingParams; + /** + * ERC-7984 wrap / wrapApprove parameters (`type: 'wrapApprove' | 'wrap'`). + * Passed through to WP as tokenName + amount on the intent. + */ + shieldParams?: { + tokenName: string; + amount: string; + }; /** * Parameters for executing DAML commands on Canton. */ @@ -951,6 +959,13 @@ export interface SendManyOptions extends PrebuildAndSignTransactionOptions { actionType?: string; operationId?: string; }; + /** + * ERC-7984 wrap / wrapApprove parameters. WP builds approve/wrap calldata from these. + */ + shieldParams?: { + tokenName: string; + amount: string; + }; } export interface FetchCrossChainUTXOsOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 4618486c50..b7e0319522 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2643,7 +2643,13 @@ export class Wallet implements IWallet { throw error; } - if (params.recipients && (params.type === 'fillNonce' || params.type === 'acceleration')) { + if ( + params.recipients && + (params.type === 'fillNonce' || + params.type === 'acceleration' || + params.type === 'wrapApprove' || + params.type === 'wrap') + ) { const error: any = new Error(`cannot provide recipients for transaction type ${params.type}`); error.code = 'recipients_not_allowed_for_fillnonce_and_acceleration_tx_type'; throw error; @@ -4477,6 +4483,20 @@ export class Wallet implements IWallet { params.preview ); break; + case 'wrapApprove': + case 'wrap': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: params.type === 'wrap' ? 'wrap' : 'wrapApprove', + shieldParams: params.shieldParams as { tokenName: string; amount: string }, + feeOptions, + feeToken: params.feeToken, + }, + apiVersion, + params.preview + ); + break; case 'createAccount': txRequest = await this.tssUtils!.prebuildTxWithIntent( { diff --git a/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.wrapApprove.ts b/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.wrapApprove.ts new file mode 100644 index 0000000000..5f35ca56b5 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.wrapApprove.ts @@ -0,0 +1,89 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { IBaseCoin, KeychainsTriplet } from '../../../../src/bitgo/baseCoin'; +import { BitGoBase } from '../../../../src/bitgo/bitgoBase'; +import { MpcUtils } from '../../../../src/bitgo/utils/mpcUtils'; +import { IRequestTracer } from '../../../../src/api/types'; + +class TestMpcUtils extends MpcUtils { + createKeychains(): Promise { + return Promise.reject(new Error('unused')); + } +} + +describe('populateIntent wrapApprove / wrap', function () { + const reqId = { id: () => 'test-req' } as IRequestTracer; + let mpcUtils: TestMpcUtils; + let coin: IBaseCoin; + + beforeEach(function () { + const mockBitgo = { getEnv: sinon.stub().returns('test') } as unknown as BitGoBase; + coin = { + getChain: () => 'hteth', + getFamily: () => 'eth', + isEVM: () => true, + supportsTss: () => true, + } as unknown as IBaseCoin; + mpcUtils = new TestMpcUtils(mockBitgo, coin); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('flattens shieldParams onto wrapApprove intent', function () { + const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' }; + const feeOptions = { maxFeePerGas: 3000000000, maxPriorityFeePerGas: 2000000000 }; + + const intent = mpcUtils.populateIntent(coin, { + reqId, + intentType: 'wrapApprove', + shieldParams, + feeOptions, + }); + + assert.strictEqual(intent.intentType, 'wrapApprove'); + assert.strictEqual(intent.tokenName, shieldParams.tokenName); + assert.strictEqual(intent.amount, shieldParams.amount); + assert.deepStrictEqual(intent.feeOptions, feeOptions); + assert.strictEqual(intent.recipients, undefined); + }); + + it('flattens shieldParams onto wrap intent', function () { + const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' }; + + const intent = mpcUtils.populateIntent(coin, { + reqId, + intentType: 'wrap', + shieldParams, + }); + + assert.strictEqual(intent.intentType, 'wrap'); + assert.strictEqual(intent.tokenName, shieldParams.tokenName); + assert.strictEqual(intent.amount, shieldParams.amount); + assert.strictEqual(intent.recipients, undefined); + }); + + it('requires shieldParams for wrapApprove', function () { + assert.throws( + () => + mpcUtils.populateIntent(coin, { + reqId, + intentType: 'wrapApprove', + }), + /shieldParams/ + ); + }); + + it('rejects non-positive shieldParams.amount', function () { + assert.throws( + () => + mpcUtils.populateIntent(coin, { + reqId, + intentType: 'wrapApprove', + shieldParams: { tokenName: 'hteth:cusdt', amount: '0' }, + }), + /shieldParams.amount/ + ); + }); +}); 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..7625c2f72c 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -31,6 +31,7 @@ describe('recipientUtils', function () { 'defiDeposit', 'defiWithdraw', 'wrapApprove', + 'wrap', 'contractCall', // Staking — 'delegate' also covers SOL solDelegateIntent 'delegate', @@ -130,6 +131,21 @@ describe('recipientUtils', function () { assert.strictEqual(result.recipients, undefined); }); + it('does not require recipients for a wrap intent', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'wrap', + tokenName: 'hteth:cusdt', + amount: '1000000', + } as any, + }); + + const result = resolveEffectiveTxParams(txRequest, {}); + + assert.strictEqual(result.type, 'wrap'); + assert.strictEqual(result.recipients, undefined); + }); + it('does not throw for Avalanche cross-chain imports resolved from intent.intentType', function () { // P-chain and C-chain import intents legitimately carry no recipients — // the wallet imports its own UTXOs and the destination address is the diff --git a/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts b/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts index 7390c1a6e0..378be0514e 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts @@ -101,6 +101,22 @@ describe('BuildParams', function () { assert.ok(buildParamKeys.includes('attestation'), 'buildParamKeys must include attestation'); }); + it('should whitelist shieldParams while stripping unknown params', function () { + const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' }; + assert.deepStrictEqual( + BuildParams.encode({ + type: 'wrapApprove', + shieldParams, + unknownField: 'should be stripped', + } as any), + { + type: 'wrapApprove', + shieldParams, + } + ); + assert.ok(buildParamKeys.includes('shieldParams'), 'buildParamKeys must include shieldParams'); + }); + it('AttestationPayload codec requires all four fields', function () { const valid = { signature: 'sig',