Overview
Fishnet uses EIP-712 typed structured data signing to create cryptographically secure permits. Each permit authorizes a specific on-chain action with precise parameters.
Permit Structure
The FishnetPermit struct defines the data that gets signed:
struct FishnetPermit {
address wallet; // The FishnetWallet contract address
uint64 chainId; // Chain ID (prevents replay attacks across chains)
uint256 nonce; // Unique nonce (prevents replay attacks)
uint48 expiry; // Expiration timestamp (prevents stale permits)
address target; // Target contract to call
uint256 value; // ETH value to send
bytes32 calldataHash; // Hash of the exact calldata
bytes32 policyHash; // Hash of the policy that authorized this action
}
Field Breakdown
Purpose : Binds the permit to a specific FishnetWallet instance.Why : Prevents permit reuse across different wallets.Value : Always address(this) of the target wallet contract.
Purpose : Prevents cross-chain replay attacks.Why : A permit signed for Base Sepolia (84532) cannot be replayed on Base Mainnet (8453).Type : uint64 instead of uint256 to save gas while supporting all realistic chain IDs.
Purpose : Makes each permit unique, even if all other parameters are identical.Why : Prevents replay attacks. Each nonce can only be used once.Best Practice : Use random nonces (not sequential) to prevent front-running.
Purpose : Automatic permit expiration.Why : Limits the time window for permit execution. Prevents stale permits from being executed.Type : uint48 (max value: 281474976710655, ~year 8921). Saves gas vs. uint256.Validation : block.timestamp <= permit.expiry (inclusive).
Purpose : Specifies which contract the wallet will call.Example : Uniswap Router, Aave Pool, etc.Why : Prevents the relayer from redirecting the call to a malicious contract.
Purpose : Amount of ETH to send with the call.Why : Commits to the exact ETH amount, preventing over-spending.Value : 0 for most DeFi calls (token approvals, swaps), non-zero for ETH transfers.
Purpose : Cryptographic commitment to the exact transaction data.Computation : keccak256(data) where data is the full calldata.Why : Prevents any modification of function calls or parameters.
Purpose : Links the permit to the Fishnet policy that approved it.Why : Creates an audit trail. Emitted in events for off-chain analysis.Example : keccak256("swap-policy-v1")
EIP-712 Type Definition
Permit Typehash
The permit type string defines the structure for EIP-712:
bytes32 internal constant PERMIT_TYPEHASH = keccak256 (
"FishnetPermit(address wallet,uint64 chainId,uint256 nonce,"
"uint48 expiry,address target,uint256 value,"
"bytes32 calldataHash,bytes32 policyHash)"
);
Computed Value : 0xc9b0b9ae2da684ebdabf410d61b7a56935bff0fa4a926059abf894606ed05965
Note the use of uint64 chainId and uint48 expiry instead of uint256. This matches the struct definition and saves gas.
Domain Separator
The EIP-712 domain separator uniquely identifies the contract and chain:
function _computeDomainSeparator () internal view returns ( bytes32 ) {
return keccak256 (
abi . encode (
keccak256 ( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ),
keccak256 ( "Fishnet" ),
keccak256 ( "1" ),
block .chainid,
address ( this )
)
);
}
Components :
Domain type hash : keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
Name : "Fishnet"
Version : "1"
Chain ID : block.chainid
Verifying contract : address(this)
The domain name is "Fishnet", not "FishnetPermit" or "FishnetWallet". This is critical for signature verification.
Signing Process
Step 1: Compute Struct Hash
Encode all permit fields:
bytes32 structHash = keccak256 (
abi . encode (
PERMIT_TYPEHASH,
permit.wallet,
permit.chainId, // uint64 → padded to 32 bytes
permit.nonce,
permit.expiry, // uint48 → padded to 32 bytes
permit.target,
permit.value,
permit.calldataHash,
permit.policyHash
)
);
Solidity’s abi.encode() automatically left-pads uint64 and uint48 to 32 bytes. The Rust backend manually replicates this padding for compatibility.
Step 2: Compute EIP-712 Digest
Combine the domain separator and struct hash:
bytes32 digest = keccak256 (
abi . encodePacked (
"\x19\x01" , // EIP-712 prefix
DOMAIN_SEPARATOR (),
structHash
)
);
This is the final 32-byte hash that gets signed.
Step 3: Sign the Digest
The Fishnet backend signs the digest using ECDSA:
crates/server/src/signer.rs:230-238
let ( signature , recovery_id ) : ( k256 :: ecdsa :: Signature , RecoveryId ) = self
. signing_key
. sign_prehash ( & hash )
. map_err ( | e | SignerError :: SigningFailed ( e . to_string ())) ? ;
let mut sig_bytes = Vec :: with_capacity ( 65 );
sig_bytes . extend_from_slice ( & signature . to_bytes ()); // r || s (64 bytes)
sig_bytes . push ( recovery_id . to_byte () + 27 ); // v (1 byte)
Ok ( sig_bytes )
Output : 65-byte signature in r || s || v format:
r : bytes 0-31
s : bytes 32-63
v : byte 64 (value: 27 or 28)
Verification Process
The contract verifies signatures in _verifySignature():
FishnetWallet.sol:114-150
function _verifySignature (
FishnetPermit calldata permit ,
bytes calldata signature
) internal view returns ( bool ) {
if (signature.length != 65 ) revert InvalidSignatureLength ();
// Step 1: Compute struct hash
bytes32 structHash = keccak256 (
abi . encode (
PERMIT_TYPEHASH,
permit.wallet,
permit.chainId,
permit.nonce,
permit.expiry,
permit.target,
permit.value,
permit.calldataHash,
permit.policyHash
)
);
// Step 2: Compute EIP-712 digest
bytes32 digest = keccak256 (
abi . encodePacked ( "\x19\x01" , DOMAIN_SEPARATOR (), structHash)
);
// Step 3: Extract r, s, v from signature
bytes32 r;
bytes32 s;
uint8 v;
assembly {
let ptr := signature.offset
r := calldataload (ptr)
s := calldataload ( add (ptr, 32 ))
v := byte ( 0 , calldataload ( add (ptr, 64 )))
}
// Step 4: Recover signer address
address recoveredSigner = ecrecover (digest, v, r, s);
return recoveredSigner == fishnetSigner;
}
Steps :
Validate signature is exactly 65 bytes
Recompute struct hash from permit fields
Recompute EIP-712 digest
Extract r, s, v from signature using assembly
Use ecrecover to get the signer’s address
Compare recovered address to fishnetSigner
Rust Implementation
The Rust backend (crates/server/src/signer.rs) mirrors the Solidity implementation exactly.
Domain Separator (Rust)
crates/server/src/signer.rs:129-153
let domain_type_hash = Keccak256 :: digest (
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
let name_hash = Keccak256 :: digest ( b"Fishnet" );
let version_hash = Keccak256 :: digest ( b"1" );
let mut domain_data = Vec :: new ();
domain_data . extend_from_slice ( & domain_type_hash );
domain_data . extend_from_slice ( & name_hash );
domain_data . extend_from_slice ( & version_hash );
// Chain ID: left-pad to 32 bytes (big-endian)
let mut chain_id_bytes = [ 0 u8 ; 32 ];
chain_id_bytes [ 24 .. ] . copy_from_slice ( & permit . chain_id . to_be_bytes ());
domain_data . extend_from_slice ( & chain_id_bytes );
// Verifying contract: left-pad address to 32 bytes
let vc_bytes = hex :: decode ( permit . verifying_contract . strip_prefix ( "0x" ) . unwrap_or ( & permit . verifying_contract)) . unwrap_or_default ();
let mut vc_padded = [ 0 u8 ; 32 ];
if vc_bytes . len () <= 32 {
vc_padded [ 32 - vc_bytes . len () .. ] . copy_from_slice ( & vc_bytes );
}
domain_data . extend_from_slice ( & vc_padded );
let domain_separator = Keccak256 :: digest ( & domain_data );
Struct Hash (Rust)
crates/server/src/signer.rs:155-208
let permit_type_hash = Keccak256 :: digest (
b"FishnetPermit(address wallet,uint64 chainId,uint256 nonce,uint48 expiry,address target,uint256 value,bytes32 calldataHash,bytes32 policyHash)"
);
let mut struct_data = Vec :: new ();
struct_data . extend_from_slice ( & permit_type_hash );
// Wallet address (left-pad to 32 bytes)
let wallet_bytes = hex :: decode ( permit . wallet . strip_prefix ( "0x" ) . unwrap_or ( & permit . wallet)) . unwrap_or_default ();
let mut wallet_padded = [ 0 u8 ; 32 ];
if wallet_bytes . len () <= 32 {
wallet_padded [ 32 - wallet_bytes . len () .. ] . copy_from_slice ( & wallet_bytes );
}
struct_data . extend_from_slice ( & wallet_padded );
// Chain ID (same as domain separator)
struct_data . extend_from_slice ( & chain_id_bytes );
// Nonce (u64 → 32 bytes, big-endian)
let mut nonce_bytes = [ 0 u8 ; 32 ];
nonce_bytes [ 24 .. ] . copy_from_slice ( & permit . nonce . to_be_bytes ());
struct_data . extend_from_slice ( & nonce_bytes );
// Expiry (u64 → 32 bytes, big-endian)
let mut expiry_bytes = [ 0 u8 ; 32 ];
expiry_bytes [ 24 .. ] . copy_from_slice ( & permit . expiry . to_be_bytes ());
struct_data . extend_from_slice ( & expiry_bytes );
// Target address, value, calldataHash, policyHash...
// (see signer.rs:179-208 for full implementation)
Final Digest (Rust)
crates/server/src/signer.rs:210-221
let struct_hash = Keccak256 :: digest ( & struct_data );
let mut final_data = Vec :: with_capacity ( 66 );
final_data . push ( 0x19 );
final_data . push ( 0x01 );
final_data . extend_from_slice ( & domain_separator );
final_data . extend_from_slice ( & struct_hash );
let result = Keccak256 :: digest ( & final_data );
let mut hash = [ 0 u8 ; 32 ];
hash . copy_from_slice ( & result );
hash
Compatibility Testing
The EIP712Compatibility.t.sol test suite proves that the Rust and Solidity implementations are 100% compatible:
Test 1: Typehash Match
test/EIP712Compatibility.t.sol:36-51
function test_permitTypehashMatchesSolidity () public view {
bytes32 fromRawString = keccak256 (
"FishnetPermit(address wallet,uint64 chainId,uint256 nonce,"
"uint48 expiry,address target,uint256 value,"
"bytes32 calldataHash,bytes32 policyHash)"
);
assertEq (fromRawString, EXPECTED_PERMIT_TYPEHASH);
}
Test 2: Domain Separator Encoding
test/EIP712Compatibility.t.sol:57-86
function test_domainSeparatorEncoding () public view {
bytes32 manualDomainSep = keccak256 (
abi . encode (
EXPECTED_DOMAIN_TYPEHASH,
keccak256 ( "Fishnet" ),
keccak256 ( "1" ),
block .chainid,
address (wallet)
)
);
assertEq (manualDomainSep, wallet. DOMAIN_SEPARATOR ());
}
Test 3: Struct Hash Encoding
test/EIP712Compatibility.t.sol:92-140
function test_structHashEncoding () public view {
// Verifies that abi.encode(uint64) and abi.encode(uint48)
// produce the same 32-byte left-padded values as Rust's manual padding
bytes32 structHash = keccak256 (
abi . encode (
EXPECTED_PERMIT_TYPEHASH,
walletAddr,
chainId, // uint64 → auto-padded
nonce,
expiry, // uint48 → auto-padded
target,
value,
calldataHash,
policyHash
)
);
// ...
}
Test 4: End-to-End Signing
test/EIP712Compatibility.t.sol:146-182
function test_rustSignerEndToEnd () public {
// Signs a permit using the exact Rust code path
// and verifies the contract accepts it
bytes32 digest = _computeDigest (...);
( uint8 v, bytes32 r, bytes32 s) = vm. sign (signerPrivateKey, digest);
bytes memory signature = abi . encodePacked (r, s, v);
wallet. execute ( address (receiver), 0 , callData, permit, signature);
assertTrue (wallet. usedNonces ( 1 ));
}
test/EIP712Compatibility.t.sol:225-258
function test_signatureFormatRSV () public view {
// Verifies the contract's assembly unpacking matches Rust's r || s || v format
bytes memory packed = abi . encodePacked (r, s, v);
assertEq (packed.length, 65 );
// Extract using assembly (same as contract)
bytes32 extractedR;
bytes32 extractedS;
uint8 extractedV;
assembly {
let ptr := add (packed, 32 )
extractedR := mload (ptr)
extractedS := mload ( add (ptr, 32 ))
extractedV := byte ( 0 , mload ( add (ptr, 64 )))
}
assertEq (extractedR, r);
assertEq (extractedS, s);
assertEq (extractedV, v);
}
Run these tests:
forge test --match-contract EIP712Compatibility -vvv
Example: Creating a Permit
Define the Action
const action = {
target: "0x..." , // Uniswap Router
value: 0 ,
data: "0x..." , // Encoded swap call
};
Build the Permit
const permit = {
wallet: walletAddress ,
chainId: 84532 , // Base Sepolia
nonce: randomNonce (),
expiry: Math . floor ( Date . now () / 1000 ) + 600 , // 10 minutes
target: action . target ,
value: action . value ,
calldataHash: keccak256 ( action . data ),
policyHash: keccak256 ( "swap-policy-v1" ),
};
Send to Fishnet Backend
const response = await fetch ( "http://localhost:3001/api/onchain/sign" , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({ permit }),
});
const { signature } = await response . json ();
Execute On-Chain
await wallet . execute (
action . target ,
action . value ,
action . data ,
permit ,
signature
);
Security Best Practices
Random Nonces Use cryptographically random nonces, not sequential counters. Prevents front-running.
Short Expiry Keep expiry times short (5-10 minutes). Limits the window for stale permits.
Validate Inputs The Rust signer validates all permit fields before signing (see crates/server/src/signer.rs:41-62).
Policy Tracking Always set a meaningful policyHash for audit trails.
Next Steps
Test Compatibility Run the EIP712Compatibility test suite
Deploy Contracts Deploy your own FishnetWallet instance