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
54 changes: 54 additions & 0 deletions modules/abstract-eth/src/lib/zamaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,57 @@ export function wrapInCallFromParent(targetAddress: string, calldata: string): s
);
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
}

/**
* Decodes token contract addresses from delegation calldata.
*
* Handles two shapes of calldata:
* - Direct ACL.multicall(bytes[]) (root wallet path)
* - ForwarderV4.callFromParent(address, uint256, bytes) wrapping a multicall (forwarder path)
*
* @param calldata ABI-encoded delegation calldata (0x-prefixed or raw hex)
* @returns Array of token contract addresses (lowercase) found in the delegation calls
* @throws {Error} if the calldata does not start with a recognised method selector
*/
export function decodeTokenAddressesFromDelegationCalldata(calldata: string): string[] {
const data = calldata.startsWith('0x') ? calldata : '0x' + calldata;
const methodId = data.slice(0, 10);
const abiCoder = new ethers.utils.AbiCoder();

let multicallHex: string;

if (methodId === callFromParentMethodId) {
// Decode callFromParent(address, uint256, bytes) — inner bytes is the full multicall calldata.
// ethers v5 returns `bytes` as a hex string; use hexlify to normalise to a 0x-prefixed hex string
// regardless of whether the runtime returns a string or a Uint8Array.
const decoded = abiCoder.decode([...callFromParentTypes], '0x' + data.slice(10));
multicallHex = ethers.utils.hexlify(decoded[2]);
} else if (methodId === aclMulticallMethodId) {
multicallHex = data;
} else {
throw new Error('Not a valid delegation calldata');
}

if (multicallHex.slice(0, 10) !== aclMulticallMethodId) {
throw new Error('Not a valid delegation calldata');
}

// Decode multicall(bytes[]) — each element is an inner delegateForUserDecryption call.
// ethers v5 returns bytes[] elements as hex strings; use hexlify to normalise each element.
const decoded = abiCoder.decode(['bytes[]'], '0x' + multicallHex.slice(10));
const innerCalls: unknown[] = decoded[0];

const tokenAddresses: string[] = [];
for (const innerCall of innerCalls) {
const innerHex = ethers.utils.hexlify(innerCall as ethers.utils.BytesLike).slice(2); // strip 0x
const innerMethodId = '0x' + innerHex.slice(0, 8);
if (innerMethodId !== delegateForUserDecryptionMethodId) {
continue;
}
// Decode delegateForUserDecryption(address delegate, address tokenAddress, uint64 expiry)
const innerDecoded = abiCoder.decode([...delegateForUserDecryptionTypes], '0x' + innerHex.slice(8));
tokenAddresses.push((innerDecoded[1] as string).toLowerCase());
}

return tokenAddresses;
}
155 changes: 152 additions & 3 deletions modules/sdk-coin-eth/src/erc7984Token.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
/**
* @prettier
*/
import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core';
import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor, TokenEnablementConfig } from '@bitgo/sdk-core';

import { coins, Erc7984TokenConfig, tokens } from '@bitgo/statics';
import { CoinNames, DecryptionDelegationBuilder } from '@bitgo/abstract-eth';
import { coins, Erc7984TokenConfig, EthereumNetwork, tokens } from '@bitgo/statics';
import {
CoinNames,
DecryptionDelegationBuilder,
decodeTokenAddressesFromDelegationCalldata,
VerifyEthTransactionOptions,
aclMulticallMethodId,
callFromParentMethodId,
} from '@bitgo/abstract-eth';

import { Eth } from './eth';
import { TransactionBuilder } from './lib';
Expand Down Expand Up @@ -114,6 +121,148 @@ export class Erc7984Token extends Eth {
return new TransactionBuilder(coins.get(this.getBaseChain()));
}

/** @inheritDoc */
getTokenEnablementConfig(): TokenEnablementConfig {
return {
requiresTokenEnablement: true,
supportsMultipleTokenEnablements: true,
};
}

/** @inheritDoc */
async verifyTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
if (params.txParams?.type === 'enabletoken') {
return this.verifyEnableTokenTransaction(params);
}
return super.verifyTransaction(params);
}

/**
* Verifies a token enablement transaction for ERC-7984 decryption delegation.
*
* TSS path: decodes the raw tx and verifies it calls the ACL contract with
* calldata that covers all requested token contract addresses.
*
* Multisig path: verifies the buildParams recipients carry the correct tokenNames
* and zero amounts.
*/
private async verifyEnableTokenTransaction(params: VerifyEthTransactionOptions): Promise<boolean> {
const { txParams, txPrebuild, walletType } = params;

if (walletType === 'tss') {
// TSS path: full raw-tx decode
const enableTokens = txParams.enableTokens;
if (!enableTokens || enableTokens.length === 0) {
throw new Error('verifyEnableTokenTransaction: enableTokens must be non-empty for TSS path');
}
if (!txPrebuild.txHex) {
throw new Error('verifyEnableTokenTransaction: missing txHex in txPrebuild');
}

// Resolve requested token names → contract addresses
const requestedAddresses = enableTokens.map((t) => {
const tokenCoin = this.bitgo.coin(t.name) as Erc7984Token;
return tokenCoin.tokenContractAddress.toLowerCase();
});

// Parse the raw transaction
const txBuilder = this.getTransactionBuilder();
txBuilder.from(txPrebuild.txHex);
const tx = await txBuilder.build();
const txJson = tx.toJson();

// Verify transaction targets the correct contract based on calldata shape
const network = this.getNetwork() as EthereumNetwork;
const aclContractAddress = network?.zamaAclContractAddress;
if (!aclContractAddress) {
throw new Error('verifyEnableTokenTransaction: zamaAclContractAddress not configured for this network');
}
if (!txJson.to) {
throw new Error('verifyEnableTokenTransaction: transaction is missing recipient address');
}

// Inspect calldata method ID to distinguish root wallet from forwarder wallet:
// aclMulticallMethodId → root wallet: to = ACL contract directly
// callFromParentMethodId → forwarder wallet: to = forwarder, ACL address is inside calldata
const calldataMethodId = txJson.data.slice(0, 10);
if (calldataMethodId === aclMulticallMethodId) {
// Root wallet (base address): tx calls the ACL contract directly
if (txJson.to.toLowerCase() !== aclContractAddress.toLowerCase()) {
throw new Error(
`verifyEnableTokenTransaction: transaction target ${txJson.to} does not match ACL contract ${aclContractAddress}`
);
}
} else if (calldataMethodId === callFromParentMethodId) {
// Forwarder wallet: tx calls the forwarder, which calls the ACL via callFromParent.
// The forwarder address is wallet-specific and cannot be statically verified here;
// token address correctness is still verified below via calldata decoding.
} else {
throw new Error(
`verifyEnableTokenTransaction: unrecognised calldata method ID ${calldataMethodId}; expected multicall or callFromParent`
);
}

// Verify value is 0
if (txJson.value !== '0') {
throw new Error(`verifyEnableTokenTransaction: expected transaction value 0 but got ${txJson.value}`);
}

// Decode token addresses from calldata and verify all requested tokens are present
const decodedAddresses = decodeTokenAddressesFromDelegationCalldata(txJson.data);
for (const requested of requestedAddresses) {
if (!decodedAddresses.includes(requested)) {
throw new Error(
`verifyEnableTokenTransaction: requested token ${requested} not found in delegation calldata`
);
}
}

return true;
} else {
// Multisig path: buildParams-level check
const recipients = txPrebuild.buildParams?.recipients as
| Array<{ tokenName?: string; amount?: string }>
| undefined;
if (!recipients || recipients.length === 0) {
throw new Error('verifyEnableTokenTransaction: missing buildParams.recipients for multisig path');
}

// Determine requested token names from txParams
const requestedTokenNames: string[] = [];
if (txParams.enableTokens && txParams.enableTokens.length > 0) {
requestedTokenNames.push(...txParams.enableTokens.map((t) => t.name));
} else if (txParams.recipients && txParams.recipients.length > 0) {
requestedTokenNames.push(...txParams.recipients.map((r: any) => r.tokenName).filter(Boolean));
}

// Verify all recipients have tokenName and amount = '0'
for (const recipient of recipients) {
if (!recipient.tokenName) {
throw new Error('verifyEnableTokenTransaction: recipient is missing tokenName in buildParams');
}
if (recipient.amount !== '0') {
throw new Error(
`verifyEnableTokenTransaction: expected amount 0 for token enablement but got ${recipient.amount}`
);
}
}

// Verify requested token names are present in recipients
if (requestedTokenNames.length > 0) {
const recipientTokenNames = recipients.map((r) => r.tokenName);
for (const requested of requestedTokenNames) {
if (!recipientTokenNames.includes(requested)) {
throw new Error(
`verifyEnableTokenTransaction: requested token ${requested} not found in buildParams recipients`
);
}
}
}

return true;
}
}

/**
* Returns a DecryptionDelegationBuilder for constructing Zama ACL decryption
* delegation transactions.
Expand Down
Loading
Loading