Skip to content

Commit 4d1c4ba

Browse files
committed
fix(sdk-core): allow ERC-7984 PA rebuild without recipients (CHALO-1957)
pendingApproval.approve() on a custodial multi-sig ERC-7984 leg PA (wrapApprove/wrap/unwrap user legs, wrap/finalizeUnwrap server-originated legs) merges the PA's stored info.transactionRequest.recipients into the prebuild params, tripping the prebuildAndSignTransaction no-recipients guard before any network call. recreateAndSignTransaction now strips recipients for the four ERC-7984 intent types before prebuilding: stored recipients are display-only for the approval UI, and the /tx/build PA-rebuild carve-out (CHALO-1954) re-derives the real intent inputs server-side from the pendingApprovalId approve() injects. Other PA types forward recipients unchanged, and the guard still rejects direct sendMany/prebuild calls without a pendingApprovalId. Ticket: CHALO-1957
1 parent d4a0535 commit 4d1c4ba

4 files changed

Lines changed: 128 additions & 2 deletions

File tree

‎modules/bitgo/test/v2/unit/pendingApproval.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @prettier
33
*/
4-
import 'should';
4+
import should = require('should');
55
import nock = require('nock');
66
import * as sinon from 'sinon';
77
import { TestableBG, TestBitGo } from '@bitgo/sdk-test';
@@ -449,5 +449,95 @@ describe('Pending Approvals:', () => {
449449
const prebuildParamsArg = prebuildAndSignStub.getCall(0).args[0]!;
450450
(prebuildParamsArg.verification === undefined).should.be.true();
451451
});
452+
453+
function makeEthIntentPendingApproval(legType: 'wrap' | 'finalizeUnwrap') {
454+
const htethCoin = bitgo.coin('hteth');
455+
const paData: PendingApprovalData = {
456+
id: `pa-${legType}`,
457+
info: {
458+
type: Type.TRANSACTION_REQUEST,
459+
transactionRequest: {
460+
coinSpecific: { hteth: {} },
461+
recipients: [{ address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba', amount: '1000000' }],
462+
buildParams: { type: legType },
463+
sourceWallet: walletId,
464+
},
465+
},
466+
wallet: walletId,
467+
state: State.PENDING,
468+
creator: 'test',
469+
};
470+
const ethWallet = new Wallet(bitgo, htethCoin, {
471+
id: walletId,
472+
coin: 'hteth',
473+
pendingApprovals: [paData],
474+
});
475+
return { pendingApproval: ethWallet.pendingApprovals()[0], htethCoin, paData };
476+
}
477+
478+
(['wrap', 'finalizeUnwrap'] as const).forEach((legType) => {
479+
it(`strips stored recipients when approving a ${legType} intent pending approval`, async () => {
480+
const { pendingApproval, htethCoin, paData } = makeEthIntentPendingApproval(legType);
481+
482+
const prebuildAndSignStub = sandbox
483+
.stub(Wallet.prototype, 'prebuildAndSignTransaction')
484+
.resolves({ halfSigned: { txHex: 'half-signed-hex' } });
485+
sandbox.stub(htethCoin, 'parseTransaction').resolves({});
486+
487+
const paScope = nock(bgUrl)
488+
.put(`/api/v2/hteth/pendingapprovals/pa-${legType}`, (body) => {
489+
body.state.should.equal('approved');
490+
body.halfSigned.txHex.should.equal('half-signed-hex');
491+
return true;
492+
})
493+
.reply(200, { ...paData, state: 'approved' });
494+
495+
await pendingApproval.approve({ xprv: 'nonsense', walletPassphrase: 'pw' });
496+
497+
paScope.isDone().should.be.true();
498+
prebuildAndSignStub.calledOnce.should.be.true();
499+
const prebuildParamsArg = prebuildAndSignStub.getCall(0).args[0]!;
500+
should.not.exist(prebuildParamsArg.recipients);
501+
prebuildParamsArg.type!.should.equal(legType);
502+
(prebuildParamsArg.pendingApprovalId as string).should.equal(`pa-${legType}`);
503+
});
504+
});
505+
506+
it('keeps forwarding stored recipients for a transfer-type pending approval rebuild', async () => {
507+
const htethCoin = bitgo.coin('hteth');
508+
const transferPaData: PendingApprovalData = {
509+
id: 'pa-transfer',
510+
info: {
511+
type: Type.TRANSACTION_REQUEST,
512+
transactionRequest: {
513+
coinSpecific: { hteth: {} },
514+
recipients: [{ address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba', amount: '1000000' }],
515+
buildParams: {},
516+
sourceWallet: walletId,
517+
},
518+
},
519+
wallet: walletId,
520+
state: State.PENDING,
521+
creator: 'test',
522+
};
523+
const ethWallet = new Wallet(bitgo, htethCoin, {
524+
id: walletId,
525+
coin: 'hteth',
526+
pendingApprovals: [transferPaData],
527+
});
528+
const pendingApproval = ethWallet.pendingApprovals()[0];
529+
530+
const prebuildAndSignStub = sandbox
531+
.stub(Wallet.prototype, 'prebuildAndSignTransaction')
532+
.resolves({ txHex: 'signed-tx-hex' });
533+
sandbox.stub(htethCoin, 'parseTransaction').resolves({});
534+
535+
await pendingApproval.recreateAndSignTransaction({});
536+
537+
const prebuildParamsArg = prebuildAndSignStub.getCall(0).args[0]!;
538+
prebuildParamsArg.recipients!.should.deepEqual([
539+
{ address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba', amount: '1000000' },
540+
]);
541+
});
452542
});
453543
});

‎modules/bitgo/test/v2/unit/wallet.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,26 @@ describe('V2 Wallet:', function () {
704704
e.message.should.equal('cannot provide recipients for transaction type finalizeUnwrap');
705705
}
706706
});
707+
708+
it('still rejects recipients when prebuilding wrapApprove without a pendingApprovalId', async function () {
709+
const recipients = [
710+
{
711+
address: '0x7db562c4dd465cc895761c56f83b6af0e32689ba',
712+
amount: 0,
713+
},
714+
];
715+
716+
let caught: (Error & { code?: string }) | undefined;
717+
try {
718+
await ethWallet.prebuildAndSignTransaction({ recipients, type: 'wrapApprove' });
719+
} catch (e) {
720+
caught = e;
721+
}
722+
723+
should.exist(caught);
724+
caught!.message.should.equal('cannot provide recipients for transaction type wrapApprove');
725+
caught!.code!.should.equal('recipients_not_allowed_for_fillnonce_and_acceleration_tx_type');
726+
});
707727
});
708728

709729
describe('OFC Create Address', () => {

‎modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface PendingApprovalInfo {
4343
coinSpecific: { [key: string]: any };
4444
recipients: any;
4545
buildParams: {
46-
type?: 'fanout' | 'consolidate' | 'enabletoken';
46+
type?: 'fanout' | 'consolidate' | 'enabletoken' | 'wrapApprove' | 'wrap' | 'unwrap' | 'finalizeUnwrap';
4747
[index: string]: any;
4848
};
4949
sourceWallet?: string;

‎modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ type ApprovePendingApprovalRequestBody = {
4040
halfSigned?: string | Omit<PreApproveResult, 'halfSigned'>;
4141
};
4242

43+
// ERC-7984 shield/unshield intent types: calldata is built server-side from the intent, so a
44+
// pending approval's stored recipients are display-only for the approval UI, never build inputs.
45+
const ERC7984_INTENT_TX_TYPES: Record<string, true> = {
46+
wrapApprove: true,
47+
wrap: true,
48+
unwrap: true,
49+
finalizeUnwrap: true,
50+
};
51+
4352
export class PendingApproval implements IPendingApproval {
4453
private readonly bitgo: BitGoBase;
4554
private readonly baseCoin: IBaseCoin;
@@ -304,6 +313,13 @@ export class PendingApproval implements IPendingApproval {
304313
const recipients = transactionRequest.recipients;
305314
let prebuildParams = _.extend({}, params, { recipients: recipients }, transactionRequest.buildParams);
306315

316+
// Stored recipients must not be forwarded for these intents — the prebuild guard rejects
317+
// recipients for them, and the /tx/build PA-rebuild carve-out re-derives the real inputs
318+
// server-side from the pendingApprovalId approve() injects.
319+
if (ERC7984_INTENT_TX_TYPES[prebuildParams.type]) {
320+
delete prebuildParams.recipients;
321+
}
322+
307323
if (!_.isUndefined(originalPrebuild.hopTransaction)) {
308324
prebuildParams.hop = true;
309325
}

0 commit comments

Comments
 (0)