Skip to content
BaseHub by Will Binns Updated

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.

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.

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.

Terminal window
npm i ox wagmi viem

Build the suffix once in its own module so every call site can import it:

attribution.ts
import { Attribution } from "ox/erc8021";
// Get your Builder Code from Base Dashboard > Settings > Project Settings > Builder Code
export 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:

SendButton.tsx
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>
);
}
MintButton.tsx
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:

SendCallsButton.tsx
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.

Terminal window
npm i ox viem

The wallet client takes the same option:

client.ts
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 Code
const 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:

send-transaction.ts
import { parseEther } from "viem";
import { walletClient } from "./client";
const hash = await walletClient.sendTransaction({
to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
value: parseEther("0.01"),
});
  • CDP Wallets — Coinbase Developer Platform smart accounts accept dataSuffix on user operations, across the React hooks (useSendUserOperation), Node, and Python SDKs. Generate the suffix with Attribution.toDataSuffix from ox/erc8021; setup notes are under Builder Codes in their docs.
  • Privy — ships a dataSuffix plugin that covers plain EOA transactions and ERC-4337 user operations alike. See the Privy integration guide.
  • Turnkey — infrastructure for programmatic wallets, also supported.

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 EOA
function 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-4337
function 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 + appSuffix

Apps need to do nothing for this — the wallet handles it alone.

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.

POST /v1/agents/builder-codes

No authentication required.

FieldTypeRequiredDescription
walletAddressstringYesYour agent’s EVM wallet address (0x...)
Terminal window
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.

Working in Claude Code, Cursor, or Codex? The Base skills package handles the whole path:

Terminal window
npx skills add base/base-skills

Then 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.

Two independent checks:

  1. 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 8021 pattern. Decode the suffix to see your code.
  2. 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.

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.

MetricWhat to countWhere it comes from
Daily, weekly, and monthly active usersDistinct addresses that connected, or that sent a successful attributed transactionThe eth_requestAccounts result; the sender and status of each transaction
D1, D7, and D30 retentionEach address’s first-seen date, and whether it returned laterBlock timestamp of the address’s first successful attributed transaction
Conversion funnelOpen, connect, submit, success, joined on address and hashPage load, eth_requestAccounts, the hash or call bundle ID from submit, the receipt
Attributed onchain activityTransactions whose ERC-8021 suffix holds your codeTransaction input, or each UserOperation’s callData
ETH and USDC volumeWhat the user moved out in each attributed transactionThe 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_source or ref, and from document.referrer. Do it on the first load, because client-side navigation discards them.
  • Connect. A resolved eth_requestAccounts call. Error code 4001 means the user said no.
  • Submit. eth_sendTransaction returns a transaction hash. wallet_sendCalls (EIP-5792) returns a call bundle ID instead, and wallet_getCallsStatus turns it into hashes. Store the hash with the in-app action it stands for (mint, swap, deposit). Error code 4001 here 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 value nor any log. You need a call trace for it (debug_traceTransaction with the callTracer), from a node or provider that offers one. The public mainnet.base.org endpoint stopped serving debug_ on 2026-10-08.
  • USDC and other ERC-20 tokens are Transfer logs 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 own sender.
get-attributed-volume.ts
import { erc20Abi, isAddressEqual, parseEventLogs, type Address, type Transaction, type TransactionReceipt } from "viem";
// USDC on Base
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
// Pass the transaction and receipt fetched in step 3, and the sender of each row where success and attributed are true
export 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:

  1. 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 success and attributed are both true). The gap between the two is your activation rate.
  2. 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.
  3. 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 (success is false), or a missing code (attributed is false). The last one usually means a client was set up without dataSuffix.

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.

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.