-
Notifications
You must be signed in to change notification settings - Fork 307
feat(sdk-core): add wrap() and unwrap() to DefiVault #9569
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Could this path fail fast for coins that do not support a wrapped-native DeFi vault? |
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Could we also add the corresponding calldata check in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will do this in a followup PR. |
||
| '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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The public
wrap/unwrapboundary currently only rejects falsy values, while the downstream codec accepts negative and hexadecimal strings (for example-1and0xabc) and zero. Since these values are forwarded into a value-moving WETH9 operation, could we validate an unsigned decimal amount here, decide explicitly whether zero is allowed, and reject/trim blankvaultIdvalues before callingsendMany? Please add boundary tests for the accepted/rejected forms.