PERMANENT COLLECTIONProtocol Reference

Swap with referral attribution

Every swap on the official $111 pool can carry an attribution payload in the V4 hookData. When the payload names a valid referrer, the skim hook carves a referral slice out of the protocol fee leg and credits it to the referrer in ReferralPayout, in the same transaction as the swap. Frontends, wallets, and aggregators that route $111 volume earn ETH per swap this way, with no registration step.

How the slice is sized

Per swap:

referral = min(volume × min(att.referralBps, maxReferralBpsOfVolume) / 100_000,
               protocolShare)
  • att.referralBps is what your payload requests, in a 100k denominator (250 = 0.25% of swap volume)
  • maxReferralBpsOfVolume is the pool's cap, set to 250 (0.25% of volume) at launch and admin-tunable up to the hook's hard limit MAX_REFERRAL_CAP_OF_VOLUME = 1000 (1% of volume)
  • the slice is deducted from the protocol leg only. It never reduces the live-bid leg, and it is clamped so it can never exceed the protocol share itself

Read the current cap from the hook's per-pool config (third output, maxReferralBpsOfVolume):

bash
cast call 0x636c050296B5Cc528D8785169Bf8923716FCa9cc \
  "skimConfig(bytes32)(uint24,uint16,uint24,uint24,address,address,address,address)" \
  0xf860d8f4896aed6cc1c68d234ba728680902f0ae43a459fbee6f6baa8036f795 \
  --rpc-url https://ethereum-rpc.publicnode.com

A payload with no valid referrer, or no payload at all, leaves the slice in the protocol leg. Nothing reverts either way.

The encoding: a single-struct abi.encode

The hook decodes the swap's hookData as a 1-tuple: abi.decode(hookData, (PoolSwapData)). The struct shapes:

solidity
struct PoolSwapData {
    bytes mevModuleSwapData;      // leave empty ("0x")
    bytes poolExtensionSwapData;  // carries the abi-encoded PCSwapData below
}

struct PCSwapData {
    PCAttribution attribution;
    bytes extensionPayload;       // leave empty ("0x")
}

struct PCAttribution {
    bytes32 sourceId;     // builder-chosen identifier (frontend, campaign)
    address referrer;     // credited in ReferralPayout; address(0) = none
    bytes16 campaignId;   // optional sub-attribution under a sourceId
    uint24  referralBps;  // requested bps of volume, 100k denom; clamped at the hook
}

The gotcha: encode the outer envelope as a single struct argument, abi.encode(poolSwapData), never as a 2-tuple of raw bytes like abi.encode(bytes(""), inner). The 2-tuple form is off by a 32-byte outer offset, and the hook's decode tolerance treats it as "no attribution": the swap completes, the referral is silently skipped, and nothing reverts to tell you.

TypeScript encoding example

This mirrors the encoder the official frontend uses:

ts
import {encodeAbiParameters, getAddress, type Hex} from 'viem';

const PC_SWAP_DATA_ABI = [
    {
        type: 'tuple',
        components: [
            {
                name: 'attribution',
                type: 'tuple',
                components: [
                    {name: 'sourceId', type: 'bytes32'},
                    {name: 'referrer', type: 'address'},
                    {name: 'campaignId', type: 'bytes16'},
                    {name: 'referralBps', type: 'uint24'},
                ],
            },
            {name: 'extensionPayload', type: 'bytes'},
        ],
    },
] as const;

const POOL_SWAP_DATA_ABI = [
    {
        type: 'tuple',
        components: [
            {name: 'mevModuleSwapData', type: 'bytes'},
            {name: 'poolExtensionSwapData', type: 'bytes'},
        ],
    },
] as const;

const ZERO_BYTES32: Hex = `0x${'00'.repeat(32)}`;
const ZERO_BYTES16: Hex = `0x${'00'.repeat(16)}`;

export function encodeAttributionHookData(args: {
    referrer: `0x${string}`;
    sourceId?: Hex;    // e.g. a 32-byte hash identifying your frontend
    campaignId?: Hex;  // 16-byte campaign tag
    referralBps?: number; // defaults to the launch cap (250 = 0.25% of volume)
}): Hex {
    const inner = encodeAbiParameters(PC_SWAP_DATA_ABI, [
        {
            attribution: {
                sourceId: args.sourceId ?? ZERO_BYTES32,
                referrer: getAddress(args.referrer),
                campaignId: args.campaignId ?? ZERO_BYTES16,
                referralBps: args.referralBps ?? 250,
            },
            extensionPayload: '0x',
        },
    ]);
    // Single-struct encoding of the outer envelope: see the gotcha above.
    return encodeAbiParameters(POOL_SWAP_DATA_ABI, [
        {mevModuleSwapData: '0x', poolExtensionSwapData: inner},
    ]);
}

Pass the result as the hookData argument of the V4 swap (Universal Router, PoolManager.swap, or whatever router you use). If a swap carries no attribution, pass '0x' instead.

Requesting more than the cap is harmless: the hook clamps to maxReferralBpsOfVolume. Malformed encoding at any layer is treated as no attribution and never reverts the swap.

Where the credit lands

On each attributed swap the hook:

  1. emits SwapAttribution(poolId, swapper, referrer, sourceId, campaignId, quoteVolume, referralPaid) with the per-swap context
  2. pays the slice into ReferralPayout at 0xB03Cbd862F47059e928C113182814c676eA29d4c via its hook-only notify(referrer), which increments balances[referrer] and emits ReferralCredited(referrer, amount)

If the payout leg fails, the slice folds back to the protocol escrow and the swap still completes. The referral path is fail-closed in every direction.

Check a balance:

bash
cast call 0xB03Cbd862F47059e928C113182814c676eA29d4c "balances(address)(uint256)" 0xYourReferrerAddress \
  --rpc-url https://ethereum-rpc.publicnode.com

Claiming

solidity
function claim() external              // pays msg.sender's balance to msg.sender
function claimFor(address referrer) external  // anyone may trigger a referrer's payout

The send carries a CLAIM_GAS = 35_000 gas budget. A claim with a zero balance reverts NothingToClaim(); a recipient that rejects the ETH reverts TransferFailed() with the balance reinstated, so funds are never lost. Stray ETH sent directly to the contract is accepted but never credited to anyone.

bash
cast send 0xB03Cbd862F47059e928C113182814c676eA29d4c "claim()" \
  --rpc-url https://ethereum-rpc.publicnode.com --private-key $KEEPER_KEY

Or from viem:

ts
import {parseAbi} from 'viem';

await walletClient.writeContract({
    address: '0xB03Cbd862F47059e928C113182814c676eA29d4c',
    abi: parseAbi(['function claimFor(address referrer)']),
    functionName: 'claimFor',
    args: ['0xReferrerAddress'],
});

ReferralClaimed(referrer, amount) is emitted on success.

The ?ref= URL pattern

The official frontend resolves the referrer it encodes, in priority order: a ?ref=0x... query parameter on the current visit (persisted to localStorage, so it stays sticky across later visits), then the previously stored value, then an operator-configured default. Any address case is accepted and normalized. So the simplest integration of all is a link:

https://permanentcollection.art/?ref=0xYourAddress

Swaps made through the app by visitors who arrived on that link carry your address as the referrer, at the frontend's default referralBps of 250.

Return auctions have a separate referral surface (placeBidWithReferral on ReturnAuctionModule, paid from the winning bid's premium at settle); see Bid on a return auction.

Swap with referral attribution · Permanent Collection