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.
| Chain | Chain id | Native gas | RPC | Explorer |
|---|---|---|---|---|
| Robinhood Chain mainnet | 4663 | ETH | https://rpc.mainnet.chain.robinhood.com | https://robinhoodchain.blockscout.com |
| Robinhood Chain testnet | 46630 | ETH | https://rpc.testnet.chain.robinhood.com | https://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:
- 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%. - 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 == totalholds exactly. - 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.
| Item | Value |
|---|---|
CREATOR_BPS | 7500 (constant) |
BPS | 10000 (constant) |
creator | immutable, receives CREATOR_BPS / BPS of every claim |
platform | immutable, receives the remainder |
| Funding | receive() for ETH; ERC-20s by plain transfer to the splitter address |
| Access | claim and claimMany are permissionless; funds can only go to the two fixed recipients |
| Errors | ZeroAddress(), NothingToClaim(), EthTransferFailed(address to) |
| Event | Claimed(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 withNothingToClaim().claimMany(tokens)skips tokens with a zero balance and reverts withNothingToClaim()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
calland all gas is forwarded. Ifcreatororplatformis a contract that rejects ETH, the claim reverts withEthTransferFailed(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:
claimandclaimManyarenonReentrant. A creator that re-entersclaimfrom itsreceive()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)
| Item | Value |
|---|---|
platform | immutable, 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 |
| Salt | keccak256(abi.encode(creator, platform)) |
| Event | SplitterDeployed(address indexed creator, address indexed platform, address splitter) (emitted once per creator) |
| Errors | ZeroAddress() 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:
| Recipient | Share |
|---|---|
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 |
platform | PLATFORM_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)
| Balance | Creator (75%) | Platform (25%) | Note |
|---|---|---|---|
| 1 | 0 | 1 | creator transfer skipped |
| 2 | 1 | 1 | 1.5 rounds down |
| 3 | 2 | 1 | 2.25 rounds down |
| 4 | 3 | 1 | exact |
| 7 | 5 | 2 | 5.25 rounds down |
| 99 | 74 | 25 | 74.25 rounds down |
| 10001 | 7500 | 2501 | |
| 100 ETH | 75 ETH | 25 ETH | exact |
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
- Resolve the splitter. For an agent wallet
creator, the launch tool callsfactory.splitterOf(creator). If it isaddress(0), it either callsfactory.deploy(creator)first or usesfactory.computeAddress(creator)and deploys lazily later. Both give the same address. - 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_addresscolumn inpackages/dbrecords which splitter belongs to the launch. - 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/sweepPoolon 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 withNoBalance()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):claimandclaimManyfirst collect the splitter's escrow credit for each token, then split everything held.pending(token)andescrowBalance(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. - Index earnings. The worker's fee job reads
pending(token)for each(splitter, feeAsset)pair and watchesClaimedevents, updating theearningstable (accrued vs claimed). - 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. NothingToClaimon empty ETH, empty ERC-20, a second claim, an all-emptyclaimMany, and an empty array.claimManyover ETH plus two tokens, skipping empties, and duplicate entries.- Reentrancy via a malicious creator that re-enters
claimfromreceive(): the bubbling variant reverts the outer claim withEthTransferFailed, the swallowing variant completes with exact amounts and the nested call is rejected.claimManyis checked the same way. - A creator that reverts on ETH receive makes
claimrevert and nothing moves (documented above). Same for the platform. - An ERC-20 whose
transferreturnsfalsereverts throughSafeERC20. - Factory:
computeAddress == deployresult, manual CREATE2 derivation withvm.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,claimManywith 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):
| Variable | Used by | Meaning |
|---|---|---|
ROBINHOOD_TESTNET_RPC_URL | foundry.toml alias robinhood_testnet | https://rpc.testnet.chain.robinhood.com |
BLOCKSCOUT_API_KEY | [etherscan] block | any non-empty string; Blockscout ignores it |
PLATFORM_ADDRESS | script/Deploy.s.sol | treasury that receives the 25% share |
ESCROW_ADDRESS | script/Deploy.s.sol | Pons fee escrow every splitter collects from (zero address for none) |
FACTORY, CREATOR | script/DeploySplitter.s.sol | factory 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
| Chain | Chain id | FeeSplitterFactory | Platform treasury | Deploy tx |
|---|---|---|---|---|
| Robinhood Chain testnet | 46630 | not yet deployed | not yet deployed | |
| Robinhood Chain mainnet | 4663 | 0x1F911e9f1c6caF205af99749c12726A7478Fee92 (v2) | platform 0xEF235b874a7BC1dACEC00AEA4efA93C5eabA756C, escrow 0xd3AFEB2a57f70eF218Aa82451c51B2fb0416Ac9e | tx 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-20balanceOf/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
deployis permissionless; front-running it is harmless since the address and the constructor arguments are fixed by the salt. - Reported issues go through
SECURITY.md.