@@ -86,8 +86,11 @@ import {
8686 coinFamiliesWithL1Fees ,
8787 approveMethodId ,
8888 decodeApproveCalldata ,
89+ decodeFinalizeUnwrapCalldata ,
8990 decodeNativeTransferData ,
9091 decodeTransferData ,
92+ decodeUnwrapCalldata ,
93+ decodeWrapCalldata ,
9194 ERC1155TransferBuilder ,
9295 ERC721TransferBuilder ,
9396 getBufferedByteCode ,
@@ -100,6 +103,7 @@ import {
100103 sendMultisigMethodId ,
101104 TransactionBuilder ,
102105 TransferBuilder ,
106+ UINT64_MAX ,
103107} from './lib' ;
104108import { SendCrossChainRecoveryOptions } from './types' ;
105109
@@ -392,6 +396,8 @@ interface EthTransactionParams extends TransactionParams {
392396 defiParams ?: DefiIntentParams ;
393397 /** ERC-7984 shield intent (`type: 'wrapApprove' | 'wrap'`): wrapper coin name and exact amount. */
394398 wrapParams ?: { tokenName : string ; amount : string } ;
399+ /** ERC-7984 unshield intent (`type: 'unwrap'`): wrapper coin name and plaintext amount. */
400+ unwrapParams ?: { tokenName : string ; amount : string } ;
395401}
396402
397403export interface VerifyEthTransactionOptions extends VerifyTransactionOptions {
@@ -3747,6 +3753,15 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
37473753 return true ;
37483754 }
37493755
3756+ // ERC-7984 intent legs (wrap / unwrap / finalizeUnwrap) for non-TSS (onchain multisig) wallets:
3757+ // calldata is derived server-side from the intent or the journey, so no client recipients exist.
3758+ // Content-based on the sendMultiSig args instead of the generic recipient guards.
3759+ if ( txParams ?. type === 'wrap' || txParams ?. type === 'unwrap' || txParams ?. type === 'finalizeUnwrap' ) {
3760+ await this . verifyMultisigErc7984IntentTransaction ( params , throwRecipientMismatch ) ;
3761+ await this . throwIfPrebuildCoinMismatch ( txParams , txPrebuild ) ;
3762+ return true ;
3763+ }
3764+
37503765 if ( ! txParams ?. recipients || ! txPrebuild ?. recipients || ! wallet ) {
37513766 throw new Error ( 'missing params' ) ;
37523767 }
@@ -4077,6 +4092,235 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin implem
40774092 }
40784093 }
40794094
4095+ /**
4096+ * Verifies an ERC-7984 intent leg (wrap / unwrap / finalizeUnwrap) for non-TSS (onchain multisig) wallets.
4097+ *
4098+ * Multisig shape (server-built unsigned tx):
4099+ * tx.to = wallet contract
4100+ * tx.data = sendMultiSig(wrapper, 0, <intent calldata>, expireTime, sequenceId, signature)
4101+ * txPrebuild.recipients[0] = { address: wrapper, amount: '0', data: intent calldata }
4102+ *
4103+ * Client recipients never exist for these intents: user-leg builds derive the calldata from
4104+ * wrapParams/unwrapParams, and server-leg PA rebuilds re-derive everything from the journey
4105+ * (the PA carries only the type and pendingApprovalId). Verification is therefore content-based
4106+ * on the sendMultiSig args:
4107+ * - unwrap: strict — inner target must be the registered pair's wrapper for unwrapParams.tokenName
4108+ * and the unwrap args must be self-directed (wallet base address).
4109+ * - wrap: strict when wrapParams is present (target wrapper + amount); bounded otherwise —
4110+ * inner target must be a registered ERC-7984 wrapper and the mint recipient the wallet base.
4111+ * - finalizeUnwrap: bounded — registered wrapper target and well-formed request fields.
4112+ */
4113+ private async verifyMultisigErc7984IntentTransaction (
4114+ params : VerifyEthTransactionOptions ,
4115+ throwRecipientMismatch : ( message : string , mismatchedRecipients : Recipient [ ] ) => Promise < never >
4116+ ) : Promise < void > {
4117+ const { txParams, txPrebuild } = params ;
4118+ if ( ! txParams ) {
4119+ throw new Error ( 'missing params' ) ;
4120+ }
4121+ const type = txParams . type as 'wrap' | 'unwrap' | 'finalizeUnwrap' ;
4122+ const intentParams = type === 'unwrap' ? txParams . unwrapParams : txParams . wrapParams ;
4123+ if ( ! txPrebuild ?. txHex ) {
4124+ throw new Error ( 'missing txHex in txPrebuild' ) ;
4125+ }
4126+ if ( type === 'unwrap' && ! intentParams ?. tokenName ) {
4127+ return throwRecipientMismatch ( 'unwrap transaction requires unwrapParams with tokenName' , [ ] ) ;
4128+ }
4129+
4130+ const txBuilder = this . getTransactionBuilder ( ) ;
4131+ txBuilder . from ( txPrebuild . txHex ) ;
4132+ const txJson = ( await txBuilder . build ( ) ) . toJson ( ) ;
4133+
4134+ let innerTo : string ;
4135+ let innerAmount : string ;
4136+ let innerData : string ;
4137+ const data = txJson . data || '' ;
4138+ if ( ! data . toLowerCase ( ) . startsWith ( sendMultisigMethodId . toLowerCase ( ) ) ) {
4139+ return throwRecipientMismatch (
4140+ `${ type } multisig transaction must encode the ${ type } call inside sendMultiSig` ,
4141+ [ ]
4142+ ) ;
4143+ }
4144+
4145+ try {
4146+ const decoded = decodeNativeTransferData ( data ) ;
4147+ innerTo = decoded . to ;
4148+ innerAmount = decoded . amount ;
4149+ innerData = decoded . data ;
4150+ } catch ( e ) {
4151+ return throwRecipientMismatch (
4152+ `${ type } multisig transaction failed to decode sendMultiSig calldata — ${ ( e as Error ) . message } ` ,
4153+ [ ]
4154+ ) ;
4155+ }
4156+
4157+ const mismatch = ( message : string , address = innerTo , amount = innerAmount ) : Promise < never > =>
4158+ throwRecipientMismatch ( message , [ { address : address || '' , amount : amount || '' } ] ) ;
4159+ if ( innerAmount !== '0' ) {
4160+ await mismatch ( `${ type } transaction must not carry native value inside sendMultiSig` ) ;
4161+ }
4162+
4163+ const baseAddress = this . getVerifyWalletBaseAddress ( params . wallet ) ;
4164+ if ( ! baseAddress ) {
4165+ throw new Error ( 'missing required coin specific property baseAddress' ) ;
4166+ }
4167+ const registeredWrappers = new Set < string > ( ) ;
4168+ for ( const wrappers of getErc7984WrappersByUnderlying ( ) . values ( ) ) {
4169+ for ( const wrapper of wrappers ) {
4170+ registeredWrappers . add ( wrapper ) ;
4171+ }
4172+ }
4173+ const registeredWrapperFor = ( tokenName : string ) : string | undefined => {
4174+ const staticsCoin = coins . get ( tokenName ) ;
4175+ if ( ! ( staticsCoin instanceof Erc7984Coin ) || ! staticsCoin . contractAddress ) {
4176+ return undefined ;
4177+ }
4178+ return staticsCoin . contractAddress . toLowerCase ( ) ;
4179+ } ;
4180+
4181+ switch ( type ) {
4182+ case 'wrap' : {
4183+ let decoded : { to : string ; amount : string } ;
4184+ try {
4185+ decoded = decodeWrapCalldata ( innerData || '' ) ;
4186+ } catch ( e ) {
4187+ return mismatch (
4188+ `wrap transaction must use ERC-7984 wrap(address,uint256) calldata — ${ ( e as Error ) . message } `
4189+ ) ;
4190+ }
4191+ if ( decoded . to . toLowerCase ( ) !== baseAddress . toLowerCase ( ) ) {
4192+ await mismatch ( 'wrap transaction mint recipient must be the wallet base address' , decoded . to , decoded . amount ) ;
4193+ }
4194+ if ( ! / ^ \d + $ / . test ( decoded . amount ) || BigInt ( decoded . amount ) <= 0n ) {
4195+ await mismatch ( 'wrap transaction amount must be a positive integer' , decoded . to , decoded . amount ) ;
4196+ }
4197+ if ( intentParams ?. tokenName ) {
4198+ const wrapper = registeredWrapperFor ( intentParams . tokenName ) ;
4199+ if ( ! wrapper ) {
4200+ return throwRecipientMismatch (
4201+ `wrap: '${ intentParams . tokenName } ' is not a registered ERC-7984 shield pair` ,
4202+ [ ]
4203+ ) ;
4204+ }
4205+ if ( innerTo . toLowerCase ( ) !== wrapper ) {
4206+ await mismatch ( 'wrap transaction must target the registered wrapper contract' ) ;
4207+ }
4208+ if ( decoded . amount !== intentParams . amount ) {
4209+ await mismatch ( 'wrap transaction amount does not match the requested amount' , decoded . to , decoded . amount ) ;
4210+ }
4211+ } else if ( ! registeredWrappers . has ( innerTo . toLowerCase ( ) ) ) {
4212+ await mismatch ( 'wrap transaction must target a registered ERC-7984 wrapper' ) ;
4213+ }
4214+ break ;
4215+ }
4216+ case 'unwrap' : {
4217+ const wrapper = registeredWrapperFor ( intentParams ! . tokenName ) ;
4218+ if ( ! wrapper ) {
4219+ return throwRecipientMismatch (
4220+ `unwrap: '${ intentParams ! . tokenName } ' is not a registered ERC-7984 shield pair` ,
4221+ [ ]
4222+ ) ;
4223+ }
4224+ if ( innerTo . toLowerCase ( ) !== wrapper ) {
4225+ await mismatch ( 'unwrap transaction must target the registered wrapper contract' ) ;
4226+ }
4227+ let decoded : { from : string ; to : string ; encryptedAmount : string ; inputProof : string } ;
4228+ try {
4229+ decoded = decodeUnwrapCalldata ( innerData || '' ) ;
4230+ } catch ( e ) {
4231+ return mismatch (
4232+ `unwrap transaction must use ERC-7984 unwrap(address,address,bytes32,bytes) calldata — ${
4233+ ( e as Error ) . message
4234+ } `
4235+ ) ;
4236+ }
4237+ if (
4238+ decoded . from . toLowerCase ( ) !== baseAddress . toLowerCase ( ) ||
4239+ decoded . to . toLowerCase ( ) !== baseAddress . toLowerCase ( )
4240+ ) {
4241+ await mismatch ( 'unwrap transaction must be self-directed to the wallet base address' ) ;
4242+ }
4243+ if ( ! decoded . encryptedAmount || / ^ 0 x 0 + $ / . test ( decoded . encryptedAmount ) ) {
4244+ await mismatch ( 'unwrap transaction encryptedAmount is missing or empty' ) ;
4245+ }
4246+ if ( ! decoded . inputProof || decoded . inputProof === '0x' ) {
4247+ await mismatch ( 'unwrap transaction inputProof is missing or empty' ) ;
4248+ }
4249+ break ;
4250+ }
4251+ case 'finalizeUnwrap' : {
4252+ if ( ! registeredWrappers . has ( innerTo . toLowerCase ( ) ) ) {
4253+ await mismatch ( 'finalizeUnwrap transaction must target a registered ERC-7984 wrapper' ) ;
4254+ }
4255+ let decoded : { requestId : string ; cleartextAmount : string ; decryptionProof : string } ;
4256+ try {
4257+ decoded = decodeFinalizeUnwrapCalldata ( innerData || '' ) ;
4258+ } catch ( e ) {
4259+ return mismatch (
4260+ `finalizeUnwrap transaction must use ERC-7984 finalizeUnwrap(bytes32,uint64,bytes) calldata — ${
4261+ ( e as Error ) . message
4262+ } `
4263+ ) ;
4264+ }
4265+ if ( ! decoded . requestId || / ^ 0 x 0 + $ / . test ( decoded . requestId ) ) {
4266+ await mismatch ( 'finalizeUnwrap transaction requestId is missing or empty' ) ;
4267+ }
4268+ if (
4269+ ! / ^ \d + $ / . test ( decoded . cleartextAmount ) ||
4270+ BigInt ( decoded . cleartextAmount ) <= 0n ||
4271+ BigInt ( decoded . cleartextAmount ) > UINT64_MAX
4272+ ) {
4273+ await mismatch (
4274+ 'finalizeUnwrap transaction cleartextAmount must be a positive integer within uint64' ,
4275+ innerTo ,
4276+ decoded . cleartextAmount
4277+ ) ;
4278+ }
4279+ if ( ! decoded . decryptionProof || decoded . decryptionProof === '0x' ) {
4280+ await mismatch ( 'finalizeUnwrap transaction decryptionProof is missing or empty' ) ;
4281+ }
4282+ break ;
4283+ }
4284+ default :
4285+ return throwRecipientMismatch ( `unsupported ERC-7984 intent type '${ type } '` , [ ] ) ;
4286+ }
4287+
4288+ // Cross-check the build recipients the client signs the operation hash against — they must
4289+ // mirror the sendMultiSig args encoded in txHex.
4290+ const prebuildRecipient = txPrebuild . recipients ?. [ 0 ] ;
4291+ if ( prebuildRecipient ) {
4292+ const recipientDetail = [
4293+ { address : prebuildRecipient . address , amount : prebuildRecipient . amount ?. toString ( ) ?? '' } ,
4294+ ] ;
4295+ if ( prebuildRecipient . address ?. toLowerCase ( ) !== innerTo . toLowerCase ( ) ) {
4296+ await throwRecipientMismatch (
4297+ `${ type } prebuild recipient address does not match the sendMultiSig target encoded in txHex` ,
4298+ recipientDetail
4299+ ) ;
4300+ }
4301+ let recipientData : string | undefined ;
4302+ if ( 'data' in prebuildRecipient && typeof prebuildRecipient . data === 'string' ) {
4303+ recipientData = prebuildRecipient . data ;
4304+ }
4305+ if ( recipientData && stripHexPrefix ( recipientData ) . toLowerCase ( ) !== stripHexPrefix ( innerData ) . toLowerCase ( ) ) {
4306+ await throwRecipientMismatch (
4307+ `${ type } prebuild recipient data does not match the sendMultiSig calldata encoded in txHex` ,
4308+ recipientDetail
4309+ ) ;
4310+ }
4311+ }
4312+ }
4313+
4314+ /** Wallet base (contract) address for onchain multisig verify paths; undefined when absent. */
4315+ private getVerifyWalletBaseAddress ( wallet : VerifyEthTransactionOptions [ 'wallet' ] ) : string | undefined {
4316+ if ( ! wallet ) {
4317+ return undefined ;
4318+ }
4319+ const coinSpecific = typeof wallet . coinSpecific === 'function' ? wallet . coinSpecific ( ) : wallet . coinSpecific ;
4320+ const ethCoinSpecific = coinSpecific as { baseAddress ?: string ; rootAddress ?: string } | undefined ;
4321+ return ethCoinSpecific ?. baseAddress ?? ethCoinSpecific ?. rootAddress ;
4322+ }
4323+
40804324 private isETHAddress ( address : string ) : boolean {
40814325 return ! ! address . match ( / 0 x [ a - f A - F 0 - 9 ] { 40 } / ) ;
40824326 }
0 commit comments