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
93 changes: 93 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3626,6 +3626,52 @@ describe('V2 Wallet:', function () {
args[1]!.should.equal('full');
});

it('should call prebuildTxWithIntent with the correct params for wrapApprove', async function () {
const feeOptions = {
maxFeePerGas: 3000000000,
maxPriorityFeePerGas: 2000000000,
};
const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' };

const prebuildTxWithIntent = sandbox.stub(ECDSAUtils.EcdsaUtils.prototype, 'prebuildTxWithIntent');
prebuildTxWithIntent.resolves(txRequestFull);

await tssEthWallet.prebuildTransaction({
reqId,
type: 'wrapApprove',
shieldParams,
feeOptions,
});

sinon.assert.calledOnce(prebuildTxWithIntent);
const args = prebuildTxWithIntent.args[0];
args[0]!.should.not.have.property('recipients');
args[0]!.intentType.should.equal('wrapApprove');
args[0]!.shieldParams!.should.deepEqual(shieldParams);
args[0]!.feeOptions!.should.deepEqual(feeOptions);
args[1]!.should.equal('full');
});

it('should call prebuildTxWithIntent with the correct params for wrap', async function () {
const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' };

const prebuildTxWithIntent = sandbox.stub(ECDSAUtils.EcdsaUtils.prototype, 'prebuildTxWithIntent');
prebuildTxWithIntent.resolves(txRequestFull);

await tssEthWallet.prebuildTransaction({
reqId,
type: 'wrap',
shieldParams,
});

sinon.assert.calledOnce(prebuildTxWithIntent);
const args = prebuildTxWithIntent.args[0];
args[0]!.should.not.have.property('recipients');
args[0]!.intentType.should.equal('wrap');
args[0]!.shieldParams!.should.deepEqual(shieldParams);
args[1]!.should.equal('full');
});

it('should call prebuildTxWithIntent with the correct params for eth fillNonce for receive address nonce filling tx', async function () {
const feeOptions = {
maxFeePerGas: 3000000000,
Expand Down Expand Up @@ -4018,6 +4064,53 @@ describe('V2 Wallet:', function () {
intent.intentType.should.equal('tokenApproval');
});

it('populate intent should return valid eth wrapApprove intent from shieldParams', async function () {
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth'));
const feeOptions = {
maxFeePerGas: 3000000000,
maxPriorityFeePerGas: 2000000000,
};
const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' };

const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), {
reqId,
intentType: 'wrapApprove',
shieldParams,
feeOptions,
});

intent.should.have.property('recipients', undefined);
intent.feeOptions!.should.deepEqual(feeOptions);
intent.tokenName!.should.equal(shieldParams.tokenName);
intent.amount!.should.equal(shieldParams.amount);
intent.intentType.should.equal('wrapApprove');
});

it('populate intent should return valid eth wrap intent from shieldParams', async function () {
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth'));
const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' };

const intent = mpcUtils.populateIntent(bitgo.coin('hteth'), {
reqId,
intentType: 'wrap',
shieldParams,
});

intent.should.have.property('recipients', undefined);
intent.tokenName!.should.equal(shieldParams.tokenName);
intent.amount!.should.equal(shieldParams.amount);
intent.intentType.should.equal('wrap');
});

it('populate intent should require shieldParams for wrapApprove', async function () {
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth'));
(() =>
mpcUtils.populateIntent(bitgo.coin('hteth'), {
reqId,
intentType: 'wrapApprove',
})).should.throw(/shieldParams/);
});

it('should populate intent with custodianTransactionId', async function () {
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth'));
const feeOptions = {
Expand Down
21 changes: 21 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,25 @@ export abstract class MpcUtils {
shareTokenAmount: params.defiParams.amount,
};
}
case 'wrapApprove':
case 'wrap': {
assert(params.shieldParams, `'shieldParams' is required for ${params.intentType} intent`);
assert(
typeof params.shieldParams.tokenName === 'string' && params.shieldParams.tokenName.length > 0,
`'shieldParams.tokenName' is required for ${params.intentType} intent`
);
assert(
typeof params.shieldParams.amount === 'string' && /^[1-9]\d*$/.test(params.shieldParams.amount),
`'shieldParams.amount' must be a positive integer string for ${params.intentType} intent`
);
return {
...baseIntent,
tokenName: params.shieldParams.tokenName,
amount: params.shieldParams.amount,
feeOptions: params.feeOptions,
feeToken: params.feeToken,
};
}
default:
throw new Error(`Unsupported intent type ${params.intentType}`);
}
Expand Down
8 changes: 8 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@ export interface DefiIntentParams {
operationId?: string;
}

/** ERC-7984 wrap / wrapApprove parameters (input container for shieldParams). */
export interface ShieldIntentParams {
tokenName: string;
amount: string;
}

export interface IntentOptionsForMessage extends IntentOptionsBase {
messageRaw: string;
messageEncoded?: string;
Expand Down Expand Up @@ -375,6 +381,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase
cantonCommandParams?: CantonCommandParams;
/** DeFi vault intent fields for defi-approve / defi-deposit intents. */
defiParams?: DefiIntentParams;
/** ERC-7984 wrap / wrapApprove fields flattened onto the WP intent. */
shieldParams?: ShieldIntentParams;
/** Canton party ID of the end investor to onboard (cantonEndInvestorOnboardingOffer intent). */
endInvestorPartyId?: string;
/** Reason for rejecting the onboarding offer (cantonEndInvestorOnboardingReject intent). */
Expand Down
5 changes: 3 additions & 2 deletions modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { PopulatedIntent, TxRequest } from './baseTypes';
* Mirrors the bypass list in abstractEthLikeNewCoins.ts verifyTssTransaction.
*
* ECDSA types: acceleration, fillNonce, transferToken, tokenApproval, consolidate,
* bridgeFunds, enableToken, customTx, wrapApprove, contractCall
* bridgeFunds, enableToken, customTx, wrapApprove, wrap, contractCall
* BSC/BNB delegation-based staking: delegate, undelegate, switchValidator
* CELO/ETH lock-based staking: stake, unstake, stakeWithCallData, unstakeWithCallData,
* transferStake, increaseStake, goUnstake
Expand All @@ -31,8 +31,9 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
'defiApprove',
'defiDeposit',
'defiWithdraw',
// ERC-7984 shielding: approve calldata is built server-side from the wrap intent
// ERC-7984 shielding: approve/wrap calldata is built server-side from the wrap intent
'wrapApprove',
'wrap',
// Smart contract invocations with no explicit SDK-level recipients
'contractCall',

Expand Down
2 changes: 2 additions & 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,8 @@ export const BuildParams = t.exact(
// Bridging parameters for cross-chain operations (e.g., BTC to sBTC)
bridgingParams: t.unknown,
defiParams: t.unknown,
// ERC-7984 wrap / wrapApprove: { tokenName, amount } passthrough to WP
shieldParams: t.unknown,
// WebAuthn attestation for the withdrawal intent (WCN-539) — pass-through only.
attestation: AttestationPayload,
}),
Expand Down
15 changes: 15 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ export interface PrebuildTransactionOptions {
* Used with type: 'bridging' for cross-chain bridging operations.
*/
bridgingParams?: BridgingParams;
/**
* ERC-7984 wrap / wrapApprove parameters (`type: 'wrapApprove' | 'wrap'`).
* Passed through to WP as tokenName + amount on the intent.
*/
shieldParams?: {
tokenName: string;
amount: string;
};
/**
* Parameters for executing DAML commands on Canton.
*/
Expand Down Expand Up @@ -951,6 +959,13 @@ export interface SendManyOptions extends PrebuildAndSignTransactionOptions {
actionType?: string;
operationId?: string;
};
/**
* ERC-7984 wrap / wrapApprove parameters. WP builds approve/wrap calldata from these.
*/
shieldParams?: {
tokenName: string;
amount: string;
};
}

export interface FetchCrossChainUTXOsOptions {
Expand Down
22 changes: 21 additions & 1 deletion modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2643,7 +2643,13 @@ export class Wallet implements IWallet {
throw error;
}

if (params.recipients && (params.type === 'fillNonce' || params.type === 'acceleration')) {
if (
params.recipients &&
(params.type === 'fillNonce' ||
params.type === 'acceleration' ||
params.type === 'wrapApprove' ||
params.type === 'wrap')
) {
const error: any = new Error(`cannot provide recipients for transaction type ${params.type}`);
error.code = 'recipients_not_allowed_for_fillnonce_and_acceleration_tx_type';
throw error;
Expand Down Expand Up @@ -4477,6 +4483,20 @@ export class Wallet implements IWallet {
params.preview
);
break;
case 'wrapApprove':
case 'wrap':
txRequest = await this.tssUtils!.prebuildTxWithIntent(
{
reqId,
intentType: params.type === 'wrap' ? 'wrap' : 'wrapApprove',
shieldParams: params.shieldParams as { tokenName: string; amount: string },
feeOptions,
feeToken: params.feeToken,
},
apiVersion,
params.preview
);
break;
case 'createAccount':
txRequest = await this.tssUtils!.prebuildTxWithIntent(
{
Expand Down
89 changes: 89 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/mpcUtils.wrapApprove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from 'assert';
import * as sinon from 'sinon';
import { IBaseCoin, KeychainsTriplet } from '../../../../src/bitgo/baseCoin';
import { BitGoBase } from '../../../../src/bitgo/bitgoBase';
import { MpcUtils } from '../../../../src/bitgo/utils/mpcUtils';
import { IRequestTracer } from '../../../../src/api/types';

class TestMpcUtils extends MpcUtils {
createKeychains(): Promise<KeychainsTriplet> {
return Promise.reject(new Error('unused'));
}
}

describe('populateIntent wrapApprove / wrap', function () {
const reqId = { id: () => 'test-req' } as IRequestTracer;
let mpcUtils: TestMpcUtils;
let coin: IBaseCoin;

beforeEach(function () {
const mockBitgo = { getEnv: sinon.stub().returns('test') } as unknown as BitGoBase;
coin = {
getChain: () => 'hteth',
getFamily: () => 'eth',
isEVM: () => true,
supportsTss: () => true,
} as unknown as IBaseCoin;
mpcUtils = new TestMpcUtils(mockBitgo, coin);
});

afterEach(function () {
sinon.restore();
});

it('flattens shieldParams onto wrapApprove intent', function () {
const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' };
const feeOptions = { maxFeePerGas: 3000000000, maxPriorityFeePerGas: 2000000000 };

const intent = mpcUtils.populateIntent(coin, {
reqId,
intentType: 'wrapApprove',
shieldParams,
feeOptions,
});

assert.strictEqual(intent.intentType, 'wrapApprove');
assert.strictEqual(intent.tokenName, shieldParams.tokenName);
assert.strictEqual(intent.amount, shieldParams.amount);
assert.deepStrictEqual(intent.feeOptions, feeOptions);
assert.strictEqual(intent.recipients, undefined);
});

it('flattens shieldParams onto wrap intent', function () {
const shieldParams = { tokenName: 'hteth:cusdt', amount: '1000000' };

const intent = mpcUtils.populateIntent(coin, {
reqId,
intentType: 'wrap',
shieldParams,
});

assert.strictEqual(intent.intentType, 'wrap');
assert.strictEqual(intent.tokenName, shieldParams.tokenName);
assert.strictEqual(intent.amount, shieldParams.amount);
assert.strictEqual(intent.recipients, undefined);
});

it('requires shieldParams for wrapApprove', function () {
assert.throws(
() =>
mpcUtils.populateIntent(coin, {
reqId,
intentType: 'wrapApprove',
}),
/shieldParams/
);
});

it('rejects non-positive shieldParams.amount', function () {
assert.throws(
() =>
mpcUtils.populateIntent(coin, {
reqId,
intentType: 'wrapApprove',
shieldParams: { tokenName: 'hteth:cusdt', amount: '0' },
}),
/shieldParams.amount/
);
});
});
16 changes: 16 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('recipientUtils', function () {
'defiDeposit',
'defiWithdraw',
'wrapApprove',
'wrap',
'contractCall',
// Staking — 'delegate' also covers SOL solDelegateIntent
'delegate',
Expand Down Expand Up @@ -130,6 +131,21 @@ describe('recipientUtils', function () {
assert.strictEqual(result.recipients, undefined);
});

it('does not require recipients for a wrap intent', function () {
const txRequest = makeTxRequest({
intent: {
intentType: 'wrap',
tokenName: 'hteth:cusdt',
amount: '1000000',
} as any,
});

const result = resolveEffectiveTxParams(txRequest, {});

assert.strictEqual(result.type, 'wrap');
assert.strictEqual(result.recipients, undefined);
});

it('does not throw for Avalanche cross-chain imports resolved from intent.intentType', function () {
// P-chain and C-chain import intents legitimately carry no recipients —
// the wallet imports its own UTXOs and the destination address is the
Expand Down
Loading
Loading