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.referralBpsis what your payload requests, in a 100k denominator (250 = 0.25% of swap volume)maxReferralBpsOfVolumeis the pool's cap, set to250(0.25% of volume) at launch and admin-tunable up to the hook's hard limitMAX_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):
cast call 0x636c050296B5Cc528D8785169Bf8923716FCa9cc \
"skimConfig(bytes32)(uint24,uint16,uint24,uint24,address,address,address,address)" \
0xf860d8f4896aed6cc1c68d234ba728680902f0ae43a459fbee6f6baa8036f795 \
--rpc-url https://ethereum-rpc.publicnode.comA 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:
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:
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:
- emits
SwapAttribution(poolId, swapper, referrer, sourceId, campaignId, quoteVolume, referralPaid)with the per-swap context - pays the slice into ReferralPayout at 0xB03Cbd862F47059e928C113182814c676eA29d4c via its hook-only
notify(referrer), which incrementsbalances[referrer]and emitsReferralCredited(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:
cast call 0xB03Cbd862F47059e928C113182814c676eA29d4c "balances(address)(uint256)" 0xYourReferrerAddress \
--rpc-url https://ethereum-rpc.publicnode.comClaiming
function claim() external // pays msg.sender's balance to msg.sender
function claimFor(address referrer) external // anyone may trigger a referrer's payoutThe 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.
cast send 0xB03Cbd862F47059e928C113182814c676eA29d4c "claim()" \
--rpc-url https://ethereum-rpc.publicnode.com --private-key $KEEPER_KEYOr from viem:
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=0xYourAddressSwaps 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.