diff --git a/modules/sdk-core/src/bitgo/confidential/confidentialToken.ts b/modules/sdk-core/src/bitgo/confidential/confidentialToken.ts new file mode 100644 index 0000000000..c549b0fba7 --- /dev/null +++ b/modules/sdk-core/src/bitgo/confidential/confidentialToken.ts @@ -0,0 +1,178 @@ +/** + * @prettier + */ +import { IWallet } from '../wallet/iWallet'; +import { BitGoBase } from '../bitgoBase'; +import { ShieldJourneyDetail, ShieldTokenOptions, ShieldTokenResult } from './iConfidential'; + +export { ShieldTokenOptions, ShieldTokenResult, ShieldJourneyDetail }; + +const DEFAULT_POLL_INTERVAL_MS = 2000; +const DEFAULT_POLL_TIMEOUT_MS = 120_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Orchestrates ERC-7984 shield (wrapApprove → wait → sign WP-created wrap). + * + * Per TDD: the client creates only `wrapApprove`. WP performs the allowance + * branch and creates the `wrap` txnReq on approve confirm. The SDK polls the + * shield journey GET and signs/sends that wrap txnReq for hot wallets. + */ +export class ConfidentialToken { + private readonly wallet: IWallet; + private readonly bitgo: BitGoBase; + + constructor(wallet: IWallet) { + this.wallet = wallet; + this.bitgo = wallet.bitgo; + } + + /** + * Shield (wrap) an underlying ERC-20 amount into an ERC-7984 confidential token. + * + * @param params.tokenName - confidential wrapper token (e.g. `hteth:cusdt`) + * @param params.amount - underlying amount in base units + * @param params.walletPassphrase - required for hot TSS wallets + */ + async shieldToken(params: ShieldTokenOptions): Promise { + if (!params.tokenName) { + throw new Error('tokenName is required'); + } + if (!params.amount) { + throw new Error('amount is required'); + } + if (!/^[1-9]\d*$/.test(String(params.amount))) { + throw new Error(`amount must be a positive integer string, got '${params.amount}'`); + } + + // Step 1: client creates wrapApprove only (WP owns allowance branch + wrap create) + const wrapApproveResult = (await this.wallet.sendMany({ + type: 'wrapApprove', + shieldParams: { + tokenName: params.tokenName, + amount: String(params.amount), + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + })) as Record; + + const wrapApproveTxRequestId = this.extractTxRequestId(wrapApproveResult); + const wrapLinkId = this.extractWrapLinkId(wrapApproveResult); + if (!wrapLinkId) { + throw new Error('wrapLinkId not found in wrapApprove txRequest response'); + } + + // Custodial / pendingApproval: stop after wrapApprove; WP creates wrap later + const pendingApproval = wrapApproveResult.pendingApproval as { id?: string } | undefined; + if (pendingApproval?.id) { + return { + wrapLinkId, + wrapApproveTxRequestId, + pendingApprovalId: pendingApproval.id, + raw: { wrapApproveResult }, + }; + } + + // Step 2: wait for WP-created wrap txnReq (same wrapLinkId) + const journey = await this.pollForWrapTxRequest(wrapLinkId, { + pollIntervalMs: params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + pollTimeoutMs: params.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS, + }); + + if (!journey.wrapTxRequestId) { + throw new Error(`shield journey for ${wrapLinkId} reached status ${journey.status} without wrapTxRequestId`); + } + + // Step 3: sign + send WP-created wrap (client must not POST create) + if (!params.walletPassphrase) { + return { + wrapLinkId, + wrapApproveTxRequestId, + wrapTxRequestId: journey.wrapTxRequestId, + raw: { wrapApproveResult }, + }; + } + + const wrapResult = await this.wallet.signAndSendTxRequest({ + txRequestId: journey.wrapTxRequestId, + walletPassphrase: params.walletPassphrase, + isTxRequestFull: true, + }); + + return { + wrapLinkId, + wrapApproveTxRequestId, + wrapTxRequestId: journey.wrapTxRequestId, + raw: { wrapApproveResult, wrapResult }, + }; + } + + /** + * Fetch shield journey detail for a wrapLinkId. + */ + async getShieldJourney(wrapLinkId: string): Promise { + if (!wrapLinkId) { + throw new Error('wrapLinkId is required'); + } + return (await this.bitgo.get(this.wallet.url(`/token/shield/${wrapLinkId}`)).result()) as ShieldJourneyDetail; + } + + private async pollForWrapTxRequest( + wrapLinkId: string, + opts: { pollIntervalMs: number; pollTimeoutMs: number } + ): Promise { + const deadline = Date.now() + opts.pollTimeoutMs; + let last: ShieldJourneyDetail | undefined; + + while (Date.now() < deadline) { + last = await this.getShieldJourney(wrapLinkId); + if (last.wrapTxRequestId && (last.status === 'WRAP' || last.status === 'CONSUMED')) { + return last; + } + if (last.status === 'APPROVE_FAILED' || last.status === 'WRAP_FAILED') { + throw new Error(`shield journey ${wrapLinkId} failed with status ${last.status}`); + } + await sleep(opts.pollIntervalMs); + } + + throw new Error( + `timed out waiting for WP-created wrap txnReq for wrapLinkId ${wrapLinkId}` + + (last ? ` (last status: ${last.status})` : '') + ); + } + + private extractTxRequestId(sendManyResult: Record): string { + const txRequest = sendManyResult.txRequest as Record | undefined; + if (txRequest?.txRequestId) { + return txRequest.txRequestId as string; + } + if (sendManyResult.txRequestId) { + return sendManyResult.txRequestId as string; + } + throw new Error('txRequestId not found in sendMany response'); + } + + private extractWrapLinkId(sendManyResult: Record): string | undefined { + const txRequest = sendManyResult.txRequest as Record | undefined; + if (!txRequest) { + return undefined; + } + + const transactions = txRequest.transactions as Array> | undefined; + const fullUnsignedTx = transactions?.[0]?.unsignedTx as Record | undefined; + const fullCoinSpecific = fullUnsignedTx?.coinSpecific as Record | undefined; + if (typeof fullCoinSpecific?.wrapLinkId === 'string') { + return fullCoinSpecific.wrapLinkId; + } + + const unsignedTxs = txRequest.unsignedTxs as Array> | undefined; + const liteCoinSpecific = unsignedTxs?.[0]?.coinSpecific as Record | undefined; + if (typeof liteCoinSpecific?.wrapLinkId === 'string') { + return liteCoinSpecific.wrapLinkId; + } + + return undefined; + } +} diff --git a/modules/sdk-core/src/bitgo/confidential/iConfidential.ts b/modules/sdk-core/src/bitgo/confidential/iConfidential.ts new file mode 100644 index 0000000000..95f88b7d86 --- /dev/null +++ b/modules/sdk-core/src/bitgo/confidential/iConfidential.ts @@ -0,0 +1,55 @@ +/** + * @prettier + */ +/** + * Options for {@link IWallet.shieldToken}. + * + * Client creates only `wrapApprove`; WP creates the follow-up `wrap` txnReq + * after approve confirms (same `wrapLinkId`). See TDD Flow A. + */ +export interface ShieldTokenOptions { + /** Confidential wrapper token name (e.g. `hteth:cusdt`). */ + tokenName: string; + /** Underlying ERC-20 amount to shield, in base units (decimal string). */ + amount: string; + /** Required for hot wallets; omit for custodial (pendingApproval path). */ + walletPassphrase?: string; + /** Poll interval while waiting for WP-created wrap (ms). Default 2000. */ + pollIntervalMs?: number; + /** Max wait for wrap txnReq after approve (ms). Default 120000. */ + pollTimeoutMs?: number; +} + +/** + * Result of a successful {@link IWallet.shieldToken} orchestration. + */ +export interface ShieldTokenResult { + /** Linkage id minted on the wrapApprove txnReq (`unsignedTx.coinSpecific`). */ + wrapLinkId: string; + wrapApproveTxRequestId: string; + /** Present once WP has created the wrap txnReq and the SDK has signed/sent it (hot). */ + wrapTxRequestId?: string; + /** Present when wrapApprove returned a pendingApproval (custodial). */ + pendingApprovalId?: string; + raw?: { + wrapApproveResult?: unknown; + wrapResult?: unknown; + }; +} + +/** + * Journey detail returned by WP `GET …/token/shield/{wrapLinkId}`. + * Status values follow TDD coinSpecific orchestration states. + */ +export interface ShieldJourneyDetail { + wrapLinkId: string; + status: string; + wrapApproveTxRequestId?: string; + wrapTxRequestId?: string; +} + +/** Params passed through sendMany / TSS prebuild for ERC-7984 shield intents. */ +export interface ShieldIntentParams { + tokenName: string; + amount: string; +} diff --git a/modules/sdk-core/src/bitgo/confidential/index.ts b/modules/sdk-core/src/bitgo/confidential/index.ts new file mode 100644 index 0000000000..222321da60 --- /dev/null +++ b/modules/sdk-core/src/bitgo/confidential/index.ts @@ -0,0 +1,5 @@ +/** + * @prettier + */ +export * from './iConfidential'; +export { ConfidentialToken } from './confidentialToken'; diff --git a/modules/sdk-core/src/bitgo/index.ts b/modules/sdk-core/src/bitgo/index.ts index 18b116ce4c..934f91dbcd 100644 --- a/modules/sdk-core/src/bitgo/index.ts +++ b/modules/sdk-core/src/bitgo/index.ts @@ -9,6 +9,7 @@ export * from './bitgoBase'; export * from './config'; export * from './coinFactory'; export * from './defi'; +export * from './confidential'; export * from './ecdh'; export * from './enterprise'; export * from './environments'; diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index ed19e12f56..58d9eca7d6 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,15 @@ export abstract class MpcUtils { shareTokenAmount: params.defiParams.amount, }; } + case 'wrapApprove': + case 'wrap': { + assert(params.shieldParams, `'shieldParams' is required for ${params.intentType} intent`); + return { + ...baseIntent, + tokenName: params.shieldParams.tokenName, + amount: params.shieldParams.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..6fe1b98b92 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -375,6 +375,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase cantonCommandParams?: CantonCommandParams; /** DeFi vault intent fields for defi-approve / defi-deposit intents. */ defiParams?: DefiIntentParams; + /** ERC-7984 shield intent fields for wrapApprove / wrap. */ + shieldParams?: { tokenName: string; amount: string }; /** 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 d7116dea0d..68d341987e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -31,6 +31,9 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'defiApprove', 'defiDeposit', 'defiWithdraw', + // ERC-7984 shield — recipients/calldata built server-side from shieldParams + '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..7ab58846d8 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -161,6 +161,7 @@ export const BuildParams = t.exact( // Bridging parameters for cross-chain operations (e.g., BTC to sBTC) bridgingParams: t.unknown, defiParams: t.unknown, + 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 3fb40b46b3..f2ebd3a9cd 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -16,6 +16,7 @@ import { BitGoBase } from '../bitgoBase'; import { Keychain, KeychainWithEncryptedPrv } from '../keychain'; import { IPendingApproval, PendingApprovalData } from '../pendingApproval'; import { IDefiVault } from '../defi'; +import { ShieldTokenOptions, ShieldTokenResult } from '../confidential'; import { IGoStakingWallet, IStakingWallet } from '../staking'; import { ITradingAccount } from '../trading'; import { @@ -951,6 +952,11 @@ export interface SendManyOptions extends PrebuildAndSignTransactionOptions { actionType?: string; operationId?: string; }; + /** ERC-7984 shield intent fields for wrapApprove / wrap. */ + shieldParams?: { + tokenName: string; + amount: string; + }; } export interface FetchCrossChainUTXOsOptions { @@ -1252,6 +1258,11 @@ export interface IWallet { toJSON(): WalletData; createLightningInvoice(params: CreateLightningInvoiceParams): Promise; readonly defi: IDefiVault; + /** + * Shield (wrap) an underlying ERC-20 into an ERC-7984 confidential token. + * Creates wrapApprove only; waits for WP-created wrap and signs it for hot wallets. + */ + shieldToken(params: ShieldTokenOptions): Promise; toTradingAccount(): ITradingAccount; toStakingWallet(): IStakingWallet; toGoStakingWallet(): IGoStakingWallet; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 30b1bd4f3d..8eb74ff7e0 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -39,6 +39,7 @@ import { CreateLightningInvoiceParams, LightningInvoiceResponse } from '../../li import { getLightningAuthKey } from '../lightning/lightningWalletUtil'; import { IPendingApproval, PendingApproval, PendingApprovals } from '../pendingApproval'; import { DefiVault } from '../defi'; +import { ConfidentialToken, ShieldTokenOptions, ShieldTokenResult } from '../confidential'; import { GoStakingWallet, StakingWallet } from '../staking'; import { TradingAccount } from '../trading'; import { getTxRequest } from '../tss'; @@ -236,6 +237,7 @@ export class Wallet implements IWallet { public readonly baseCoin: IBaseCoin; public _wallet: WalletData; private _defi?: DefiVault; + private _confidential?: ConfidentialToken; private readonly tssUtils: EcdsaUtils | EcdsaMPCv2Utils | EddsaUtils | EddsaMPCv2Utils | undefined; private readonly _permissions?: string[]; /** Root keychain from passphrase preflight; consumed by getUserPrv to avoid a second GET. */ @@ -3342,6 +3344,17 @@ export class Wallet implements IWallet { return this._defi; } + /** + * Shield (wrap) an underlying ERC-20 into an ERC-7984 confidential token. + * Creates wrapApprove only; waits for WP-created wrap and signs it for hot wallets. + */ + async shieldToken(params: ShieldTokenOptions): Promise { + if (!this._confidential) { + this._confidential = new ConfidentialToken(this); + } + return this._confidential.shieldToken(params); + } + /** * Create a staking wallet from this wallet */ @@ -4780,6 +4793,28 @@ export class Wallet implements IWallet { ); break; } + case 'wrapApprove': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'wrapApprove', + shieldParams: params.shieldParams as { tokenName: string; amount: string }, + }, + apiVersion, + params.preview + ); + break; + case 'wrap': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'wrap', + shieldParams: params.shieldParams as { tokenName: string; amount: string }, + }, + apiVersion, + params.preview + ); + break; default: throw new Error(`transaction type not supported: ${params.type}`); } diff --git a/modules/sdk-core/test/unit/bitgo/confidential/shieldToken.ts b/modules/sdk-core/test/unit/bitgo/confidential/shieldToken.ts new file mode 100644 index 0000000000..4011adf8ae --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/confidential/shieldToken.ts @@ -0,0 +1,170 @@ +/** + * @prettier + */ +import sinon from 'sinon'; +import assert from 'assert'; +import 'should'; +import { ConfidentialToken, Wallet } from '../../../../src'; + +describe('ConfidentialToken.shieldToken', function () { + let wallet: Wallet; + let confidential: ConfidentialToken; + let mockBitGo: any; + let mockBaseCoin: any; + + function mockRequest(result: any) { + return { + send: sinon.stub().returnsThis(), + set: sinon.stub().returnsThis(), + query: sinon.stub().returnsThis(), + result: sinon.stub().resolves(result), + }; + } + + beforeEach(function () { + mockBitGo = { + post: sinon.stub(), + get: sinon.stub(), + del: sinon.stub(), + url: sinon.stub().callsFake((path: string, version = 2) => `https://bitgo.com/api/v${version}${path}`), + microservicesUrl: sinon.stub().callsFake((path: string) => `https://bitgo.com${path}`), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + getFamily: sinon.stub().returns('eth'), + getChain: sinon.stub().returns('hteth'), + url: sinon.stub().callsFake((path: string) => `https://bitgo.com/api/v2/hteth${path}`), + keychains: sinon.stub(), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub(), + }; + + const mockWalletData = { + id: 'test-wallet-id', + coin: 'hteth', + keys: ['user-key', 'backup-key', 'bitgo-key'], + enterprise: 'test-enterprise-id', + multisigType: 'tss', + }; + + wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData); + confidential = new ConfidentialToken(wallet); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should reject missing tokenName', async function () { + await assert.rejects(() => confidential.shieldToken({ tokenName: '', amount: '1000' }), { + message: 'tokenName is required', + }); + }); + + it('should reject missing amount', async function () { + await assert.rejects(() => confidential.shieldToken({ tokenName: 'hteth:cusdt', amount: '' }), { + message: 'amount is required', + }); + }); + + it('should reject non-positive amount', async function () { + await assert.rejects(() => confidential.shieldToken({ tokenName: 'hteth:cusdt', amount: '0' }), { + message: /amount must be a positive integer string/, + }); + }); + + it('should create wrapApprove, poll journey, and sign WP-created wrap', async function () { + const wrapLinkId = 'wrap-link-1'; + const wrapApproveTxRequestId = 'txreq-approve-1'; + const wrapTxRequestId = 'txreq-wrap-1'; + + const wrapApproveResult = { + txRequest: { + txRequestId: wrapApproveTxRequestId, + transactions: [ + { + unsignedTx: { + coinSpecific: { wrapLinkId }, + }, + }, + ], + }, + transfer: { state: 'confirmed' }, + }; + + const sendManyStub = sinon.stub(wallet, 'sendMany').resolves(wrapApproveResult); + + const journeyReq = mockRequest({ + wrapLinkId, + status: 'WRAP', + wrapApproveTxRequestId, + wrapTxRequestId, + }); + mockBitGo.get.returns(journeyReq); + + const wrapResult = { txRequestId: wrapTxRequestId }; + const signAndSendStub = sinon.stub(wallet, 'signAndSendTxRequest').resolves(wrapResult as any); + + const result = await confidential.shieldToken({ + tokenName: 'hteth:cusdt', + amount: '1000000', + walletPassphrase: 'secret', + pollIntervalMs: 1, + pollTimeoutMs: 1000, + }); + + sendManyStub.calledOnce.should.be.true(); + const sendManyArgs = sendManyStub.firstCall.args[0]!; + sendManyArgs.type!.should.equal('wrapApprove'); + sendManyArgs.shieldParams!.should.deepEqual({ tokenName: 'hteth:cusdt', amount: '1000000' }); + sendManyArgs.walletPassphrase!.should.equal('secret'); + + signAndSendStub.calledOnce.should.be.true(); + signAndSendStub.firstCall.args[0].should.deepEqual({ + txRequestId: wrapTxRequestId, + walletPassphrase: 'secret', + isTxRequestFull: true, + }); + + result.should.deepEqual({ + wrapLinkId, + wrapApproveTxRequestId, + wrapTxRequestId, + raw: { + wrapApproveResult, + wrapResult, + }, + }); + }); + + it('should return pendingApproval without polling wrap for custodial path', async function () { + sinon.stub(wallet, 'sendMany').resolves({ + pendingApproval: { id: 'pa-1', state: 'awaitingSignature' }, + txRequest: { + txRequestId: 'txreq-approve-1', + transactions: [{ unsignedTx: { coinSpecific: { wrapLinkId: 'wrap-link-1' } } }], + }, + }); + const signAndSendStub = sinon.stub(wallet, 'signAndSendTxRequest'); + + const result = await confidential.shieldToken({ + tokenName: 'hteth:cusdt', + amount: '1000000', + }); + + result.wrapLinkId.should.equal('wrap-link-1'); + result.pendingApprovalId!.should.equal('pa-1'); + assert.strictEqual(result.wrapTxRequestId, undefined); + signAndSendStub.called.should.be.false(); + }); + + it('should be exposed as wallet.shieldToken', async function () { + const stub = sinon.stub(ConfidentialToken.prototype, 'shieldToken').resolves({ + wrapLinkId: 'w', + wrapApproveTxRequestId: 'a', + }); + await wallet.shieldToken({ tokenName: 'hteth:cusdt', amount: '1' }); + stub.calledOnce.should.be.true(); + }); +});