Skip to content
Merged
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
81 changes: 80 additions & 1 deletion modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3289,7 +3289,12 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
`${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
);
}
if (txParams.hop && txPrebuild.hopTransaction) {
if (txParams.type === 'enabletoken') {
// ERC-7984 token enablement: intercept before hop/batch/single routing.
// Handles both base-address (txPrebuild → ACL) and forwarder (txPrebuild → forwarder) wallets,
// for single or multiple tokens at once.
await this.verifyMultisigEnableTokenTransaction(params, throwRecipientMismatch);
} else if (txParams.hop && txPrebuild.hopTransaction) {
// Check recipient amount for hop transaction
if (recipients.length !== 1) {
throw new Error(`hop transaction only supports 1 recipient but ${recipients.length} found`);
Expand Down Expand Up @@ -3388,6 +3393,80 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
* @param address
* @returns {boolean}
*/
/**
* Verifies an ERC-7984 token enablement (decryption delegation) transaction for multisig wallets.
*
* Two wallet shapes are supported:
*
* Base-address wallet — txPrebuild targets the Zama ACL contract directly:
* txParams.recipients : one entry per token, all with the wallet base address, amount '0'
* txPrebuild.recipients[0] : the Zama ACL contract address
*
* Forwarder wallet — txPrebuild targets the forwarder which calls the ACL via callFromParent:
* txParams.recipients : one entry per token, all with the forwarder address, amount '0'
* txPrebuild.recipients[0] : the forwarder address (matches txParams recipients)
*
* In both cases all amounts must be 0 and all txParams recipients must share the same address.
*/
private async verifyMultisigEnableTokenTransaction(
params: VerifyEthTransactionOptions,
throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise<never>
): Promise<void> {
const { txParams, txPrebuild } = params;
const recipients = txParams.recipients!;

if (!recipients || recipients.length === 0) {
throw new Error('token enablement transaction must have at least one recipient in txParams');
}

// All txParams recipients must have amount 0
for (const r of recipients) {
if (!new BigNumber(r.amount).isEqualTo(0)) {
await throwRecipientMismatch('token enablement txParams recipients must all have amount 0', [
{ address: r.address, amount: String(r.amount) },
]);
}
}

// txPrebuild recipient amount must be 0
if (!new BigNumber(txPrebuild.recipients[0].amount).isEqualTo(0)) {
await throwRecipientMismatch('token enablement transaction expected amount 0 in txPrebuild', [
{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() },
]);
}

// All txParams recipients must share the same address —
// a single delegation tx covers one wallet, not multiple wallets simultaneously.
const firstAddress = recipients[0].address.toLowerCase();
for (const r of recipients) {
if (r.address.toLowerCase() !== firstAddress) {
await throwRecipientMismatch('token enablement txParams recipients must all have the same address', [
{ address: r.address, amount: String(r.amount) },
]);
}
}

const prebuildAddress = txPrebuild.recipients[0].address.toLowerCase();
const zamaAclContractAddress = (this.getNetwork() as EthLikeNetwork)?.zamaAclContractAddress;

// Base-address wallet: txPrebuild targets the Zama ACL contract directly.
// txParams recipients carry the wallet base address — the intentional address mismatch is expected.
if (zamaAclContractAddress && prebuildAddress === zamaAclContractAddress.toLowerCase()) {
return;
}

// Forwarder wallet: txPrebuild targets the forwarder address, which must match txParams recipients.
if (prebuildAddress === firstAddress) {
return;
}

// Neither recognised shape — txPrebuild address is not the ACL contract and doesn't match txParams
await throwRecipientMismatch(
'token enablement txPrebuild recipient must be the Zama ACL contract (base-address wallet) or match txParams recipients (forwarder wallet)',
[{ address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }]
);
}

private isETHAddress(address: string): boolean {
return !!address.match(/0x[a-fA-F0-9]{40}/);
}
Expand Down
167 changes: 167 additions & 0 deletions modules/sdk-coin-eth/test/unit/eth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2647,4 +2647,171 @@ describe('ETH:', function () {
assert.strictEqual(payload.length, 32);
});
});

// ---------------------------------------------------------------------------
// ERC-7984 token enablement — multisig verifyTransaction (all scenarios)
// ---------------------------------------------------------------------------
describe('verifyTransaction – ERC-7984 enabletoken (multisig wallet)', function () {
// hteth Zama ACL contract (Networks.test.hoodi.zamaAclContractAddress)
const ZAMA_ACL_ADDRESS = '0x6d3faf6f86e1ff9f3b0831dda920aba1cbd5bd68';
// Simulated wallet base address (what the client sends as the recipient for base-address wallets)
const WALLET_BASE_ADDRESS = '0x08e6736e876d772d2fa8f803a076cd6c9f845546';
// Simulated forwarder address (what the client sends as recipient for forwarder wallets)
const FORWARDER_ADDRESS = '0x1234567890123456789012345678901234567890';
const WRONG_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';

const makePrebuild = (recipientAddress: string) => ({
recipients: [{ address: recipientAddress, amount: '0' }],
nextContractSequenceId: 0,
gasPrice: 20000000000,
gasLimit: 300000,
isBatch: false,
coin: 'hteth',
walletId: 'fakeWalletId',
});

// ── Base-address wallet ──────────────────────────────────────────────────

it('base-address, single token: txParams → walletBase, txPrebuild → ACL', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

const result = await htethCoin.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }],
} as any,
txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any,
wallet,
});
result.should.equal(true);
});

it('base-address, multi-token: multiple txParams recipients (same base address), txPrebuild → ACL multicall', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

const result = await htethCoin.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [
{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' },
{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:cusdt' },
],
} as any,
txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any,
wallet,
});
result.should.equal(true);
});

// ── Forwarder wallet ─────────────────────────────────────────────────────

it('forwarder, single token: txParams → forwarder, txPrebuild → forwarder', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

const result = await htethCoin.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [{ address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }],
} as any,
txPrebuild: makePrebuild(FORWARDER_ADDRESS) as any,
wallet,
});
result.should.equal(true);
});

it('forwarder, multi-token: multiple txParams recipients (same forwarder), txPrebuild → forwarder', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

const result = await htethCoin.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [
{ address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' },
{ address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:cusdt' },
],
} as any,
txPrebuild: makePrebuild(FORWARDER_ADDRESS) as any,
wallet,
});
result.should.equal(true);
});

// ── Negative cases ───────────────────────────────────────────────────────

it('should throw when txPrebuild recipient is neither the ACL contract nor the forwarder from txParams', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

await htethCoin
.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }],
} as any,
txPrebuild: makePrebuild(WRONG_ADDRESS) as any,
wallet,
})
.should.be.rejectedWith(/token enablement txPrebuild recipient must be/);
});

it('should throw when txParams recipients have different addresses', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

await htethCoin
.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [
{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' },
{ address: FORWARDER_ADDRESS, amount: '0', tokenName: 'hteth:cusdt' },
],
} as any,
txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any,
wallet,
})
.should.be.rejectedWith(/must all have the same address/);
});

it('should throw when a txParams recipient has non-zero amount', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

await htethCoin
.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [{ address: WALLET_BASE_ADDRESS, amount: '100', tokenName: 'hteth:ctest1' }],
} as any,
txPrebuild: makePrebuild(ZAMA_ACL_ADDRESS) as any,
wallet,
})
.should.be.rejectedWith(/must all have amount 0/);
});

it('should throw when txPrebuild recipient has non-zero amount', async function () {
const htethCoin = bitgo.coin('hteth') as Hteth;
const wallet = new Wallet(bitgo, htethCoin, { coinSpecific: { baseAddress: WALLET_BASE_ADDRESS } });

await htethCoin
.verifyTransaction({
txParams: {
type: 'enabletoken',
recipients: [{ address: WALLET_BASE_ADDRESS, amount: '0', tokenName: 'hteth:ctest1' }],
} as any,
txPrebuild: {
recipients: [{ address: ZAMA_ACL_ADDRESS, amount: '500' }],
nextContractSequenceId: 0,
coin: 'hteth',
walletId: 'fakeWalletId',
} as any,
wallet,
})
.should.be.rejectedWith(/expected amount 0 in txPrebuild/);
});
});
});
Loading