TALON contracts

On-chain fee split for TALON on Robinhood Chain (Arbitrum Orbit L2). Two small, immutable contracts live in contracts/ as a Foundry project.

ChainChain idNative gasRPCExplorer
Robinhood Chain mainnet4663ETHhttps://rpc.mainnet.chain.robinhood.comhttps://robinhoodchain.blockscout.com
Robinhood Chain testnet46630ETHhttps://rpc.testnet.chain.robinhood.comhttps://explorer.testnet.chain.robinhood.com

Purpose

When an agent launches a token through TALON, the launchpad (Pons V2 first) pays creator fees to a single address. TALON points that address at a FeeSplitter instead of the agent wallet. Fees accrue in the splitter and, whenever anyone calls claim, 75% is pushed to the agent (the creator) and 25% to the TALON platform treasury.

Design goals, in order:

  1. No trust in TALON. The splitter has no owner, no admin, no pause, no upgrade path. The two recipients are immutable. TALON cannot redirect, freeze or take more than 25%.
  2. No stranded funds. Every claim pushes the full balance out. Integer rounding always favors the platform by at most 1 base unit, so creator + platform == total holds exactly.
  3. Predictable addresses. The factory uses CREATE2 with salt = keccak256(abi.encode(creator, platform)), so the splitter address for any creator is known before it exists. A launch can name the address as its fee recipient before the splitter is deployed; ETH or tokens that land there early stay claimable after deployment.

Contracts

FeeSplitter (contracts/src/FeeSplitter.sol)

Immutable per-creator vault. Inherits OpenZeppelin ReentrancyGuard, uses SafeERC20.

ItemValue
CREATOR_BPS7500 (constant)
BPS10000 (constant)
creatorimmutable, receives CREATOR_BPS / BPS of every claim
platformimmutable, receives the remainder
Fundingreceive() for ETH; ERC-20s by plain transfer to the splitter address
Accessclaim and claimMany are permissionless; funds can only go to the two fixed recipients
ErrorsZeroAddress(), NothingToClaim(), EthTransferFailed(address to)
EventClaimed(address indexed token, uint256 creatorAmount, uint256 platformAmount)

Behavior worth knowing:

  • claim(address(0)) splits native ETH; claim(token) splits that ERC-20. Zero balance reverts with NothingToClaim().
  • claimMany(tokens) skips tokens with a zero balance and reverts with NothingToClaim() only if nothing at all was claimed (including an empty array). This lets the worker batch ETH plus every fee asset in one transaction without pre-filtering.
  • pending(token) returns exactly what a claim would pay each side right now; (0, 0) when empty.
  • Push semantics: ETH is sent with call and all gas is forwarded. If creator or platform is a contract that rejects ETH, the claim reverts with EthTransferFailed(recipient) and nothing moves. This is intentional and accepted: creators are agent wallets (EOAs or payable smart accounts), and the platform treasury is an EOA or a payable multisig. A creator that cannot receive ETH simply cannot be paid until it can; ERC-20 claims for that creator still work.
  • When the creator share rounds to zero (only possible for a 1-unit balance), the creator transfer is skipped so tokens that revert on zero-value transfers do not block the claim.
  • Reentrancy: claim and claimMany are nonReentrant. A creator that re-enters claim from its receive() sees the nested call revert; if it bubbles that revert, the outer claim reverts (no funds move); if it swallows it, the outer claim completes with the exact 75 / 25 amounts. Both paths are tested.
  • Any ERC-20 works, including tokens paid as Pons quote assets. Fee-on-transfer tokens deliver less than the emitted amounts to each recipient but never revert; rebasing tokens are split at their balance at claim time.

FeeSplitterFactory (contracts/src/FeeSplitterFactory.sol)

ItemValue
platformimmutable, set once in the constructor, baked into every splitter
deploy(creator)CREATE2 deploy; idempotent, returns the existing splitter without reverting on repeat calls
computeAddress(creator)the address deploy would produce, valid before or after deployment
splitterOf(creator)mapping lookup, address(0) until deployed
Saltkeccak256(abi.encode(creator, platform))
EventSplitterDeployed(address indexed creator, address indexed platform, address splitter) (emitted once per creator)
ErrorsZeroAddress() on a zero platform; FeeSplitter.ZeroAddress() bubbles up on a zero creator

One factory per platform treasury. If the treasury ever changes, deploy a new factory; existing splitters keep paying the old treasury, which is the point.

TreasurySplitter and TreasurySplitterFactory (v3, contracts/src/TreasurySplitter.sol)

The fee route for a coin launched with an agent (docs/launch-with-a-mind.md). Same shape as FeeSplitter v2 (immutable recipients, permissionless claim, collects the splitter's credit from the Pons fee escrow first), with three recipients and shares fixed at deploy:

RecipientShare
treasury (the coin's agent wallet)everything the other two do not take: 65% to 90%
creator (the launcher)creatorBps, 0 to 2,500 bps, chosen at launch
platformPLATFORM_BPS = 1,000 bps, fixed for every splitter

Rounding: creator and platform round down, the treasury takes the remainder, so nothing is stranded. The factory deploys one splitter per treasury with CREATE2 (salt: treasury, creator, creatorBps, platform) and refuses a second splitter for the same treasury with a different key, so a coin can never have two fee routes. Deploy with script/DeployTreasuryFactory.s.sol (same env as Deploy.s.sol). Coins keep launching through Pons exactly as today; only the creator fee recipient changes from a v2 splitter to a v3 one.

Tests: 26 unit tests at 100% coverage, plus two mainnet fork tests (test/fork/TreasurySplitter.fork.t.sol) that collect a live ETH credit from the real Pons escrow and split it three ways. Not deployed yet.

AgentFeeHook (contracts/src/AgentFeeHook.sol)

A zero-callback Uniswap v4 hook through which a TALON agent sets one pool's dynamic swap fee by US market hours, inside bounds fixed at deploy. See agent-fee-hook.md for the design, the tests and the launch runbook.

Fee math

creatorAmount  = amount * 7500 / 10000     (integer division, rounds down)
platformAmount = amount - creatorAmount    (remainder, so nothing is stranded)
BalanceCreator (75%)Platform (25%)Note
101creator transfer skipped
2111.5 rounds down
3212.25 rounds down
431exact
7525.25 rounds down
99742574.25 rounds down
1000175002501
100 ETH75 ETH25 ETHexact

Invariants covered by the fuzz suite for every uint128 amount: creator + platform == amount, 4 * creator <= 3 * amount, 4 * platform >= amount, and the creator is short by strictly less than one base unit versus the exact 75%.

Off-chain policy, not in the contract: of the platform's 25%, 60% is earmarked for $TALON buyback and burn. The contract only knows two recipients and two numbers. The buyback split is executed by the treasury off-chain and is documented here so nobody looks for it in Solidity.

Interfaces

interface IFeeSplitter {
    function CREATOR_BPS() external view returns (uint256); // 7500
    function BPS() external view returns (uint256);         // 10000
    function creator() external view returns (address);
    function platform() external view returns (address);

    function pending(address token) external view returns (uint256 creatorAmount, uint256 platformAmount);
    function claim(address token) external;                 // token == address(0) means ETH
    function claimMany(address[] calldata tokens) external;

    event Claimed(address indexed token, uint256 creatorAmount, uint256 platformAmount);

    error ZeroAddress();
    error NothingToClaim();
    error EthTransferFailed(address to);
}

interface IFeeSplitterFactory {
    function platform() external view returns (address);
    function deploy(address creator) external returns (address splitter);
    function computeAddress(address creator) external view returns (address);
    function splitterOf(address creator) external view returns (address);

    event SplitterDeployed(address indexed creator, address indexed platform, address splitter);

    error ZeroAddress();
}

The viem ABIs used by the apps live in packages/chain/src/abis/feeSplitter.ts and must stay in sync with the signatures above.

How Pons points at the splitter

  1. Resolve the splitter. For an agent wallet creator, the launch tool calls factory.splitterOf(creator). If it is address(0), it either calls factory.deploy(creator) first or uses factory.computeAddress(creator) and deploys lazily later. Both give the same address.
  2. Launch with the splitter as fee recipient. When building the Pons V2 launch transaction, set the creator fee recipient parameter to the splitter address instead of the agent wallet. Every other launch parameter is unchanged. The launches.splitter_address column in packages/db records which splitter belongs to the launch.
  3. Fees accrue in the Pons escrow. Pons does not pay creator fees to the recipient directly. A curve holds its creator tax until Pons' sweeper (sweepCurve / sweepPool on 0xa1018c1D...8cA83, run by Pons continuously) moves it, once a token's held fees pass a floor of 0.0037 ETH or 10 USDG. The sweep credits the creator fee recipient in the Pons fee escrow (0xd3AFEB2a...Ac9e), and only that recipient can claim the credit (claim() for ETH, claimToken(token) for ERC-20s; both revert with NoBalance() when nothing is owed). A v1 splitter could never make that call, so its fees would stay in the escrow forever. v2 splitters know the escrow (an immutable set by the factory): claim and claimMany first collect the splitter's escrow credit for each token, then split everything held. pending(token) and escrowBalance(token) include the credit. Collection never reverts the claim: an escrow that fails or pays nothing leaves the claim to split what the splitter already holds.
  4. Index earnings. The worker's fee job reads pending(token) for each (splitter, feeAsset) pair and watches Claimed events, updating the earnings table (accrued vs claimed).
  5. Claim. The Earnings page, CLI, MCP tool or a worker cron calls claimMany([address(0), quoteAsset, ...]). Anyone may pay the gas; the outcome is identical regardless of caller. The creator receives 75% directly in its wallet and the treasury receives 25%.

If a launch was created before the splitter was deployed, fees still land at the predicted address and become claimable the moment deploy(creator) runs. Nothing is lost in the gap.

Build, test, coverage

cd contracts
forge fmt --check
forge build
forge test -vvv
forge coverage --report summary --no-match-coverage "(script|test)"

Toolchain: forge 1.7.1, solc 0.8.28, evm_version = "cancun", optimizer on with 1,000,000 runs, OpenZeppelin Contracts v5.4.0 and forge-std vendored under contracts/lib/ as plain files (no git submodules).

Why Cancun: Robinhood Chain is an Arbitrum Orbit chain running Nitro, and Nitro supports the Cancun opcode set (TSTORE / TLOAD, MCOPY, PUSH0). OpenZeppelin v5's ReentrancyGuard uses regular storage, so nothing here strictly needs transient storage, but compiling for Cancun avoids solc emitting a pre-Shanghai fallback for PUSH0. If a future target chain does not support Cancun, set evm_version = "paris" in foundry.toml; the contracts compile unchanged.

Test suite

52 tests across two suites, all passing (forge test -vvv):

Ran 14 tests for test/FeeSplitterFactory.t.sol:FeeSplitterFactoryTest
Suite result: ok. 14 passed; 0 failed; 0 skipped

Ran 38 tests for test/FeeSplitter.t.sol:FeeSplitterTest
Suite result: ok. 38 passed; 0 failed; 0 skipped

Ran 2 test suites: 52 tests passed, 0 failed, 0 skipped (52 total tests)

What is covered:

  • Constructor validation (zero creator, zero platform, both zero) for the splitter and the factory.
  • ETH split, ERC-20 split, event payloads, permissionless claim, ETH and ERC-20 balances kept separate.
  • Rounding on 1, 2, 3, 4, 5, 7, 13, 99, 101, 9999, 10001 and 123456789 units: creator == total * 7500 / 10000, creator + platform == total, nothing left in the splitter.
  • NothingToClaim on empty ETH, empty ERC-20, a second claim, an all-empty claimMany, and an empty array.
  • claimMany over ETH plus two tokens, skipping empties, and duplicate entries.
  • Reentrancy via a malicious creator that re-enters claim from receive(): the bubbling variant reverts the outer claim with EthTransferFailed, the swallowing variant completes with exact amounts and the nested call is rejected. claimMany is checked the same way.
  • A creator that reverts on ETH receive makes claim revert and nothing moves (documented above). Same for the platform.
  • An ERC-20 whose transfer returns false reverts through SafeERC20.
  • Factory: computeAddress == deploy result, manual CREATE2 derivation with vm.computeCreate2Address, idempotent redeploy with no second event, splitterOf, correct recipients on the deployed splitter, different creators and different platforms give different addresses, zero creator reverts.
  • Fuzz (512 runs each, amounts bound to uint128): ETH claim, ERC-20 claim, pending, pure split invariants, claimMany with two independent balances, and factory determinism over arbitrary creator addresses.

Coverage

forge coverage --report summary --no-match-coverage "(script|test)":

| File                       | % Lines         | % Statements    | % Branches      | % Funcs         |
|----------------------------|-----------------|-----------------|-----------------|-----------------|
| src/FeeSplitter.sol        | 100.00% (33/33) | 100.00% (42/42) | 100.00% (10/10) | 100.00% (8/8)   |
| src/FeeSplitterFactory.sol | 100.00% (15/15) | 100.00% (15/15) | 100.00% (2/2)   | 100.00% (4/4)   |
| Total                      | 100.00% (48/48) | 100.00% (57/57) | 100.00% (12/12) | 100.00% (12/12) |

The unfiltered report also lists script/*.s.sol at 0% (deploy scripts are exercised against anvil, not by unit tests) and the test mocks.

Deploy and verify

Environment variables (see contracts/.env.example):

VariableUsed byMeaning
ROBINHOOD_TESTNET_RPC_URLfoundry.toml alias robinhood_testnethttps://rpc.testnet.chain.robinhood.com
BLOCKSCOUT_API_KEY[etherscan] blockany non-empty string; Blockscout ignores it
PLATFORM_ADDRESSscript/Deploy.s.soltreasury that receives the 25% share
ESCROW_ADDRESSscript/Deploy.s.solPons fee escrow every splitter collects from (zero address for none)
FACTORY, CREATORscript/DeploySplitter.s.solfactory address and creator to deploy for

Use a keystore account (cast wallet import talon-deployer --interactive) and pass --account talon-deployer. Never put a raw private key in a shell history or a .env that could be committed.

Testnet (46630)

cd contracts
export ROBINHOOD_TESTNET_RPC_URL=https://rpc.testnet.chain.robinhood.com
export BLOCKSCOUT_API_KEY=unused

# 1. Factory
PLATFORM_ADDRESS=0x<treasury> forge script script/Deploy.s.sol \
  --rpc-url robinhood_testnet --broadcast --account talon-deployer \
  --verify --verifier blockscout --verifier-url https://explorer.testnet.chain.robinhood.com/api

# 2. One splitter (optional smoke test; the app normally calls factory.deploy itself)
FACTORY=0x<factory> CREATOR=0x<agent-wallet> forge script script/DeploySplitter.s.sol \
  --rpc-url robinhood_testnet --broadcast --account talon-deployer

Mainnet (4663)

cd contracts
export BLOCKSCOUT_API_KEY=unused

PLATFORM_ADDRESS=0x<treasury> forge script script/Deploy.s.sol \
  --rpc-url robinhood --broadcast --account talon-deployer \
  --verify --verifier blockscout --verifier-url https://robinhoodchain.blockscout.com/api

Verifying after the fact

If --verify was skipped or Blockscout was slow, verify the factory manually:

forge verify-contract 0x<factory> src/FeeSplitterFactory.sol:FeeSplitterFactory \
  --chain 4663 --verifier blockscout --verifier-url https://robinhoodchain.blockscout.com/api \
  --constructor-args $(cast abi-encode "constructor(address)" 0x<treasury>)

Splitters deployed by the factory share one bytecode, so once one is verified Blockscout matches the rest automatically. To verify a specific splitter anyway:

forge verify-contract 0x<splitter> src/FeeSplitter.sol:FeeSplitter \
  --chain 4663 --verifier blockscout --verifier-url https://robinhoodchain.blockscout.com/api \
  --constructor-args $(cast abi-encode "constructor(address,address)" 0x<creator> 0x<treasury>)

Use --chain 46630 and the testnet verifier URL for testnet.

Local end-to-end check

Both scripts were exercised against a local anvil: Deploy produced a factory, DeploySplitter deployed a splitter at exactly computeAddress(creator), and a second DeploySplitter run returned the same address with status already existed and no new transaction.

Deployed addresses

ChainChain idFeeSplitterFactoryPlatform treasuryDeploy tx
Robinhood Chain testnet46630not yet deployednot yet deployed
Robinhood Chain mainnet46630x1F911e9f1c6caF205af99749c12726A7478Fee92 (v2)platform 0xEF235b874a7BC1dACEC00AEA4efA93C5eabA756C, escrow 0xd3AFEB2a57f70eF218Aa82451c51B2fb0416Ac9etx 0xb3fa...04b6, block 78911117, 2026-10-03

Once filled in, mirror the factory and treasury addresses into packages/chain/src/addresses under talon.feeSplitterFactory and talon.platformTreasury.

Security notes

  • No admin keys, no proxies, no selfdestruct, no external calls except the two payouts and ERC-20 balanceOf / transfer.
  • The only state is OpenZeppelin's reentrancy flag; recipients and constants are compiled into the bytecode.
  • Anyone can trigger a claim, which is a feature: the platform can sweep fees for creators without custody, and creators can sweep without depending on the platform.
  • A malicious or broken creator can only block its own ETH payouts, never the platform's share of other creators or any ERC-20 payouts.
  • The factory's deploy is permissionless; front-running it is harmless since the address and the constructor arguments are fixed by the salt.
  • Reported issues go through SECURITY.md.