Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions examples/ts/defi-vault-wrap.ts
Original file line number Diff line number Diff line change
@@ -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=<token> \
* STAGING_WALLET_ID=<walletId> \
* STAGING_WALLET_PASSPHRASE=<passphrase> \
* DEFI_VAULT_ID=<vaultId> \
* DEFI_WRAP_AMOUNT=<amountInBaseUnits> \
* DEFI_WRAP_DIRECTION=<wrap|unwrap> \
* 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);
});
23 changes: 23 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
61 changes: 61 additions & 0 deletions modules/sdk-core/src/bitgo/defi/defiVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
ResumeDepositOptions,
WithdrawFromVaultOptions,
WithdrawResult,
WrapOptions,
WrapResult,
} from './iDefiVault';
import { IWallet } from '../wallet';
import { BitGoBase } from '../bitgoBase';
Expand Down Expand Up @@ -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<WrapResult> {
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<WrapResult> {
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<WrapResult> {
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:
Expand Down
17 changes: 17 additions & 0 deletions modules/sdk-core/src/bitgo/defi/iDefiVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DepositResult>;
resumeDeposit(params: ResumeDepositOptions): Promise<DepositResult>;
Expand All @@ -88,4 +103,6 @@ export interface IDefiVault {
getVaultConfig(params: GetVaultConfigOptions): Promise<GetVaultResponse>;
getVaultProtocol(params: GetVaultConfigOptions): Promise<VaultProtocol>;
withdrawFromVault(params: WithdrawFromVaultOptions): Promise<WithdrawResult>;
wrap(params: WrapOptions): Promise<WrapResult>;
unwrap(params: WrapOptions): Promise<WrapResult>;
}
14 changes: 14 additions & 0 deletions modules/sdk-core/src/bitgo/utils/mpcUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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}`);
}
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
Loading
Loading