SMART CONTRACTS

TabEscrowRouter

Holds funds in escrow until the recipient claims, or the sender refunds after a deadline. Two claim paths: Handle (recipient must own a Tab handle, executor releases) and Code (anyone with the secret can claim — magic share links / claim-by-link). Backs the OpenTab product.

Surface

// --- create ---
function createEscrow(
    string calldata handleSlug,
    uint256 amount,
    uint256 deadline,
    uint256 senderNonce
) external returns (bytes32 id);

function createEscrowByCode(
    bytes32 codeHash,        // keccak256(bytes(secret))
    uint256 amount,
    uint256 deadline,
    uint256 senderNonce
) external returns (bytes32 id);

// --- claim ---
function claim(
    bytes32 id,
    string calldata handleSlug,
    address recipient
) external onlyExecutor;     // handle path

function claimByCode(
    bytes32 id,
    bytes calldata secret
) external;                  // permissionless: caller receives

function claimByCodeFor(
    bytes32 id,
    address recipient,
    bytes calldata secret
) external onlyExecutor;     // sponsored: caller pays gas, recipient receives

// --- refund (always permissionless after deadline) ---
function refund(bytes32 id) external;

// --- views ---
function escrows(bytes32 id) external view returns (
    address  sender,
    bytes32  commitment,     // handleHash OR codeHash
    uint256  amount,
    uint256  deadline,
    uint16   feeBps,         // snapshotted at create time
    uint8    claimType,      // 0=Handle 1=Code
    uint8    status          // 0=None 1=Pending 2=Claimed 3=Refunded
);
function escrowFee(bytes32 id) external view returns (uint256 fee, uint256 net);
function computeHandleId(address sender, string calldata handleSlug, uint256 senderNonce)
    external pure returns (bytes32);
function computeCodeId(address sender, bytes32 codeHash, uint256 senderNonce)
    external pure returns (bytes32);

ID derivation

id = keccak256(abi.encode(sender, commitment, senderNonce, claimType)). The claimType byte is part of the id, so a handle and code escrow with the same commitment + nonce will never collide. Off-chain code can compute the id without a chain call.

Trust model

  • Custody is on-chain. Funds sit in the router until claim* or refund moves them.
  • Handle path.Tab's executor allowlist is the only caller permitted to release a handle escrow — and only to the address that owns the committed handle. Compromise of one executor key doesn't let it pay itself.
  • Code path. Anyone presenting the preimage of the committed codeHash can call claimByCode and receive the funds in their own wallet — single-use, the escrow flips to Claimed on the first valid reveal. Possessing the secret IS the authorization, so the sender must treat the share link like cash.
  • Sponsored code claims. claimByCodeForlets an executor pay gas on behalf of a recipient — used for "click link → instant funds" flows where the recipient has no native gas yet.
  • Fee. Snapshotted on the escrow at create time (default 1%). Later admin fee changes never retroactively touch an open escrow.
  • Pause. Owner can halt NEW escrow creation in an emergency. Existing claims + refunds always work — user funds can never be trapped.

Events

event EscrowCreated(bytes32 indexed id, address indexed sender,
                    bytes32 indexed commitment, uint8 claimType,
                    string handleSlug,    // empty for code escrows
                    uint256 amount, uint256 deadline, uint16 feeBps);
event EscrowClaimed(bytes32 indexed id, address indexed recipient,
                    uint256 net, uint256 fee);
event EscrowRefunded(bytes32 indexed id, address indexed sender,
                     uint256 amount);

Source + tests

Tab/src/TabEscrowRouter.sol with a Foundry test suite covering the executor allowlist, the deadline arithmetic, claim-by-code reveal logic, fee snapshotting, and the full create / claim / refund state machine. See the contracts overview for the deployment table and audit scope.