Builder Codes
A Builder Code ties onchain activity back to whoever built the thing that caused it. Codes live as an ERC-721 collection, one NFT per code, each holding a short identifier such as abc123. The onchain metadata carries a payout address — where rewards would go if the program pays out. Name, site, and similar details sit offchain.
Register a project on Base Dashboard to get a code. Base Dashboard is the new name for base.dev, and base.dev now redirects there. The code sits under Settings → Project Settings → Builder Code. Switch the format to Encoded String there to copy the whole ERC-8021 suffix. It is the same hex that Attribution.toDataSuffix returns.
The mechanism, in one paragraph
Section titled “The mechanism, in one paragraph”Attribution rides on the tail of the calldata. Your code is encoded as an ERC-8021 suffix and appended after the real arguments; indexers strip it back off later, offchain. Nothing onchain reads it, so contracts run exactly as before. Three consequences follow, and they are the reason this design was picked:
- Any deployed contract already works with ERC-8021. There is nothing to upgrade and nothing to redeploy.
- Execution is unaffected, because the trailing bytes are never decoded by the callee.
- The cost is 16 gas per non-zero byte, which is negligible against any real transaction.
EOAs and smart contract wallets both work. Attribution links a transaction to an application — it does not surface wallet information that was not already public onchain.
For app developers
Section titled “For app developers”An app registered on Base Dashboard gets attribution inside the Base App for free — the Base App appends your code to transactions its users make through your app or its browser. That credits the activity to your project and counts toward any future rewards.
Everything outside the Base App is on you. If users reach your app on the web or through another client, wire up dataSuffix yourself to capture that traffic.
Registration hands you a Builder Code — a random string along the lines of bc_b7k3p9da. How you attach it depends on the library. A Viem wallet client takes it once and applies it to everything it sends. Wagmi does not, so there you pass it with each write.
Both examples need viem 2.45.0 or newer.
npm i ox wagmi viemBuild the suffix once in its own module so every call site can import it:
import { Attribution } from "ox/erc8021";
// Get your Builder Code from Base Dashboard > Settings > Project Settings > Builder Codeexport const DATA_SUFFIX = Attribution.toDataSuffix({ codes: ["YOUR-BUILDER-CODE"],});Then hand it to every write. useSendTransaction and useWriteContract take dataSuffix directly and add it to the calldata before the wallet signs:
import { useSendTransaction } from "wagmi";import { parseEther } from "viem";import { DATA_SUFFIX } from "./attribution";
function SendButton() { const { sendTransaction } = useSendTransaction();
return ( <button onClick={() => sendTransaction({ to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", value: parseEther("0.01"), dataSuffix: DATA_SUFFIX, }) } > Send ETH </button> );}import { useWriteContract } from "wagmi";import { abi } from "./abi";import { DATA_SUFFIX } from "./attribution";
function MintButton() { const { writeContract } = useWriteContract();
return ( <button onClick={() => writeContract({ address: "0xYourContractAddress", abi, functionName: "mint", args: [1n], dataSuffix: DATA_SUFFIX, }) } > Mint </button> );}useSendCalls works differently. The suffix goes in the dataSuffix capability, and the wallet does the appending, so the connected wallet has to support that capability:
import { useSendCalls } from "wagmi";import { parseEther } from "viem";import { DATA_SUFFIX } from "./attribution";
function SendCallsButton() { const { sendCalls } = useSendCalls();
return ( <button onClick={() => sendCalls({ calls: [ { to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", value: parseEther("0.01"), }, ], capabilities: { dataSuffix: { value: DATA_SUFFIX, optional: true, }, }, }) } > Send calls </button> );}Send one test transaction and inspect its input data, as in Confirming attribution works, before you trust the setup.
npm i ox viemThe wallet client takes the same option:
import { createWalletClient, http } from "viem";import { base } from "viem/chains";import { Attribution } from "ox/erc8021";
// Get your Builder Code from Base Dashboard > Settings > Project Settings > Builder Codeconst DATA_SUFFIX = Attribution.toDataSuffix({ codes: ["YOUR-BUILDER-CODE"],});
export const walletClient = createWalletClient({ chain: base, transport: http(), dataSuffix: DATA_SUFFIX,});sendTransaction and writeContract on this client add the suffix for you. sendCalls passes it on to the wallet as the dataSuffix capability instead:
import { parseEther } from "viem";import { walletClient } from "./client";
const hash = await walletClient.sendTransaction({ to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", value: parseEther("0.01"),});Managed wallet providers
Section titled “Managed wallet providers”- CDP Wallets — Coinbase Developer Platform smart accounts accept
dataSuffixon user operations, across the React hooks (useSendUserOperation), Node, and Python SDKs. Generate the suffix withAttribution.toDataSuffixfromox/erc8021; setup notes are under Builder Codes in their docs. - Privy — ships a
dataSuffixplugin that covers plain EOA transactions and ERC-4337 user operations alike. See the Privy integration guide. - Turnkey — infrastructure for programmatic wallets, also supported.
For wallet developers
Section titled “For wallet developers”A wallet enables attribution by honoring the dataSuffix capability: accept it, then append the bytes before signing.
Accept a dataSuffix object inside the capabilities object of wallet_sendCalls:
type DataSuffixCapability = { value: `0x${string}`; // hex-encoded bytes provided by the app optional?: boolean; // whether the capability is optional}Where the bytes land depends on the transaction type. For an EOA, extend tx.data:
// Minimal example for EOAfunction applySuffixToEOA(tx, capabilities) { const suffix = capabilities.dataSuffix?.value if (!suffix) return tx
return { ...tx, // Append suffix bytes (remove 0x prefix from suffix if tx.data has it) data: tx.data + suffix.slice(2) }}For ERC-4337 the target is userOp.callData, not the outer transaction calldata:
// Minimal example for ERC-4337function applySuffixToUserOp(userOp, capabilities) { const suffix = capabilities.dataSuffix?.value if (!suffix) return userOp
return { ...userOp, // Append suffix bytes to the UserOp callData callData: userOp.callData + suffix.slice(2) }}A wallet can claim attribution of its own at the same time. ERC-8021 carries multiple codes natively, so prepend your suffix ahead of the app’s and both parties are credited:
finalSuffix = walletSuffix + appSuffixApps need to do nothing for this — the wallet handles it alone.
For agent developers
Section titled “For agent developers”Agents transact on their own schedule, with no human pressing a button per transaction. Without a code that activity is anonymous; with one it rolls up under your identity in the Base registry, where anyone can measure it from chain data, and making the agent eligible for discovery surfaces such as the App Leaderboard.
The mechanics are the ones described above — an ERC-8021 suffix, ignored by contracts, recovered by indexers, 16 gas per non-zero byte.
Registration API
Section titled “Registration API”POST /v1/agents/builder-codesNo authentication required.
| Field | Type | Required | Description |
|---|---|---|---|
walletAddress | string | Yes | Your agent’s EVM wallet address (0x...) |
curl -X POST https://api.base.dev/v1/agents/builder-codes \ -H "Content-Type: application/json" \ -d '{ "walletAddress": "<your-wallet-address>" }'{ "builderCode": "bc_a1b2c3d4", "walletAddress": "0x..."}The endpoint is idempotent on wallet address: one address always maps to the same code, and calling again returns the code you already have. That makes it safe to run on every deploy rather than gating it behind a first-run check.
Doing it from a coding agent
Section titled “Doing it from a coding agent”Working in Claude Code, Cursor, or Codex? The Base skills package handles the whole path:
npx skills add base/base-skillsThen ask for a builder code registration. The skill validates the wallet, calls the API, writes the result to src/constants/builderCode.ts, installs ox, and threads the ERC-8021 dataSuffix into whichever client you use — viem, ethers.js, or a managed service.
Confirming attribution works
Section titled “Confirming attribution works”Two independent checks:
- A block explorer — pull up the transaction on Basescan or Etherscan, open the input data field, and confirm the last 16 bytes are the repeating
8021pattern. Decode the suffix to see your code. - The validation tool — paste a transaction or UserOperation hash into Builder Code Validation, pick the transaction type, and press Check Attribution.
Upstream used to list the base.dev dashboard as a third check. It removed that step on 2026-10-01, so rely on the onchain data instead.
Measuring your own users
Section titled “Measuring your own users”Base Dashboard reports a narrow set of numbers from your code’s activity: spot trading volume, borrow TVL, and lending TVL. It does not report users, retention, funnels, or the raw ETH and USDC your attributed transactions move. For those you join two data sets yourself. One is what your app sees in the browser. The other is the attributed transactions onchain. The connected wallet address, in lowercase, is the join key. The transaction hash links each submit to its result.
| Metric | What to count | Where it comes from |
|---|---|---|
| Daily, weekly, and monthly active users | Distinct addresses that connected, or that sent a successful attributed transaction | The eth_requestAccounts result; the sender and status of each transaction |
| D1, D7, and D30 retention | Each address’s first-seen date, and whether it returned later | Block timestamp of the address’s first successful attributed transaction |
| Conversion funnel | Open, connect, submit, success, joined on address and hash | Page load, eth_requestAccounts, the hash or call bundle ID from submit, the receipt |
| Attributed onchain activity | Transactions whose ERC-8021 suffix holds your code | Transaction input, or each UserOperation’s callData |
| ETH and USDC volume | What the user moved out in each attributed transaction | The transaction’s value, plus Transfer logs in its receipt |
Capture the browser side at each step:
- Open. Read the acquisition source from URL parameters such as
utm_sourceorref, and fromdocument.referrer. Do it on the first load, because client-side navigation discards them. - Connect. A resolved
eth_requestAccountscall. Error code4001means the user said no. - Submit.
eth_sendTransactionreturns a transaction hash.wallet_sendCalls(EIP-5792) returns a call bundle ID instead, andwallet_getCallsStatusturns it into hashes. Store the hash with the in-app action it stands for (mint, swap, deposit). Error code4001here means the user refused to sign. - Success. Comes from the chain, not the app.
The suffix is parsed from the end backwards. First comes the 16-byte marker 0x80218021802180218021802180218021, then a 1-byte schema ID, then a 1-byte length, then the codes as comma-separated ASCII. So bc_b7k3p9da becomes 0x62635f62376b33703964610b0080218021802180218021802180218021. Compare decoded codes, not raw bytes.
Upstream’s helper takes a recorded hash and returns one row per user action, for either account type:
import { createPublicClient, decodeFunctionData, http, isAddressEqual, parseEventLogs, type Hash } from "viem";import { base } from "viem/chains";import { entryPoint06Abi, entryPoint06Address, entryPoint07Abi, entryPoint07Address } from "viem/account-abstraction";import { Attribution } from "ox/erc8021";
const client = createPublicClient({ chain: base, transport: http() });
const BUILDER_CODE = "YOUR-BUILDER-CODE";
export async function getAttributedActivity(hash: Hash) { const [tx, receipt] = await Promise.all([ client.getTransaction({ hash }), client.getTransactionReceipt({ hash }), ]);
const block = await client.getBlock({ blockNumber: receipt.blockNumber }); const common = { transactionHash: hash, blockNumber: receipt.blockNumber, timestamp: Number(block.timestamp), };
// ERC-4337: the outer transaction is sent by a bundler to the EntryPoint. // The user is each UserOperation's sender, and the suffix sits in its callData. const entryPoint = tx.to && isAddressEqual(tx.to, entryPoint07Address) ? { abi: entryPoint07Abi } : tx.to && isAddressEqual(tx.to, entryPoint06Address) ? { abi: entryPoint06Abi } : undefined;
if (entryPoint) { const { args } = decodeFunctionData({ abi: entryPoint.abi, data: tx.input }); const ops = (args?.[0] ?? []) as readonly { sender: `0x${string}`; callData: `0x${string}` }[]; const events = parseEventLogs({ abi: entryPoint.abi, eventName: "UserOperationEvent", logs: receipt.logs });
return ops.map((op) => { const event = events.find((e) => isAddressEqual(e.args.sender, op.sender)); return { ...common, sender: op.sender.toLowerCase(), success: event?.args.success ?? false, attributed: Attribution.fromData(op.callData)?.codes?.includes(BUILDER_CODE) ?? false, }; }); }
// EOA transaction: the user is tx.from and the suffix is at the end of tx.input. return [{ ...common, sender: tx.from.toLowerCase(), success: receipt.status === "success", attributed: Attribution.fromData(tx.input)?.codes?.includes(BUILDER_CODE) ?? false, }];}To backfill, or to catch transactions sent from outside your UI, scan Base blocks (or your contracts’ logs via eth_getLogs) for calldata that ends in the marker, and decode each hit the same way.
For volume, reuse the transaction and receipt you already fetched. Where the value shows up depends on the account type:
- ETH from an EOA is the transaction’s
value. - ETH from a smart account moves in an internal call, so it is in neither the outer
valuenor any log. You need a call trace for it (debug_traceTransactionwith thecallTracer), from a node or provider that offers one. The publicmainnet.base.orgendpoint stopped servingdebug_on 2026-10-08. - USDC and other ERC-20 tokens are
Transferlogs from the token contract. Keep only transfers whose sender is your user. That filter also splits a bundle into its separate UserOperations, because each has its ownsender.
import { erc20Abi, isAddressEqual, parseEventLogs, type Address, type Transaction, type TransactionReceipt } from "viem";
// USDC on Baseconst USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
// Pass the transaction and receipt fetched in step 3, and the sender of each row where success and attributed are trueexport function getAttributedVolume(tx: Transaction, receipt: TransactionReceipt, sender: Address) { const usdcTransfers = parseEventLogs({ abi: erc20Abi, eventName: "Transfer", logs: receipt.logs }) .filter((log) => isAddressEqual(log.address, USDC) && isAddressEqual(log.args.from, sender));
return { // Native ETH sent directly by an EOA. Smart account ETH transfers need a call trace. ethWei: isAddressEqual(tx.from, sender) ? tx.value : 0n, // USDC uses 6 decimals: format with formatUnits(usdc, 6) usdc: usdcTransfers.reduce((sum, log) => sum + log.args.value, 0n), };}Call it for each row from getAttributedActivity where success and attributed are both true. (“Step 3” in the upstream comment is that helper.) Roll the totals up by day, week, or month. For a single currency figure, price each amount at its block timestamp with whatever price source you trust.
With both sides joined:
- Active users. Count distinct addresses per UTC day, week, or month. Keep connected users and transacting users apart (a transacting user has a row where
successandattributedare both true). The gap between the two is your activation rate. - Retention. Put each address in a cohort by the week of its first successful attributed transaction. Then measure what share of each cohort transacts again after 1, 7, and 30 days.
- Funnel. Count addresses at each stage, split by acquisition source. A drop between submit and success has three causes: a refused signature (
4001), a revert (successis false), or a missing code (attributedis false). The last one usually means a client was set up withoutdataSuffix.
Wallet support today
Section titled “Wallet support today”Every EOA wallet supports dataSuffix as a matter of course. Smart wallets that implement ERC-5792 can carry it through the DataSuffixCapability:
await wallet.sendCalls({ calls: [ // your transaction calls ], capabilities: { dataSuffix: { value: "0x07626173656170700080218021802180218021802180218021", optional: true } }});Privy and Turnkey both cover the embedded wallet case.
Not the same thing as user verification
Section titled “Not the same thing as user verification”Builder Codes and Base Verify get reached for interchangeably and answer different questions. A Builder Code says which app, wallet, or agent produced a transaction. It says nothing about who the user is or whether they qualify for anything. Base Verify goes the other way: it lets a user prove control of a supported account so your app can evaluate approved traits, with a backend flow for app-controlled experiences and an onchain flow for policy a contract has to enforce itself. It carries no attribution.
If you need both — attributing activity and gating who can perform it — they are two integrations, not one.
Related resources
Section titled “Related resources”- ERC-8021 proposal — the specification itself.
- Builder Code Validation tool — check a hash for attribution.
- Base Dashboard — registration, project settings, and your code.