Skip to content
Closed
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
178 changes: 178 additions & 0 deletions modules/sdk-core/src/bitgo/confidential/confidentialToken.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<ShieldTokenResult> {
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<string, unknown>;

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<ShieldJourneyDetail> {
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<ShieldJourneyDetail> {
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, unknown>): string {
const txRequest = sendManyResult.txRequest as Record<string, unknown> | 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, unknown>): string | undefined {
const txRequest = sendManyResult.txRequest as Record<string, unknown> | undefined;
if (!txRequest) {
return undefined;
}

const transactions = txRequest.transactions as Array<Record<string, unknown>> | undefined;
const fullUnsignedTx = transactions?.[0]?.unsignedTx as Record<string, unknown> | undefined;
const fullCoinSpecific = fullUnsignedTx?.coinSpecific as Record<string, unknown> | undefined;
if (typeof fullCoinSpecific?.wrapLinkId === 'string') {
return fullCoinSpecific.wrapLinkId;
}

const unsignedTxs = txRequest.unsignedTxs as Array<Record<string, unknown>> | undefined;
const liteCoinSpecific = unsignedTxs?.[0]?.coinSpecific as Record<string, unknown> | undefined;
if (typeof liteCoinSpecific?.wrapLinkId === 'string') {
return liteCoinSpecific.wrapLinkId;
}

return undefined;
}
}
55 changes: 55 additions & 0 deletions modules/sdk-core/src/bitgo/confidential/iConfidential.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions modules/sdk-core/src/bitgo/confidential/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* @prettier
*/
export * from './iConfidential';
export { ConfidentialToken } from './confidentialToken';
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 11 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',
'wrapApprove',
'wrap',
].includes(params.intentType)
) {
assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`);
Expand Down Expand Up @@ -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}`);
}
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
3 changes: 3 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,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',

Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/BuildParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
11 changes: 11 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1252,6 +1258,11 @@ export interface IWallet {
toJSON(): WalletData;
createLightningInvoice(params: CreateLightningInvoiceParams): Promise<LightningInvoiceResponse>;
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<ShieldTokenResult>;
toTradingAccount(): ITradingAccount;
toStakingWallet(): IStakingWallet;
toGoStakingWallet(): IGoStakingWallet;
Expand Down
35 changes: 35 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<ShieldTokenResult> {
if (!this._confidential) {
this._confidential = new ConfidentialToken(this);
}
return this._confidential.shieldToken(params);
}

/**
* Create a staking wallet from this wallet
*/
Expand Down Expand Up @@ -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}`);
}
Expand Down
Loading
Loading