From ee36234ff472ec88b2ea2e4be35c9851d19911e6 Mon Sep 17 00:00:00 2001 From: Kamlesh Mugdiya Date: Wed, 26 Aug 2026 12:24:04 +0530 Subject: [PATCH] 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 b86c51c49f..e899bc0f5f 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 96c4d14d88..2ec5bf97ce 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -373,7 +373,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 4618486c50..d25a0b648a 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -4784,6 +4784,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.