Skip to main content

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:
FishnetWallet.sol:35-44
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

wallet
address
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.
chainId
uint64
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.
nonce
uint256
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.
expiry
uint48
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).
target
address
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.
value
uint256
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.
calldataHash
bytes32
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.
policyHash
bytes32
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:
FishnetWallet.sol:29-33
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:
FishnetWallet.sol:78-88
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:
  1. Validate signature is exactly 65 bytes
  2. Recompute struct hash from permit fields
  3. Recompute EIP-712 digest
  4. Extract r, s, v from signature using assembly
  5. Use ecrecover to get the signer’s address
  6. 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 = [0u8; 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 = [0u8; 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 = [0u8; 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 = [0u8; 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 = [0u8; 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 = [0u8; 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 5: Signature Format

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

1

Define the Action

const action = {
  target: "0x...",  // Uniswap Router
  value: 0,
  data: "0x...",    // Encoded swap call
};
2

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"),
};
3

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();
4

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

Build docs developers (and LLMs) love