|
1 | 1 | /** |
2 | 2 | * @prettier |
3 | 3 | */ |
4 | | -import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core'; |
| 4 | +import { BitGoBase, CoinConstructor, MPCAlgorithm, NamedCoinConstructor, TokenEnablementConfig } from '@bitgo/sdk-core'; |
5 | 5 |
|
6 | | -import { coins, Erc7984TokenConfig, tokens } from '@bitgo/statics'; |
7 | | -import { CoinNames, DecryptionDelegationBuilder } from '@bitgo/abstract-eth'; |
| 6 | +import { coins, Erc7984TokenConfig, EthereumNetwork, tokens } from '@bitgo/statics'; |
| 7 | +import { |
| 8 | + CoinNames, |
| 9 | + DecryptionDelegationBuilder, |
| 10 | + decodeTokenAddressesFromDelegationCalldata, |
| 11 | + VerifyEthTransactionOptions, |
| 12 | + aclMulticallMethodId, |
| 13 | + callFromParentMethodId, |
| 14 | +} from '@bitgo/abstract-eth'; |
8 | 15 |
|
9 | 16 | import { Eth } from './eth'; |
10 | 17 | import { TransactionBuilder } from './lib'; |
@@ -114,6 +121,148 @@ export class Erc7984Token extends Eth { |
114 | 121 | return new TransactionBuilder(coins.get(this.getBaseChain())); |
115 | 122 | } |
116 | 123 |
|
| 124 | + /** @inheritDoc */ |
| 125 | + getTokenEnablementConfig(): TokenEnablementConfig { |
| 126 | + return { |
| 127 | + requiresTokenEnablement: true, |
| 128 | + supportsMultipleTokenEnablements: true, |
| 129 | + }; |
| 130 | + } |
| 131 | + |
| 132 | + /** @inheritDoc */ |
| 133 | + async verifyTransaction(params: VerifyEthTransactionOptions): Promise<boolean> { |
| 134 | + if (params.txParams?.type === 'enabletoken') { |
| 135 | + return this.verifyEnableTokenTransaction(params); |
| 136 | + } |
| 137 | + return super.verifyTransaction(params); |
| 138 | + } |
| 139 | + |
| 140 | + /** |
| 141 | + * Verifies a token enablement transaction for ERC-7984 decryption delegation. |
| 142 | + * |
| 143 | + * TSS path: decodes the raw tx and verifies it calls the ACL contract with |
| 144 | + * calldata that covers all requested token contract addresses. |
| 145 | + * |
| 146 | + * Multisig path: verifies the buildParams recipients carry the correct tokenNames |
| 147 | + * and zero amounts. |
| 148 | + */ |
| 149 | + private async verifyEnableTokenTransaction(params: VerifyEthTransactionOptions): Promise<boolean> { |
| 150 | + const { txParams, txPrebuild, walletType } = params; |
| 151 | + |
| 152 | + if (walletType === 'tss') { |
| 153 | + // TSS path: full raw-tx decode |
| 154 | + const enableTokens = txParams.enableTokens; |
| 155 | + if (!enableTokens || enableTokens.length === 0) { |
| 156 | + throw new Error('verifyEnableTokenTransaction: enableTokens must be non-empty for TSS path'); |
| 157 | + } |
| 158 | + if (!txPrebuild.txHex) { |
| 159 | + throw new Error('verifyEnableTokenTransaction: missing txHex in txPrebuild'); |
| 160 | + } |
| 161 | + |
| 162 | + // Resolve requested token names → contract addresses |
| 163 | + const requestedAddresses = enableTokens.map((t) => { |
| 164 | + const tokenCoin = this.bitgo.coin(t.name) as Erc7984Token; |
| 165 | + return tokenCoin.tokenContractAddress.toLowerCase(); |
| 166 | + }); |
| 167 | + |
| 168 | + // Parse the raw transaction |
| 169 | + const txBuilder = this.getTransactionBuilder(); |
| 170 | + txBuilder.from(txPrebuild.txHex); |
| 171 | + const tx = await txBuilder.build(); |
| 172 | + const txJson = tx.toJson(); |
| 173 | + |
| 174 | + // Verify transaction targets the correct contract based on calldata shape |
| 175 | + const network = this.getNetwork() as EthereumNetwork; |
| 176 | + const aclContractAddress = network?.zamaAclContractAddress; |
| 177 | + if (!aclContractAddress) { |
| 178 | + throw new Error('verifyEnableTokenTransaction: zamaAclContractAddress not configured for this network'); |
| 179 | + } |
| 180 | + if (!txJson.to) { |
| 181 | + throw new Error('verifyEnableTokenTransaction: transaction is missing recipient address'); |
| 182 | + } |
| 183 | + |
| 184 | + // Inspect calldata method ID to distinguish root wallet from forwarder wallet: |
| 185 | + // aclMulticallMethodId → root wallet: to = ACL contract directly |
| 186 | + // callFromParentMethodId → forwarder wallet: to = forwarder, ACL address is inside calldata |
| 187 | + const calldataMethodId = txJson.data.slice(0, 10); |
| 188 | + if (calldataMethodId === aclMulticallMethodId) { |
| 189 | + // Root wallet (base address): tx calls the ACL contract directly |
| 190 | + if (txJson.to.toLowerCase() !== aclContractAddress.toLowerCase()) { |
| 191 | + throw new Error( |
| 192 | + `verifyEnableTokenTransaction: transaction target ${txJson.to} does not match ACL contract ${aclContractAddress}` |
| 193 | + ); |
| 194 | + } |
| 195 | + } else if (calldataMethodId === callFromParentMethodId) { |
| 196 | + // Forwarder wallet: tx calls the forwarder, which calls the ACL via callFromParent. |
| 197 | + // The forwarder address is wallet-specific and cannot be statically verified here; |
| 198 | + // token address correctness is still verified below via calldata decoding. |
| 199 | + } else { |
| 200 | + throw new Error( |
| 201 | + `verifyEnableTokenTransaction: unrecognised calldata method ID ${calldataMethodId}; expected multicall or callFromParent` |
| 202 | + ); |
| 203 | + } |
| 204 | + |
| 205 | + // Verify value is 0 |
| 206 | + if (txJson.value !== '0') { |
| 207 | + throw new Error(`verifyEnableTokenTransaction: expected transaction value 0 but got ${txJson.value}`); |
| 208 | + } |
| 209 | + |
| 210 | + // Decode token addresses from calldata and verify all requested tokens are present |
| 211 | + const decodedAddresses = decodeTokenAddressesFromDelegationCalldata(txJson.data); |
| 212 | + for (const requested of requestedAddresses) { |
| 213 | + if (!decodedAddresses.includes(requested)) { |
| 214 | + throw new Error( |
| 215 | + `verifyEnableTokenTransaction: requested token ${requested} not found in delegation calldata` |
| 216 | + ); |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + return true; |
| 221 | + } else { |
| 222 | + // Multisig path: buildParams-level check |
| 223 | + const recipients = txPrebuild.buildParams?.recipients as |
| 224 | + | Array<{ tokenName?: string; amount?: string }> |
| 225 | + | undefined; |
| 226 | + if (!recipients || recipients.length === 0) { |
| 227 | + throw new Error('verifyEnableTokenTransaction: missing buildParams.recipients for multisig path'); |
| 228 | + } |
| 229 | + |
| 230 | + // Determine requested token names from txParams |
| 231 | + const requestedTokenNames: string[] = []; |
| 232 | + if (txParams.enableTokens && txParams.enableTokens.length > 0) { |
| 233 | + requestedTokenNames.push(...txParams.enableTokens.map((t) => t.name)); |
| 234 | + } else if (txParams.recipients && txParams.recipients.length > 0) { |
| 235 | + requestedTokenNames.push(...txParams.recipients.map((r: any) => r.tokenName).filter(Boolean)); |
| 236 | + } |
| 237 | + |
| 238 | + // Verify all recipients have tokenName and amount = '0' |
| 239 | + for (const recipient of recipients) { |
| 240 | + if (!recipient.tokenName) { |
| 241 | + throw new Error('verifyEnableTokenTransaction: recipient is missing tokenName in buildParams'); |
| 242 | + } |
| 243 | + if (recipient.amount !== '0') { |
| 244 | + throw new Error( |
| 245 | + `verifyEnableTokenTransaction: expected amount 0 for token enablement but got ${recipient.amount}` |
| 246 | + ); |
| 247 | + } |
| 248 | + } |
| 249 | + |
| 250 | + // Verify requested token names are present in recipients |
| 251 | + if (requestedTokenNames.length > 0) { |
| 252 | + const recipientTokenNames = recipients.map((r) => r.tokenName); |
| 253 | + for (const requested of requestedTokenNames) { |
| 254 | + if (!recipientTokenNames.includes(requested)) { |
| 255 | + throw new Error( |
| 256 | + `verifyEnableTokenTransaction: requested token ${requested} not found in buildParams recipients` |
| 257 | + ); |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + return true; |
| 263 | + } |
| 264 | + } |
| 265 | + |
117 | 266 | /** |
118 | 267 | * Returns a DecryptionDelegationBuilder for constructing Zama ACL decryption |
119 | 268 | * delegation transactions. |
|
0 commit comments