CompactX

How CompactX implements cross-chain intent by token swaps using The Compact as an escrow primitive

CONTENT

This is the PoC of The Compact by Uniswap, so it is not a production service. However, it is deployed on Mainnet, Optimism, Base, and Unichain (as well as their respective testnets), so we can try out cross-chain intents (around token swaps).

These days, there are so many chains, which leads to liquidity fragmentation. This drives the need for Chain Abstraction. Together with Account Abstraction, the goal is to treat multiple chains as if they were a single unified chain. The Compact presents developers with an unopinionated primitive in the form of an escrow contract, enabling them to build on top of it while addressing these challenges. CompactX is a PoC demonstrating that potential. While it does not perform specific AA actions and only shows token movement via token swaps, the system is being developed with extensibility to perform actions like ERC-4337 on the fill chain. Combined with AA, a true Chain Abstraction becomes achievable.

Account Abstraction allows users to submit transactions without holding native tokens to pay for gas fees. If a user only holds USDC, they would normally need to swap it for native tokens, but they lack the tokens to pay for that swap’s gas fees. Instead, a paymaster accepts USDC as payment and submits the userOp on their behalf. The problem is that msg.sender cannot be the user’s EOA. However, with EIP-7702, even when another EOA executes the transaction, msg.sender can now be the user’s account.

Notable players in the Chain Abstraction space include ZeroDev, Rhinestone, and others.

The Compact

This post aims to walk through the overall architecture of CompactX. The Compact, which serves as the core escrow mechanism, is only briefly summarized here — since it is the most critical component, it’s better to read the codebase and TrustModel directly. Below is a simplified diagram and terminology reference based on my understanding.

When examining The Compact in the context of cross-chain operations, terminology can get confusing: compact, commit, commitment, and intent, among others.

  • intent: ERC-7683, [4]
  • commit: create a Compact, [2]
  • compact: Personally, I think of a Compact as equivalent to an intent. Like ERC-7683, a compact can be registered directly in The Compact contract, or signed off-chain by the sponsor in a gasless manner.

    // Message signed by the sponsor that specifies the conditions under which their
    // tokens can be claimed; the specified arbiter verifies that those conditions
    // have been met and specifies a set of beneficiaries that will receive up to the
    // specified amount of tokens.
    struct Compact {
        address arbiter; // The account tasked with verifying and submitting the claim.
        address sponsor; // The account to source the tokens from.
        uint256 nonce; // A parameter to enforce replay protection, scoped to allocator.
        uint256 expires; // The time at which the claim expires.
        bytes12 lockTag; // A tag representing the allocator, reset period, and scope.
        address token; // The locked token, or address(0) for native tokens.
        uint256 amount; // The amount of ERC6909 tokens to commit from the lock.
        // Optional witness may follow.
    }
    
    the-compact-v1/src/types/EIP712Types.sol
  • commitment: The code speaks for itself.

    // A batch or multichain compact can contain commitments from multiple resource locks.
    struct Lock {
        bytes12 lockTag; // A tag representing the allocator, reset period, and scope.
        address token; // The locked token, or address(0) for native tokens.
        uint256 amount; // The maximum committed amount of tokens.
    }
    
    // Message signed by the sponsor that specifies the conditions under which a set of
    // tokens, each sharing an allocator, can be claimed; the specified arbiter verifies
    // that those conditions have been met and specifies a set of beneficiaries that will
    // receive up to the specified amounts of each token.
    struct BatchCompact {
        address arbiter; // The account tasked with verifying and submitting the claim.
        address sponsor; // The account to source the tokens from.
        uint256 nonce; // A parameter to enforce replay protection, scoped to allocator.
        uint256 expires; // The time at which the claim expires.
        Lock[] commitments; // The committed locks with lock tags, tokens, & amounts.
        // Optional witness may follow.
    }
    
    the-compact-v1/src/types/EIP712Types.sol

Key Actors

There are roughly three key actors in The Compact: Sponsor, Claimant, and Allocator. The system also includes periphery contracts such as the arbiter.

When a Sponsor deposits into The Compact, resource locks are created and ERC-6909 tokens with the corresponding ID are minted. Transferring tokens always requires signatures from both the allocator and the sponsor — even when the sponsor transfers their own tokens. Looking at how transfers work, you can either literally transfer the ERC-6909 tokens or withdraw the underlying asset.

A transfer is, in a sense, a form of claim — which is why the AllocatedTransfer struct used for transfers is defined in the-compact/src/types/Claims.sol. Only the arbiter (either a contract or EOA, as designated by the sponsor) can be msg.sender when claiming. For example, if a sponsor wants to transfer their own ERC-6909 tokens, they designate themselves as the arbiter and submit the transaction — effectively a same-chain self-claim. In a cross-chain swap scenario, the sponsor typically designates a contract agreed upon with the filler as the arbiter. If the arbiter is not one the filler has agreed on, the filler must verify and evaluate it themselves.

In CompactX, the Arbiter is a contract. Via Hyperlane or Wormhole, arbiters deployed on each chain can only communicate with each other through cross-chain messages, enabling atomic execution. When using Hyperlane, the filler triggers a fill through the arbiter contract on the fill chain, which dispatches a cross-chain message. On the fill chain, this is called the Tribunal. In the PoC at the time, the arbiter and Tribunal were combined into a single contract deployed on each chain. The Hyperlane relayer then executes the claim transaction via the mailbox on the claim chain. The arbiter checks that msg.sender is the mailbox. This ensures the entire intent flow proceeds atomically in fill→claim order — it is impossible to claim before filling.

The Allocator prevents double spending and underspending. Any transfer of locked funds requires the allocator’s signature. The allocator must verify that there are sufficient funds and that forcedWithdrawal is not active before signing. There are off-chain, hybrid, and on-chain allocators — if the allocator signs a compact for a lock that doesn’t have sufficient funds, the filler could incur a loss.

Each party is responsible for performing their own verifications properly; the Trust Model is described in the documentation.

Claim

I consider the claim mechanism to be the heart of The Compact. There are several claim variants — batchClaim(), multichainClaim, exogenousClaim(), etc. — but let’s focus on claim().

struct Component {
    uint256 claimant; // The lockTag + recipient of the transfer or withdrawal.
    uint256 amount; // The amount of tokens to transfer or withdraw.
}
the-compact-v1/src/types/Components.sol
struct Claim {
    bytes allocatorData; // Authorization from the allocator.
    bytes sponsorSignature; // Authorization from the sponsor.
    address sponsor; // The account to source the tokens from.
    uint256 nonce; // A parameter to enforce replay protection, scoped to allocator.
    uint256 expires; // The time at which the claim expires.
    bytes32 witness; // Hash of the witness data.
    string witnessTypestring; // Witness typestring appended to existing typestring.
    uint256 id; // The token ID of the ERC6909 token to allocate.
    uint256 allocatedAmount; // The original allocated amount of ERC6909 tokens.
    Component[] claimants; // The claim recipients and amounts; specified by the arbiter.
}
the-compact-v1/src/types/Claims.sol
contract ClaimProcessor is ITheCompactClaims, ClaimProcessorLogic {
    /// @inheritdoc ITheCompactClaims
    function claim(Claim calldata claimPayload) external returns (bytes32 claimHash) {
        return _processClaim(claimPayload);
    }
the-compact-v1/src/lib/ClaimProcessor.sol
contract ClaimProcessorLogic is ConstructorLogic {
    using ComponentLib for bytes32;
    . . .
    using ClaimHashLib for Claim;
    . . .
    ///// 1. Claims /////
    function _processClaim(Claim calldata claimPayload) internal returns (bytes32 claimHash) {
        // Set the reentrancy guard.
        _setReentrancyGuard();

        bytes32 typehash;
        (claimHash, typehash) = claimPayload.toClaimHashAndTypehash();
        claimHash.processClaimWithComponents(claimPayload.asRawPtr(), 0, typehash, _domainSeparator());

        // Clear the reentrancy guard.
        _clearReentrancyGuard();
    }
the-compact-v1/src/lib/ClaimProcessorLogic.sol
  • (claimHash, typehash) = claimPayload.toClaimHashAndTypehash();

    library ClaimHashLib {
        . . .
        using HashLib for Claim;
        . . .
        ///// CATEGORY 2: Claim hashes & type hashes /////
        function toClaimHashAndTypehash(Claim calldata claim) internal view returns (bytes32 claimHash, bytes32 typehash) {
            return claim.toClaimHash();
        }
    
    the-compact-v1/src/lib/ClaimHashLib.sol

    Looking at the Compact struct, you’ll notice the comment // Optional witness may follow. In CompactX, the Mandate struct — which defines the conditions that must be fulfilled on the fill chain (token, amount, chainId, etc.) — is used as the witness. Since it is a custom struct, this process computes the typehash for it, and you can see the claimHash being derived from the Compact struct.

    library HashLib {
        . . .
        /**
         * @notice Internal view function for deriving the EIP-712 message hash for
          * a claim with or without a witness.
          * @param claimPointer Pointer to the claim location in calldata.
          * @return claimHash   The EIP-712 compliant message hash.
          * @return typehash    The EIP-712 typehash.
          */
        function toClaimHash(Claim calldata claimPointer) internal view returns (bytes32 claimHash, bytes32 typehash) {
            assembly ("memory-safe") {
                for { } 1 { } {
                    // Retrieve the free memory pointer; memory will be left dirtied.
                    let m := mload(0x40)
    
                    // Derive the pointer to the witness typestring.
                    let witnessTypestringPtr := add(claimPointer, calldataload(add(claimPointer, 0xc0)))
    
                    // Retrieve the length of the witness typestring.
                    let witnessTypestringLength := calldataload(witnessTypestringPtr)
    
                    if iszero(witnessTypestringLength) {
                        // Prepare initial components of message data: typehash & arbiter.
                        mstore(m, COMPACT_TYPEHASH)
                        mstore(add(m, 0x20), caller()) // arbiter: msg.sender
    
                        // Clear sponsor memory location as an added precaution so that
                        // upper bits of sponsor do not need to be copied from calldata.
                        mstore(add(m, 0x40), 0)
    
                        // Next data segment copied from calldata: sponsor, nonce & expires.
                        calldatacopy(add(m, 0x4c), add(claimPointer, 0x4c), 0x54)
    
                        // Prepare final components of message data: lockTag, token and amount.
                        // Deconstruct id into lockTag + token by inserting an empty word.
                        mstore(add(m, 0xa0), calldataload(add(claimPointer, 0xe0))) // lockTag
                        mstore(add(m, 0xac), 0) // last 20 bytes of lockTag and first 12 of token
                        calldatacopy(add(m, 0xcc), add(claimPointer, 0xec), 0x34) // token + amount
    
                        // Derive the message hash from the prepared data.
                        claimHash := keccak256(m, 0x100)
    
                        // Set Compact typehash
                        typehash := COMPACT_TYPEHASH
    
                        break
                    }
    
                    // Prepare first component of typestring from five one-word fragments.
                    mstore(m, COMPACT_TYPESTRING_FRAGMENT_ONE)
                    mstore(add(m, 0x20), COMPACT_TYPESTRING_FRAGMENT_TWO)
                    mstore(add(m, 0x40), COMPACT_TYPESTRING_FRAGMENT_THREE)
                    mstore(add(m, 0x6b), COMPACT_TYPESTRING_FRAGMENT_FIVE)
                    mstore(add(m, 0x60), COMPACT_TYPESTRING_FRAGMENT_FOUR)
    
                    // Copy remaining typestring data from calldata to memory.
                    let witnessStart := add(m, 0x8b)
                    calldatacopy(witnessStart, add(0x20, witnessTypestringPtr), witnessTypestringLength)
    
                    // Prepare closing ")" parenthesis at the very end of the memory region.
                    mstore8(add(witnessStart, witnessTypestringLength), 0x29)
    
                    // Derive the typehash from the prepared data.
                    typehash := keccak256(m, add(0x8c, witnessTypestringLength))
    
                    // Prepare initial components of message data: typehash & arbiter.
                    mstore(m, typehash)
                    mstore(add(m, 0x20), caller()) // arbiter: msg.sender
    
                    // Clear sponsor memory location as an added precaution so that
                    // upper bits of sponsor do not need to be copied from calldata.
                    mstore(add(m, 0x40), 0)
    
                    // Next data segment copied from calldata: sponsor, nonce, expires.
                    calldatacopy(add(m, 0x4c), add(claimPointer, 0x4c), 0x54)
    
                    // Prepare final components of message data: lockTag, token, amount & witness.
                    // Deconstruct id into lockTag + token by inserting an empty word.
                    mstore(add(m, 0xa0), calldataload(add(claimPointer, 0xe0))) // lockTag
                    mstore(add(m, 0xac), 0) // last 20 bytes of lockTag and first 12 of token
                    calldatacopy(add(m, 0xcc), add(claimPointer, 0xec), 0x34) // token + amount
    
                    mstore(add(m, 0x100), calldataload(add(claimPointer, 0xa0))) // witness
    
                    // Derive the message hash from the prepared data.
                    claimHash := keccak256(m, 0x120)
                    break
                }
            }
        }
    
    the-compact-v1/src/lib/HashLib.sol
  • claimHash.processClaimWithComponents(claimPayload.asRawPtr(), 0, typehash, _domainSeparator());

    Since this is not a batch, an idsAndAmounts[] array of length 1 is created.

    library ComponentLib {
        . . .
        using ComponentLib for Component[];
        using ValidityLib for bytes32;
        . . .
        /**
         * @notice Internal function for processing claims with potentially exogenous sponsor
          * signatures. Extracts claim parameters from calldata, validates the claim, validates
          * the scope, and executes either releases of ERC6909 tokens or withdrawals of underlying
          * tokens to multiple recipients.
          * @param claimHash              The EIP-712 hash of the compact for which the claim is being processed.
          * @param calldataPointer        Pointer to the location of the associated struct in calldata.
          * @param sponsorDomainSeparator The domain separator for the sponsor's signature, or zero for non-exogenous claims.
          * @param typehash               The EIP-712 typehash used for the claim message.
          * @param domainSeparator        The local domain separator.
          */
        function processClaimWithComponents(
            bytes32 claimHash,
            uint256 calldataPointer,
            bytes32 sponsorDomainSeparator,
            bytes32 typehash,
            bytes32 domainSeparator
        ) internal {
            // Declare variables for parameters that will be extracted from calldata.
            uint256 id;
            uint256 allocatedAmount;
            Component[] calldata components;
    
            assembly ("memory-safe") {
                // Calculate pointer to claim parameters using expected offset.
                let calldataPointerWithOffset := add(calldataPointer, 0xe0)
    
                // Extract resource lock id and allocated amount.
                id := calldataload(calldataPointerWithOffset)
                allocatedAmount := calldataload(add(calldataPointerWithOffset, 0x20))
    
                // Extract array of components containing claimant addresses and amounts.
                let componentsPtr := add(calldataPointer, calldataload(add(calldataPointerWithOffset, 0x40)))
                components.offset := add(0x20, componentsPtr)
                components.length := calldataload(componentsPtr)
            }
    
            // Initialize idsAndAmounts array.
            uint256[2][] memory idsAndAmounts = new uint256[2][](1);
            idsAndAmounts[0] = [id, allocatedAmount];
    
            // Validate the claim and extract the sponsor address.
            address sponsor = ClaimProcessorLib.validate(
                claimHash,
                id.toAllocatorId(),
                calldataPointer,
                domainSeparator,
                sponsorDomainSeparator,
                typehash,
                idsAndAmounts
            );
    
            // Verify the resource lock scope is compatible with the provided domain separator.
            sponsorDomainSeparator.ensureValidScope(id);
    
            // Process each component, verifying total amount and executing operations.
            components.verifyAndProcessComponents(sponsor, id, allocatedAmount);
        }
    
    the-compact-v1/src/types/ComponentLib.sol
    • address sponsor = ClaimProcessorLib.validate(claimHash,id.toAllocatorId(),calldataPointer,domainSeparator,sponsorDomainSeparator,typehash,idsAndAmounts);

      While retrieving the allocator address, the nonce is consumed — the nonce is allocator-scoped, and each allocator has its own nonce storage. Therefore, an off-chain allocator should check the nonce locally before signing a compact. Autocator (the off-chain allocator) has a local DB that handles this. In _validateSponsor(), validate sponsor authorization through either ECDSA, direct registration, EIP1271, or emissary. In _validateAllocator(), validate allocator authorization through the allocator interface — callAuthorizeClaim().

      library ClaimProcessorLib {
          . . .
          function validate(
              bytes32 claimHash,
              uint96 allocatorId,
              uint256 calldataPointer,
              bytes32 domainSeparator,
              bytes32 sponsorDomainSeparator,
              bytes32 typehash,
              uint256[2][] memory idsAndAmounts
          ) internal returns (address sponsor) {
              // Extract sponsor, nonce, and expires from calldata.
              uint256 nonce;
              uint256 expires;
              assembly ("memory-safe") {
                  // Extract sponsor address from calldata, sanitizing upper 96 bits.
                  sponsor := shr(0x60, calldataload(add(calldataPointer, 0x4c)))
      
                  // Extract nonce and expiration timestamp from calldata.
                  nonce := calldataload(add(calldataPointer, 0x60))
                  expires := calldataload(add(calldataPointer, 0x80))
      
                  // Swap domain separator for provided sponsorDomainSeparator if a nonzero value was supplied.
                  sponsorDomainSeparator := add(sponsorDomainSeparator, mul(iszero(sponsorDomainSeparator), domainSeparator))
              }
      
              // Ensure that the claim hasn't expired.
              expires.later();
      
              // Retrieve allocator address and consume nonce, ensuring it has not already been consumed.
              address allocator = allocatorId.fromRegisteredAllocatorIdWithConsumed(nonce);
      
              // Validate that the sponsor has authorized the claim.
              _validateSponsor(sponsor, claimHash, calldataPointer, sponsorDomainSeparator, typehash, idsAndAmounts);
      
              // Validate that the allocator has authorized the claim.
              _validateAllocator(allocator, sponsor, claimHash, calldataPointer, idsAndAmounts, nonce, expires);
      
              // Emit claim event.
              sponsor.emitClaim(claimHash, allocator, nonce);
          }
      
      the-compact-v1/src/lib/ClaimProcessorLib.sol
    • sponsorDomainSeparator.ensureValidScope(id);

      See [3] for how lockId is constructed:

      lockTag = scope << 255 | resetPeriod << 252 | allocatorId << 160
      lockId  = lockTag | tokenAddress
      
      enum Scope {
          Multichain,
          ChainSpecific
      }
      
      the-compact-v1/src/types/Scope.sol
      library ValidityLib {
          . . .
          function ensureValidScope(bytes32 sponsorDomainSeparator, uint256 id) internal pure {
              assembly ("memory-safe") {
                  if iszero(or(iszero(sponsorDomainSeparator), iszero(shr(255, id)))) {
                      // revert InvalidScope(id)
                      mstore(0, 0xa06356f5)
                      mstore(0x20, id)
                      revert(0x1c, 0x24)
                  }
              }
          }
      
      the-compact-v1/src/lib/ValidityLib.sol
    • components.verifyAndProcessComponents(sponsor, id, allocatedAmount);

      library ComponentLib {
          using TransferLib for address;
          . . .
          function verifyAndProcessComponents(
              Component[] calldata claimants,
              address sponsor,
              uint256 id,
              uint256 allocatedAmount
          ) internal {
              // Initialize tracking variables.
              uint256 totalClaims = claimants.length;
              uint256 spentAmount;
              uint256 errorBuffer;
      
              unchecked {
                  // Process each component while tracking total amount and checking for overflow.
                  for (uint256 i = 0; i < totalClaims; ++i) {
                      Component calldata component = claimants[i];
                      uint256 amount = component.amount;
      
                      // Track total amount claimed, checking for overflow.
                      spentAmount += amount;
                      errorBuffer |= (spentAmount < amount).asUint256();
      
                      sponsor.performOperation(id, component.claimant, amount);
                  }
              }
      
              // Revert if an overflow occurred or if total claimed amount exceeds allocation.
              errorBuffer |= (allocatedAmount < spentAmount).asUint256();
              assembly ("memory-safe") {
                  if errorBuffer {
                      // revert AllocatedAmountExceeded(allocatedAmount, amount);
                      mstore(0, 0x3078b2f6)
                      mstore(0x20, allocatedAmount)
                      mstore(0x40, spentAmount)
                      revert(0x1c, 0x44)
                  }
              }
          }
      
      the-compact-v1/src/lib/ComponentLib.sol
      function performOperation(address from, uint256 id, uint256 claimant, uint256 amount) internal {
          // Extract lock tags from both token ID and claimant.
          bytes12 lockTag = id.toLockTag();
          bytes12 claimantLockTag = claimant.toLockTag();
      
          // Extract the recipient address referenced by the claimant.
          address recipient = claimant.toAddress();
      
          if (claimantLockTag == bytes12(0)) {
              // Case 1: Zero lock tag - perform a standard withdrawal operation
              // to the recipient address referenced by the claimant.
              from.withdraw(recipient, id, amount, false);
          } else if (claimantLockTag == lockTag) {
              // Case 2: Matching lock tags - transfer tokens to the recipient address
              // referenced by the claimant.
              from.release(recipient, id, amount);
          } else {
              // Case 3: Different lock tags - convert the resource lock, burning
              // tokens and minting the same amount with the new token ID to the
              // recipient address referenced by the claimant.
      
              // Create a new token ID using the original ID with claimant's lock tag.
              uint256 claimantId = id.withReplacedLockTag(claimantLockTag);
      
              // Verify the allocator ID is registered.
              claimantId.toAllocatorIdIfRegistered();
      
              // Burn tokens from the original context.
              from.burn(id, amount);
      
              // Deposit tokens to the claimant's address with the new token ID.
              recipient.deposit(claimantId, amount);
          }
      }
      
      the-compact-v1/src/lib/TransferLib.sol

That is how a claim is processed. It is complex, but among all claim variants this is the most fundamental one — the key aspects are signature validation and the custom witness struct. CompactX implements the PoC by defining a Mandate struct as the witness, encoding the conditions that must be fulfilled on the fill chain.

Reference

[1] https://github.com/Uniswap/the-compact
[2] https://blog.uniswap.org/the-compact-v1
[3] https://docs.uniswap.org/contracts/the-compact/overview
[4] https://docs.rhinestone.dev/home/concepts/intents-and-erc4337#what-are-intents
[5] https://eips.ethereum.org/EIPS/eip-4337
[6] https://eips.ethereum.org/EIPS/eip-6909
[7] https://eips.ethereum.org/EIPS/eip-7579
[8] https://eips.ethereum.org/EIPS/eip-7683
[9] https://eips.ethereum.org/EIPS/eip-7702
[10] https://ethresear.ch/t/eil-trust-minimized-cross-l2-interop/23437

CompactX

0age’s cast provides the full picture, and each component is publicly implemented in the Uniswap repositories.

It no longer works, but you could previously try swapping on the CompactX main page. After The Compact V1 release, the endpoints changed slightly, and the PoC was built against V0. The screenshots here were captured when I set it up locally in the past.

You can see the Mandate struct being constructed and used as the witness field of the Compact. Let’s trace the entire flow through handleSwap.

// Handle the actual swap after quote is received
const handleSwap = async (
  options: { skipSignature?: boolean; isDepositAndSwap?: boolean } = {}
) => {
    . . .
    // Create mandate with required properties
    const mandate: Mandate = {
      chainId: quote.data.mandate.chainId,
      tribunal: quote.data.mandate.tribunal,
      recipient: quote.data.mandate.recipient,
      expires: quote.data.mandate.expires,
      token: quote.data.mandate.token,
      minimumAmount: quote.data.mandate.minimumAmount,
      baselinePriorityFee: quote.data.mandate.baselinePriorityFee,
      scalingFactor: quote.data.mandate.scalingFactor,
      salt: quote.data.mandate.salt.startsWith('0x')
        ? (quote.data.mandate.salt as `0x${string}`)
        : (`0x${quote.data.mandate.salt}` as `0x${string}`),
    } satisfies Mandate;

    const compactMessage = {
      arbiter: quote.data.arbiter,
      sponsor: quote.data.sponsor,
      nonce: null,
      expires: quote.data.expires,
      id: quote.data.id,
      amount: quote.data.amount,
      mandate,
    };
CompactX/src/components/TradeForm.tsx

Calibrator

It fetches price data from CoinGecko and uses it to calculate the swap amount. There is an Architecture Overview.md, but it’s too outdated to be useful; in short, it was originally intended to return whitelisted arbiter/tribunal information.

When constructing the compactMessage above, a quote must first be received.

// Handle the actual swap after quote is received
const handleSwap = async (
  options: { skipSignature?: boolean; isDepositAndSwap?: boolean } = {}
) => {
    . . .
    // Ensure we have a quote
    if (!quote?.data || !quote.context) {
      throw new Error('No quote available');
    }
CompactX/src/components/TradeForm.tsx

Take the data property and store it in a variable called quote. I initially thought this was a type annotation, but it is actually destructuring syntax.

const {
  data: quote,
  isLoading,
  error,
} = useCalibrator().useQuote(quoteParams, quoteVersion, isExecutingSwap);
CompactX/src/components/TradeForm.tsx

You can see the arbiter and tribunal contract addresses for the chain you’re swapping on.

Next, it’s time to sign the constructed compactMessage. The skipSignature flag relates to The Compact’s compact registration feature, which allows the sponsor’s signature to be omitted. For now, let’s ignore that and assume the sponsor signs. The signCompact() implementation lives in CompactX/src/hooks/useCompactSigner.ts, and it also handles obtaining the allocator’s signature. Once signatures and the nonce are ready, the intent is broadcast to the filler.

// Handle the actual swap after quote is received
const handleSwap = async (
  options: { skipSignature?: boolean; isDepositAndSwap?: boolean } = {}
) => {
      . . .
      let userSignature = '0x';
      let allocatorSignature = '0x';
      let nonce = '0';

      if (!skipSignature) {
        // Get signatures only if not skipping
        const signatures = await signCompact({
          chainId: quote.data.mandate.chainId.toString(),
          currentChainId: chainId.toString(),
          tribunal: quote.data.mandate.tribunal,
          compact: compactMessage,
          selectedAllocator,
        });
        userSignature = signatures.userSignature;
        allocatorSignature = signatures.allocatorSignature;
        nonce = signatures.nonce;
      }
CompactX/src/components/TradeForm.tsx

Indexer

Built with Ponder. It monitors The Compact events such as forcedWithdrawal, deposit, and claim.

Originally it was The Compact Indexer, monitoring only The Compact’s events, but it was renamed to the Unified Compact Indexer after incorporating Tribunal and HybridAllocator.

Before signing, the allocator queries the indexer to check state such as balance and forcedWithdrawal status.

Allocator

There are on-chain, off-chain, and hybrid allocators. Personally, I’d treat off-chain and hybrid as equivalent, since both require implementing the authorizeClaim() interface at the allocator’s on-chain address anyway. The key difference between on-chain and off-chain is whether a signature is used — on-chain allocators append to a storage array instead of signing. Allocators must manage nonces alongside signatures.

This used to show resource lock information upon deposit, but that is no longer the case.

In CompactX’s signCompact(), a request is made to the /compact endpoint to obtain the allocator’s signature.

// Submit a new compact with sponsor signature
server.post<{
  Body: CompactSubmission & { sponsorSignature: string };
}>(
  '/compact',
  async (
    request: FastifyRequest<{
      Body: CompactSubmission & { sponsorSignature: string };
    }>,
    reply: FastifyReply
  ) => {
    try {
      const { sponsorSignature, ...submission } = request.body;

      if (!sponsorSignature) {
        reply.code(400);
        return { error: 'Sponsor signature is required' };
      }

      // Return the result directly without wrapping it
      return await submitCompact(
        server,
        submission,
        submission.compact.sponsor,
        sponsorSignature
      );
    } catch (error) {
      . . .
autocator/src/routes/compact.ts

The code is well-commented, so you can follow the flow by reading through it. Along the way, validateCompact() in autocator/src/validation/compact.ts performs the following checks:

  1. Chain ID validation
  2. Structural Validation → Convert to positive BigInt type and check null field
  3. Nonce Validation
  4. Expiration Validation
  5. Domain and ID Validation → Check whether the reset period is too short compared to expiration
  6. Allocation Validation → allocatableBalance ≥ totalNeededBalance(allocatedBalance + compactAmount)—Verify resource lock has a sufficient amount
export async function submitCompact(
  server: FastifyInstance,
  submission: CompactSubmission,
  sponsorAddress: string,
  sponsorSignature: string
): Promise<{ hash: string; signature: string; nonce: string }> {
  try {
    // Start a transaction
    await server.db.query('BEGIN');

    // Validate sponsor address format
    const normalizedSponsorAddress = getAddress(sponsorAddress);

    // Validate sponsor matches the compact
    if (getAddress(submission.compact.sponsor) !== normalizedSponsorAddress) {
      throw new Error('Sponsor address does not match compact');
    }

    // Ensure nonce is provided
    if (submission.compact.nonce === null) {
      throw new Error(
        'Nonce is required. Use /suggested-nonce/:chainId to get a valid nonce.'
      );
    }

    // Validate the compact (including nonce validation)
    const validationResult = await validateCompact(
      submission.compact,
      submission.chainId,
      server.db
    );
    if (!validationResult.isValid || !validationResult.validatedCompact) {
      throw new Error(validationResult.error || 'Invalid compact');
    }

    // Get the validated compact with proper types
    const validatedCompact = validationResult.validatedCompact;

    // Convert to StoredCompactMessage for crypto operations
    const storedCompact = toStoredCompact(validatedCompact);

    // Verify sponsor signature
    let isSignatureValid = false;
    let isOnchainRegistration = false;

    if (sponsorSignature && sponsorSignature.startsWith('0x')) {
      try {
        // Generate claim hash
        const claimHash = await generateClaimHash(storedCompact);

        // Generate domain hash for the specific chain
        const domainHash = generateDomainHash(BigInt(submission.chainId));

        // Generate the digest that was signed
        const digest = generateDigest(claimHash, domainHash);

        // Convert compact signature to full signature for recovery
        const parsedCompactSig = parseCompactSignature(
          sponsorSignature as `0x${string}`
        );
        const signature = compactSignatureToSignature(parsedCompactSig);
        const fullSignature = serializeSignature(signature);

        // Recover the signer address
        const recoveredAddress = await recoverAddress({
          hash: digest,
          signature: fullSignature,
        });

        // Check if the recovered address matches the sponsor
        isSignatureValid =
          recoveredAddress.toLowerCase() ===
          normalizedSponsorAddress.toLowerCase();
      } catch (error) {
        . . .
        // Set signature as invalid and continue to onchain verification
        isSignatureValid = false;
      }
    }

    // If signature is invalid or missing, check for onchain registration
    if (!isSignatureValid) {
      . . .
    }

    // If neither signature is valid nor onchain registration is active, reject
    if (!isSignatureValid && !isOnchainRegistration) {
      throw new Error(
        'Invalid sponsor signature and no valid onchain registration found'
      );
    }

    // Sign the compact and get claim hash
    const { hash, signature: signaturePromise } = await signCompact(
      storedCompact,
      BigInt(submission.chainId)
    );
    const signature = await signaturePromise;

    // Store the compact first
    await storeCompact(
      server.db,
      storedCompact,
      submission.chainId,
      hash,
      signature
    );

    // Store the nonce as used (within the same transaction)
    await storeNonce(storedCompact.nonce, submission.chainId, server.db);

    // Commit the transaction
    await server.db.query('COMMIT');

    return {
      hash,
      signature,
      nonce: '0x' + storedCompact.nonce.toString(16).padStart(64, '0'),
    };
  } catch (error) {
    // Rollback on any error
    await server.db.query('ROLLBACK');
    throw error;
  }
}

autocator/src/compact.ts

Disseminator

Not needed when setting up a local test environment. CompactX sends directly to Fillanthropist or Neutrofill. The .env also connects directly to Fillanthropist.

VITE_BROADCAST_URL="https://fillanthropist.org"
CompactX/.env.example

Fillanthropist / Neutrofill

With the prepared signatures, the intent is broadcast. Looking at witnessTypeString, you can see the incomplete parenthesis, which is appended after the Compact struct.

// Handle the actual swap after quote is received
const handleSwap = async (
  options: { skipSignature?: boolean; isDepositAndSwap?: boolean } = {}
) => {
      . . .
      setStatusMessage('Broadcasting intent...');

      const broadcastPayload: CompactRequestPayload = {
        chainId: chainId.toString(),
        compact: {
          ...compactMessage,
          nonce,
          mandate: {
            ...mandate,
            chainId: quote.data.mandate.chainId,
            tribunal: quote.data.mandate.tribunal,
          },
        },
      };

      const broadcastContext: BroadcastContext = {
        ...quote.context,
        witnessHash: quote.context.witnessHash,
        witnessTypeString:
          'Mandate mandate)Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,bytes32 salt)',
      };

      const broadcastResponse = await broadcast(
        broadcastPayload,
        userSignature,
        allocatorSignature,
        broadcastContext
      );

      if (!broadcastResponse.success) {
        throw new Error('Failed to broadcast trade');
      }
CompactX/src/components/TradeForm.tsx

Fillanthropist is a manual filler. Neutrofill is an automated filler bot.

When a fill request comes in, the bot decides whether to execute it. Since the bot fills conservatively, you may need to increase the Slippage Tolerance in CompactX.

The bot verifies the signatures and, if the fill is not at a loss, calls fill() on-chain. It also verifies that the arbiter and tribunal addresses match its hardcoded values.

// Broadcast endpoint
app.post("/broadcast", validateBroadcastRequestMiddleware, async (req, res) => {
  . . .
  // Validate arbiter and tribunal addresses
  const arbiterAddress = request.compact.arbiter.toLowerCase();
  const tribunalAddress = request.compact.mandate.tribunal.toLowerCase();

  if (
    arbiterAddress !==
    SUPPORTED_ARBITER_ADDRESSES[
      Number(request.chainId) as SupportedChainId
    ].toLowerCase()
  ) {
    wsManager.broadcastFillRequest(
      JSON.stringify(request),
      false,
      "Unsupported arbiter address"
    );
    return res.status(400).json({ error: "Unsupported arbiter address" });
  }

  if (
    tribunalAddress !==
    SUPPORTED_TRIBUNAL_ADDRESSES[mandateChainId].toLowerCase()
  ) {
    wsManager.broadcastFillRequest(
      JSON.stringify(request),
      false,
      "Unsupported tribunal address"
    );
    return res.status(400).json({ error: "Unsupported tribunal address" });
  }

  const result = await processBroadcastTransaction(
    { ...request, chainId: Number(request.chainId) },
    mandateChainId,
    priceService,
    tokenBalanceService,
    publicClients[mandateChainId],
    walletClients[mandateChainId],
    account.address
  );
Neutrofill/src/server/index.ts
export async function processBroadcastTransaction(
  . . .
): Promise<ProcessedBroadcastResult> {
  // Encode simulation data with proper ABI
  const data = encodeFunctionData({
    abi: [
      {
        name: "fill",
        type: "function",
        stateMutability: "payable",
  . . .
  // Submit transaction with the wallet client
  const hash = await walletClient.sendTransaction({
    to: request.compact.mandate.tribunal as `0x${string}`,
    value,
    maxFeePerGas: priorityFee + (baseFee * 120n) / 100n,
    maxPriorityFeePerGas: priorityFee,
    gas: finalGasWithBuffer,
    data: data as `0x${string}`,
    chain,
    account,
  });
Neutrofill/src/server/index.ts

Arbiter / Tribunal

When setting up a local environment by forking the chain, these contracts are already deployed. The Hyperlane message relayer is a problem, but we can mimic it manually — so let’s skip that for now.

Tribunal is a framework for processing cross-chain swap settlements against PGA (priority gas auction) blockchains.

In Core Concepts, reference the Auction Participants:

  • Adjuster: Only referenced as UNISWAP_TEE in [1, 2]. I don't fully understand this, and it doesn't seem to be publicly released. My personal theory, thinking about where PGA happens, is that it's involved in block building. In particular, looking at [1], the fills field contains two fill methods: the method covered below, and another that performs a same-chain fill first and then bridges via Across Protocol. My guess is that the adjuster runs an auction during block building, dynamically adjusts the price curve in real time, and selects the optimal fillIndex from among the available fill methods. It also seems like settleOrRegister() is being developed to prevent race conditions that could arise from multiple fill methods.
  • Arbiter: The arbiter is an external party responsible for ultimately processing claims. The arbiter may accept or reject Tribunal’s suggestions concerning claimants and claim amounts (unless Tribunal is itself the arbiter).

In the old version, based on The Compact V0, the contract deployed on each chain is HyperlaneTribunal, which inherits from Tribunal. Looking at the actual transactions below, both fill (HyperlaneTribunal.fill()) and claim (HyperlaneTribunal.handle()) are executed through this single contract — confirming that arbiter and Tribunal are combined.

Since UNISWAP_TEE is not publicly available, a deeper dive would require looking into op-geth, op-node, and Rollup Boost — so let’s leave that for later. Instead, let’s look at the old version to see how the fill→claim ordering is guaranteed via Hyperlane’s cross-chain message protocol.

/**
 * @title Tribunal
 * @author 0age
 * @notice Tribunal is a framework for processing cross-chain swap settlements against PGA (priority gas auction)
 * blockchains. It ensures that tokens are transferred according to the mandate specified by the originating sponsor
 * and enforces that a single party is able to perform the fill in the event of a dispute.
 * @dev This contract is under active development; contributions, reviews, and feedback are greatly appreciated.
 */
contract Tribunal is BlockNumberish {
    . . .
    /**
     * @notice Attempt to fill a cross-chain swap.
     * @param claim The claim parameters and constraints.
     * @param mandate The fill conditions and amount derivation parameters.
     * @param claimant The recipient of claimed tokens on the claim chain.
     * @return mandateHash The derived mandate hash.
     * @return fillAmount The amount of tokens to be filled.
     * @return claimAmount The amount of tokens to be claimed.
     */
    function fill(Claim calldata claim, Mandate calldata mandate, address claimant)
        external
        payable
        nonReentrant
        returns (bytes32 mandateHash, uint256 fillAmount, uint256 claimAmount)
    {
        return _fill(
            claim.chainId,
            claim.compact,
            claim.sponsorSignature,
            claim.allocatorSignature,
            mandate,
            claimant,
            uint256(0),
            uint256(0)
        );
    }
    . . .
    function _fill(
        uint256 chainId,
        Compact calldata compact,
        bytes calldata sponsorSignature,
        bytes calldata allocatorSignature,
        Mandate calldata mandate,
        address claimant,
        uint256 targetBlock,
        uint256 maximumBlocksAfterTarget
    ) internal returns (bytes32 mandateHash, uint256 fillAmount, uint256 claimAmount) {
        . . .
        // Derive mandate hash.
        mandateHash = deriveMandateHash(mandate);

        // Derive and check claim hash.
        bytes32 claimHash = deriveClaimHash(compact, mandateHash);
        if (_dispositions[claimHash] != address(0)) {
            revert AlreadyClaimed();
        }
        _dispositions[claimHash] = claimant;
        . . .
        // Handle native token withdrawals directly.
        if (mandate.token == address(0)) {
            mandate.recipient.safeTransferETH(fillAmount);
        } else {
            // NOTE: Settling fee-on-transfer tokens will result in fewer tokens
            // being received by the recipient. Be sure to acommodate for this when
            // providing the desired fill amount.
            mandate.token.safeTransferFrom(msg.sender, mandate.recipient, fillAmount);
        }

        // Emit the fill event.
        emit Fill(compact.sponsor, claimant, claimHash, fillAmount, claimAmount, targetBlock);

        // Process the directive.
        _processDirective(
            chainId,
            compact,
            sponsorSignature,
            allocatorSignature,
            mandateHash,
            claimant,
            claimAmount,
            targetBlock,
            maximumBlocksAfterTarget
        );
        . . .
    }
Tribunal/src/Tribunal.sol

In deriveMandateHash(), the claimHash is derived on the fill chain using block.chainid before being sent via cross-chain message to trigger the claim. This means it’s impossible to spoof a fill event from another chain to intercept the claim.

function deriveMandateHash(Mandate calldata mandate) public view returns (bytes32) {
    return keccak256(
        abi.encode(
            MANDATE_TYPEHASH,
            block.chainid,
            address(this),
            mandate.recipient,
            mandate.expires,
            mandate.token,
            mandate.minimumAmount,
            mandate.baselinePriorityFee,
            mandate.scalingFactor,
            keccak256(abi.encodePacked(mandate.decayCurve)),
            mandate.salt
        )
    );
}
Tribunal/src/Tribunal.sol
contract HyperlaneTribunal is Router, Tribunal {
    . . .
    function _processDirective(
        uint256 chainId,
        Tribunal.Compact calldata compact,
        bytes calldata sponsorSignature,
        bytes calldata allocatorSignature,
        bytes32 mandateHash,
        address claimant,
        uint256 claimAmount,
        uint256 targetBlock,
        uint256 maximumBlocksAfterTarget
    ) internal virtual override {
        bytes memory message = Message.encode(
            compact,
            sponsorSignature,
            allocatorSignature,
            mandateHash,
            claimAmount,
            claimant,
            targetBlock,
            maximumBlocksAfterTarget
        );

        if (chainId > type(uint32).max) {
            revert InvalidChainId(chainId);
        }

        uint32 downcastedChainId = uint32(chainId);

        uint256 dispensation = _Router_quoteDispatch(downcastedChainId, message, "", address(hook));

        _Router_dispatch(downcastedChainId, dispensation, message, "", address(hook));
    }
arbiters/src/HyperlaneTribunal.sol

The claim is handled by overriding _handle() from the Router.

contract HyperlaneTribunal is Router, Tribunal {
    . . .
    function _handle(
        uint32,
        /*origin*/
        bytes32,
        /*sender*/
        bytes calldata message
    ) internal override {
        . . .
            ClaimWithWitness memory claimPayload = ClaimWithWitness({
                allocatorSignature: allocatorSignature,
                sponsorSignature: sponsorSignature,
                sponsor: sponsor,
                nonce: nonce,
                expires: expires,
                witness: witness,
                witnessTypestring: WITNESS_TYPESTRING,
                id: id,
                allocatedAmount: allocatedAmount,
                claimant: claimant,
                amount: claimedAmount
            });

            theCompact.claimAndWithdraw(claimPayload);
        . . .
    }
arbiters/src/HyperlaneTribunal.sol

Base → OP, USDC→USDC swap request

OP → Base, USDC→USDC swap request

I originally intended to document the latest Tribunal’s price curve, scaling factor, exact-in, exact-out, and more — but since UNISWAP_TEE (the adjuster) is not publicly available, that will have to wait.

Reference

[1] https://github.com/Uniswap/Tribunal/blob/745e6e7b1243efe3aa858c690ad25ddf994d4c47/src/types/TribunalTypeHashes.sol#L4-L85
[2] https://github.com/Uniswap/Tribunal/blob/745e6e7b1243efe3aa858c690ad25ddf994d4c47/src/types/TribunalStructs.sol#L6-L29