diff --git a/modules/sdk-coin-eth/test/unit/erc7984Token.ts b/modules/sdk-coin-eth/test/unit/erc7984Token.ts index 1f66a76457..bf923dfb37 100644 --- a/modules/sdk-coin-eth/test/unit/erc7984Token.ts +++ b/modules/sdk-coin-eth/test/unit/erc7984Token.ts @@ -1070,4 +1070,42 @@ describe('verifyTransaction – confidential consolidation (FlushERC7984Forwarde }) .should.be.rejectedWith(/parent address mismatch/); }); + + it('should verify multisig consolidation using forwarder address from txPrebuild.txInfo.recipients', async function () { + const txHex = await buildMultisigConsolidationTxHex(); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: CONSOLIDATION_BASE_ADDRESS }, + }); + + // No top-level recipients; forwarder comes from txInfo.recipients + const result = await coin.verifyTransaction({ + txParams: { type: 'consolidate' } as any, + txPrebuild: { + consolidateId: '6a44ebc0326e1be45c1d797542c8c634', + txHex, + txInfo: { recipients: [{ address: CONSOLIDATION_FORWARDER, amount: '0' }] }, + } as any, + wallet, + }); + result.should.equal(true); + }); + + it('should reject multisig consolidation when txInfo.recipients forwarder address does not match tx forwarder', async function () { + const txHex = await buildMultisigConsolidationTxHex(); + const wallet = new Wallet(bitgo, coin, { + coinSpecific: { baseAddress: CONSOLIDATION_BASE_ADDRESS }, + }); + + await coin + .verifyTransaction({ + txParams: { type: 'consolidate' } as any, + txPrebuild: { + consolidateId: 'abc123', + txHex, + txInfo: { recipients: [{ address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', amount: '0' }] }, + } as any, + wallet, + }) + .should.be.rejectedWith(/forwarder address mismatch/); + }); }); diff --git a/modules/sdk-coin-eth/test/unit/ethWallet.ts b/modules/sdk-coin-eth/test/unit/ethWallet.ts index 3e36160e4c..c0ac6d4760 100644 --- a/modules/sdk-coin-eth/test/unit/ethWallet.ts +++ b/modules/sdk-coin-eth/test/unit/ethWallet.ts @@ -94,6 +94,104 @@ describe('Sign ETH Transaction', async function () { }); }); +describe('Sign ETH Transaction – txInfo fallback', async function () { + let bitgo: TestBitGoAPI; + let ethWallet; + const recipients = [{ address: '0xe59dfe5c67114b39a5662cc856be536c614124c0', amount: '100000' }]; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('teth', Teth.createInstance); + const coin = bitgo.coin('teth'); + ethWallet = coin.newWalletObject({}); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should read recipients from txPrebuild.txInfo.recipients when txPrebuild.recipients is absent', async function () { + sinon.stub(Util, 'xprvToEthPrivateKey'); + sinon.stub(Util, 'ethSignMsgHash'); + sinon.stub(ethWallet.getOperationSha3ForExecuteAndConfirm); + + const txPrebuild = { txInfo: { recipients, nextContractSequenceId: 1 } }; + const { halfSigned } = (await ethWallet.signTransaction({ txPrebuild, prv: 'my_user_prv' })) as any; + halfSigned.should.have.property('recipients', recipients); + }); + + it('should prefer txPrebuild.recipients over txPrebuild.txInfo.recipients', async function () { + sinon.stub(Util, 'xprvToEthPrivateKey'); + sinon.stub(Util, 'ethSignMsgHash'); + sinon.stub(ethWallet.getOperationSha3ForExecuteAndConfirm); + + const txInfoRecipients = [{ address: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', amount: '1' }]; + const txPrebuild = { recipients, nextContractSequenceId: 0, txInfo: { recipients: txInfoRecipients } }; + const { halfSigned } = (await ethWallet.signTransaction({ txPrebuild, prv: 'my_user_prv' })) as any; + halfSigned.should.have.property('recipients', recipients); + }); + + it('should use txPrebuild.txInfo.nextContractSequenceId when nextContractSequenceId is absent', async function () { + sinon.stub(Util, 'xprvToEthPrivateKey'); + sinon.stub(Util, 'ethSignMsgHash'); + sinon.stub(ethWallet.getOperationSha3ForExecuteAndConfirm); + + const txPrebuild = { recipients, txInfo: { nextContractSequenceId: 5 } }; + const { halfSigned } = (await ethWallet.signTransaction({ txPrebuild, prv: 'my_user_prv' })) as any; + halfSigned.should.have.property('contractSequenceId', 5); + }); + + it('should throw when nextContractSequenceId is absent from both txPrebuild and txInfo', async function () { + await ethWallet + .signTransaction({ txPrebuild: { recipients }, prv: 'my_user_prv' }) + .should.be.rejectedWith('transaction prebuild missing required property nextContractSequenceId'); + }); + + it('should use txPrebuild.txInfo.eip1559 when txPrebuild.eip1559 is absent', async function () { + sinon.stub(Util, 'xprvToEthPrivateKey'); + sinon.stub(Util, 'ethSignMsgHash'); + sinon.stub(ethWallet.getOperationSha3ForExecuteAndConfirm); + + const eip1559 = { maxFeePerGas: '100', maxPriorityFeePerGas: '10' }; + const txPrebuild = { recipients, nextContractSequenceId: 0, txInfo: { eip1559 } }; + const { halfSigned } = (await ethWallet.signTransaction({ txPrebuild, prv: 'my_user_prv' })) as any; + halfSigned.should.have.property('eip1559', eip1559); + }); + + it('should prefer txPrebuild.eip1559 over txPrebuild.txInfo.eip1559', async function () { + sinon.stub(Util, 'xprvToEthPrivateKey'); + sinon.stub(Util, 'ethSignMsgHash'); + sinon.stub(ethWallet.getOperationSha3ForExecuteAndConfirm); + + const eip1559 = { maxFeePerGas: '200', maxPriorityFeePerGas: '20' }; + const txInfoEip1559 = { maxFeePerGas: '999', maxPriorityFeePerGas: '99' }; + const txPrebuild = { recipients, nextContractSequenceId: 0, eip1559, txInfo: { eip1559: txInfoEip1559 } }; + const { halfSigned } = (await ethWallet.signTransaction({ txPrebuild, prv: 'my_user_prv' })) as any; + halfSigned.should.have.property('eip1559', eip1559); + }); + + it('should use txPrebuild.txInfo.isBatch when txPrebuild.isBatch is absent', async function () { + sinon.stub(Util, 'xprvToEthPrivateKey'); + sinon.stub(Util, 'ethSignMsgHash'); + sinon.stub(ethWallet.getOperationSha3ForExecuteAndConfirm); + + const txPrebuild = { recipients, nextContractSequenceId: 0, txInfo: { isBatch: true } }; + const { halfSigned } = (await ethWallet.signTransaction({ txPrebuild, prv: 'my_user_prv' })) as any; + halfSigned.should.have.property('isBatch', true); + }); + + it('should prefer txPrebuild.isBatch over txPrebuild.txInfo.isBatch', async function () { + sinon.stub(Util, 'xprvToEthPrivateKey'); + sinon.stub(Util, 'ethSignMsgHash'); + sinon.stub(ethWallet.getOperationSha3ForExecuteAndConfirm); + + const txPrebuild = { recipients, nextContractSequenceId: 0, isBatch: false, txInfo: { isBatch: true } }; + const { halfSigned } = (await ethWallet.signTransaction({ txPrebuild, prv: 'my_user_prv' })) as any; + halfSigned.should.have.property('isBatch', false); + }); +}); + describe('Ethereum Hop Transactions', function () { let bitgo: TestBitGoAPI; let ethWallet;