diff --git a/modules/bitgo/test/v2/unit/pendingApproval.ts b/modules/bitgo/test/v2/unit/pendingApproval.ts index 8a52608dc7e..e8c0b09dd24 100644 --- a/modules/bitgo/test/v2/unit/pendingApproval.ts +++ b/modules/bitgo/test/v2/unit/pendingApproval.ts @@ -1,23 +1,28 @@ /** * @prettier */ -import 'should'; +import should = require('should'); import nock = require('nock'); import * as sinon from 'sinon'; import { TestableBG, TestBitGo } from '@bitgo/sdk-test'; import { BitGo } from '../../../src'; +import * as ethAbi from 'ethereumjs-abi'; +import { coins as staticsCoins } from '@bitgo/statics'; import { BaseCoin, ECDSAUtils, EddsaUtils, Environments, + Keychains, PendingApproval, PendingApprovalData, PendingApprovalInfo, RequestTracer, SignatureShareType, + SignedTransaction, State, + TransactionType, TssUtils, TxRequest, Type, @@ -449,5 +454,256 @@ describe('Pending Approvals:', () => { const prebuildParamsArg = prebuildAndSignStub.getCall(0).args[0]!; (prebuildParamsArg.verification === undefined).should.be.true(); }); + + function makeEthIntentPendingApproval(legType: 'wrap' | 'finalizeUnwrap') { + const htethCoin = bitgo.coin('hteth'); + const paData: PendingApprovalData = { + id: `pa-${legType}`, + info: { + type: Type.TRANSACTION_REQUEST, + transactionRequest: { + coinSpecific: { hteth: {} }, + recipients: [{ address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba', amount: '1000000' }], + buildParams: { type: legType }, + sourceWallet: walletId, + }, + }, + wallet: walletId, + state: State.PENDING, + creator: 'test', + }; + const ethWallet = new Wallet(bitgo, htethCoin, { + id: walletId, + coin: 'hteth', + pendingApprovals: [paData], + }); + return { pendingApproval: ethWallet.pendingApprovals()[0], htethCoin, paData }; + } + + (['wrap', 'finalizeUnwrap'] as const).forEach((legType) => { + it(`strips stored recipients when approving a ${legType} intent pending approval`, async () => { + const { pendingApproval, htethCoin, paData } = makeEthIntentPendingApproval(legType); + + const prebuildAndSignStub = sandbox + .stub(Wallet.prototype, 'prebuildAndSignTransaction') + .resolves({ halfSigned: { txHex: 'half-signed-hex' } }); + sandbox.stub(htethCoin, 'parseTransaction').resolves({}); + + const paScope = nock(bgUrl) + .put(`/api/v2/hteth/pendingapprovals/pa-${legType}`, (body) => { + body.state.should.equal('approved'); + body.halfSigned.txHex.should.equal('half-signed-hex'); + return true; + }) + .reply(200, { ...paData, state: 'approved' }); + + await pendingApproval.approve({ xprv: 'nonsense', walletPassphrase: 'pw' }); + + paScope.isDone().should.be.true(); + prebuildAndSignStub.calledOnce.should.be.true(); + const prebuildParamsArg = prebuildAndSignStub.getCall(0).args[0]!; + should.not.exist(prebuildParamsArg.recipients); + prebuildParamsArg.type!.should.equal(legType); + (prebuildParamsArg.pendingApprovalId as string).should.equal(`pa-${legType}`); + }); + }); + + it('keeps forwarding stored recipients for a transfer-type pending approval rebuild', async () => { + const htethCoin = bitgo.coin('hteth'); + const transferPaData: PendingApprovalData = { + id: 'pa-transfer', + info: { + type: Type.TRANSACTION_REQUEST, + transactionRequest: { + coinSpecific: { hteth: {} }, + recipients: [{ address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba', amount: '1000000' }], + buildParams: {}, + sourceWallet: walletId, + }, + }, + wallet: walletId, + state: State.PENDING, + creator: 'test', + }; + const ethWallet = new Wallet(bitgo, htethCoin, { + id: walletId, + coin: 'hteth', + pendingApprovals: [transferPaData], + }); + const pendingApproval = ethWallet.pendingApprovals()[0]; + + const prebuildAndSignStub = sandbox + .stub(Wallet.prototype, 'prebuildAndSignTransaction') + .resolves({ txHex: 'signed-tx-hex' }); + sandbox.stub(htethCoin, 'parseTransaction').resolves({}); + + await pendingApproval.recreateAndSignTransaction({}); + + const prebuildParamsArg = prebuildAndSignStub.getCall(0).args[0]!; + prebuildParamsArg.recipients!.should.deepEqual([ + { address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba', amount: '1000000' }, + ]); + }); + + describe('real prebuildAndSignTransaction on ERC-7984 intent legs', function () { + const baseAddress = '0x7db562c4dd465cc895761c56f83b6af0e32689ba'; + const otherAddress = '0x1b85b55b88ba9f5b803bb9ebe5c0cfc6a045b9aa'; + const pair = staticsCoins.get('hteth:cusdt') as unknown as { contractAddress: string }; + const wrapper = pair.contractAddress; + const amount = '1000000'; + const unwrapParams = { tokenName: 'hteth:cusdt', amount }; + const requestId = Buffer.alloc(32, 0xab); + const encryptedAmount = Buffer.alloc(32, 0xcd); + const proof = Buffer.from('deadbeef', 'hex'); + + const encodeCall = (name: string, types: string[], values: unknown[]): string => + '0x' + Buffer.concat([ethAbi.methodID(name, types), ethAbi.rawEncode(types, values)]).toString('hex'); + + afterEach(() => { + nock.cleanAll(); + }); + + // Rebuilds the leg through the real prebuildAndSignTransaction: only the signing seams are stubbed, so + // the real verifyTransaction runs against the nocked /tx/build prebuild. + async function setupLegApproval( + legType: 'wrap' | 'unwrap' | 'finalizeUnwrap', + innerCalldata: string, + paBuildParams: NonNullable['buildParams'] + ) { + const paId = `pa-${legType}-real`; + const sendMultiSigCalldata = encodeCall( + 'sendMultiSig', + ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes'], + [wrapper, 0, Buffer.from(innerCalldata.slice(2), 'hex'), 1234, 1, Buffer.alloc(0)] + ); + + const htethCoin = bitgo.coin('hteth'); + // AbstractEthLikeNewCoins is not a bitgo-module dependency; the builder is used structurally. + const txBuilder = ( + htethCoin as unknown as { + getTransactionBuilder(): { + type(type: TransactionType): void; + fee(fee: { fee: string; gasLimit: string }): void; + counter(counter: number): void; + contract(contract: string): void; + data(data: string): void; + build(): Promise<{ toBroadcastFormat(): string }>; + }; + } + ).getTransactionBuilder(); + txBuilder.type(TransactionType.ContractCall); + txBuilder.fee({ fee: '10', gasLimit: '100000' }); + txBuilder.counter(1); + txBuilder.contract(baseAddress); + txBuilder.data(sendMultiSigCalldata); + const txHex = (await txBuilder.build()).toBroadcastFormat(); + + const paData: PendingApprovalData = { + id: paId, + info: { + type: Type.TRANSACTION_REQUEST, + transactionRequest: { + coinSpecific: { hteth: {} }, + recipients: [{ address: wrapper, amount: '0' }], + buildParams: paBuildParams, + sourceWallet: walletId, + }, + }, + wallet: walletId, + state: State.PENDING, + creator: 'test', + }; + const ethWallet = new Wallet(bitgo, htethCoin, { + id: walletId, + coin: 'hteth', + coinSpecific: { baseAddress }, + multisigType: 'onchain', + pendingApprovals: [paData], + }); + + sandbox.stub(htethCoin, 'parseTransaction').resolves({}); + sandbox + .stub(Keychains.prototype, 'getKeysForSigning') + .resolves([{ id: 'k1', type: 'independent', pub: '0x02' + 'aa'.repeat(64) }]); + sandbox + .stub(Wallet.prototype, 'signTransaction') + .resolves({ halfSigned: { txHex: 'half-signed-hex' } } as unknown as SignedTransaction); + + const buildScope = nock(bgUrl) + .post(`/api/v2/hteth/wallet/${walletId}/tx/build`, (body) => { + body.type.should.equal(legType); + body.pendingApprovalId.should.equal(paId); + should.not.exist(body.recipients); + should.not.exist(body.wrapParams); + if (legType === 'unwrap') { + body.unwrapParams.should.deepEqual(unwrapParams); + } else { + should.not.exist(body.unwrapParams); + } + return true; + }) + .reply(200, { + txHex, + recipients: [{ address: wrapper, amount: '0', data: innerCalldata }], + feeInfo: { fee: '1000' }, + coin: 'hteth', + walletId, + }); + const paScope = nock(bgUrl) + .put(`/api/v2/hteth/pendingapprovals/${paId}`, (body) => { + body.state.should.equal('approved'); + body.halfSigned.txHex.should.equal('half-signed-hex'); + return true; + }) + .reply(200, { ...paData, state: 'approved' }); + + return { pendingApproval: ethWallet.pendingApprovals()[0], buildScope, paScope }; + } + + const wrapCalldata = encodeCall('wrap', ['address', 'uint256'], [baseAddress, amount]); + const legs = [ + { legType: 'wrap', inner: wrapCalldata, paBuildParams: { type: 'wrap' } }, + { + legType: 'finalizeUnwrap', + inner: encodeCall('finalizeUnwrap', ['bytes32', 'uint64', 'bytes'], [requestId, amount, proof]), + paBuildParams: { type: 'finalizeUnwrap' }, + }, + { + legType: 'unwrap', + inner: encodeCall( + 'unwrap', + ['address', 'address', 'bytes32', 'bytes'], + [baseAddress, baseAddress, encryptedAmount, proof] + ), + paBuildParams: { type: 'unwrap', unwrapParams }, + }, + ] as const; + + legs.forEach(({ legType, inner, paBuildParams }) => { + it(`approves a ${legType} pending approval and verifies the rebuilt leg`, async () => { + const { pendingApproval, buildScope, paScope } = await setupLegApproval(legType, inner, paBuildParams); + + const result = await pendingApproval.approve({ xprv: 'nonsense', walletPassphrase: 'pw' }); + + buildScope.isDone().should.be.true(); + paScope.isDone().should.be.true(); + result.state.should.equal('approved'); + }); + }); + + it('rejects the approval when the rebuilt wrap mints to an address other than the wallet base', async () => { + const tamperedWrap = encodeCall('wrap', ['address', 'uint256'], [otherAddress, amount]); + const { pendingApproval, buildScope, paScope } = await setupLegApproval('wrap', tamperedWrap, { + type: 'wrap', + }); + + await pendingApproval + .approve({ xprv: 'nonsense', walletPassphrase: 'pw' }) + .should.be.rejectedWith(/mint recipient must be the wallet base address/); + + buildScope.isDone().should.be.true(); + paScope.isDone().should.be.false(); + }); + }); }); }); diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index a9cf89e7042..1047b21ee17 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -704,6 +704,26 @@ describe('V2 Wallet:', function () { e.message.should.equal('cannot provide recipients for transaction type finalizeUnwrap'); } }); + + it('still rejects recipients when prebuilding wrapApprove without a pendingApprovalId', async function () { + const recipients = [ + { + address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba', + amount: 0, + }, + ]; + + let caught: (Error & { code?: string }) | undefined; + try { + await ethWallet.prebuildAndSignTransaction({ recipients, type: 'wrapApprove' }); + } catch (e) { + caught = e; + } + + should.exist(caught); + caught!.message.should.equal('cannot provide recipients for transaction type wrapApprove'); + caught!.code!.should.equal('recipients_not_allowed_for_fillnonce_and_acceleration_tx_type'); + }); }); describe('OFC Create Address', () => { diff --git a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts index a28396b0520..5b33a47e22a 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts @@ -43,7 +43,7 @@ export interface PendingApprovalInfo { coinSpecific: { [key: string]: any }; recipients: any; buildParams: { - type?: 'fanout' | 'consolidate' | 'enabletoken'; + type?: 'fanout' | 'consolidate' | 'enabletoken' | 'wrapApprove' | 'wrap' | 'unwrap' | 'finalizeUnwrap'; [index: string]: any; }; sourceWallet?: string; diff --git a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts index cb867e3c1ec..b2899d633b0 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts @@ -40,6 +40,15 @@ type ApprovePendingApprovalRequestBody = { halfSigned?: string | Omit; }; +// ERC-7984 shield/unshield intent types: calldata is built server-side from the intent, so a +// pending approval's stored recipients are display-only for the approval UI, never build inputs. +const ERC7984_INTENT_TX_TYPES: Record = { + wrapApprove: true, + wrap: true, + unwrap: true, + finalizeUnwrap: true, +}; + export class PendingApproval implements IPendingApproval { private readonly bitgo: BitGoBase; private readonly baseCoin: IBaseCoin; @@ -304,6 +313,13 @@ export class PendingApproval implements IPendingApproval { const recipients = transactionRequest.recipients; let prebuildParams = _.extend({}, params, { recipients: recipients }, transactionRequest.buildParams); + // Stored recipients must not be forwarded for these intents — the prebuild guard rejects + // recipients for them, and the /tx/build PA-rebuild carve-out re-derives the real inputs + // server-side from the pendingApprovalId approve() injects. + if (ERC7984_INTENT_TX_TYPES[prebuildParams.type]) { + delete prebuildParams.recipients; + } + if (!_.isUndefined(originalPrebuild.hopTransaction)) { prebuildParams.hop = true; }