Crypto & Payments · October 2, 2026 · 11 min read

The Chain Can't Know Who Agreed

A smart contract will accept any revenue split that sums to 100%. It cannot know whether the collaborators approved it. That gap is real, our threat model found it, and closing it meant making the server the deploy authority.

Bradley Jackson
Bradley Jackson
Founder & Principal Engineer

A Smart Contract Will Mint a 100/0 Split That Everyone Signed as 50/50

Here is an uncomfortable fact about onchain revenue splits: the contract does not care who agreed to them. It will accept any allocation set that sums to 100%. It enforces arithmetic, structure, and ownership with total reliability, and it has absolutely no idea whether the numbers it just made permanent are the numbers the collaborators actually signed.

I know this because our pre-launch threat model on SoundMint found exactly that gap in our own system, sitting in a flow we had already built and tested. The onchain payload was client-supplied. Which meant, in principle, an owner could collect signatures on one split and deploy a different one.

Before I go further, the honest footnote that I think makes this story more useful, not less: production was in mock mode when the gap existed. No real transactions, no real money, nothing exploitable. We found it with a threat model, not an incident report. The decision record we wrote at the time says it plainly: none of this was exploitable in production, but all of it was a must-fix before going live. That is the whole point of threat modeling before you flip the switch. You get to find the embarrassing thing on paper.

This post is about what we found, why the fix inverts the usual "trustless" story, and why I now believe the most important security boundary in any onchain product is the one between what the chain can verify and what it structurally cannot.

Consent lives off-chain, and no pitch deck mentions it

The standard pitch for onchain revenue splits goes something like: the splits are enforced by an immutable smart contract, so nobody has to trust anybody. Payments route automatically. Code is law.

Every word of that is true, and it quietly skips the hardest part. The contract enforces the split it was given. It cannot enforce the split that was agreed.

SoundMint's protocol contracts are audited and they enforce strong structural invariants. The vault's initializer requires between 1 and 50 creator allocations, each with a non-zero wallet and non-zero basis points, summing to exactly 10,000. The staking path requires that the caller owns the song NFT, blocks double-staking, and enforces a minimum 51% artist retention. The factory requires that the artist parameter equals the transaction sender, so only the artist can mint. All of that is solid, and none of it touches the question that matters most to the people splitting the money.

Our internal security doc puts it in one line: the contract will happily mint a 100/0 split that sums to 100%; it has no idea the collaborators signed 50/50.

Agreement between humans is an off-chain event. Someone said yes to a specific set of numbers at a specific moment. A signature can prove that. But unless something binds that signature to the exact payload that reaches the chain, the chain is enforcing a number, not an agreement. That binding cannot live in the contract, because the contract only ever sees the final calldata. It has to live somewhere that can see both the agreement and the transaction.

For us, that somewhere is the backend.

What the threat model actually found

The artist-side mint flow, as originally built, worked like this: collaborators approved their splits, and at deploy time the client sent the deploy configuration, including the creator allocations, to the server, which passed it along to the onchain worker.

The threat model asked a boring question: what binds the deployed allocations to the approved ones?

The answer was: nothing. The payload was client-supplied and not bound to the approved cap table. Three failure modes fell out immediately:

  1. An owner could collect signatures on one split and deploy a different one. Gather everyone's approval on 50/50, then ship 100/0. The contract checks that 10,000 basis points sum correctly. 100/0 sums correctly.
  2. A deploy could happen before the splits were finalized at all, meaning before anyone had consented to anything.
  3. A malformed or zero-dollar config could ship, because nothing server-side validated the economics of what was going out.

Again, all of this lived behind mock mode. But "the only thing standing between us and a consent-forgery bug is an environment variable" is not a security posture. So we wrote a decision record, dated June 19, 2026, with a one-line thesis: the backend is the deploy authority.

Making "finalized" actually mean something

The first half of the fix is a state machine that turns "everyone agreed" from a vibe into a checkable predicate.

When an owner submits splits for approval, the server pins a deterministic snapshot of the cap table: each collaborator's user ID and revenue percentage, hashed into a stable digest, with a revision number that bumps on every resubmission. Every collaborator then signs an EIP-712 typed-data payload that is bound to that snapshot hash and revision. The signature is the only valid proof of consent; the server never trusts a raw "approve me" request without verifying the typed-data signature and recovering the signer's address. Signatures bound to a superseded snapshot are invalid by construction, so an approval cannot be replayed against numbers the signer never saw.

Any edit to the cap table, whether it is a percentage change, an added payee, or a removed one, voids all existing approvals and drops the workflow back to draft. There is no path where the numbers change and stale signatures survive.

Finalization is where it converges. FinalizeSplit walks every active collaborator and refuses the transition if a single one has not approved the current snapshot. When it passes, the server persists an immutable split snapshot on the track record: the snapshot hash, the total basis points, the collector price, the full allocation list, who finalized it and when. That snapshot is what the deploy path reads.

There is one design detail here I am particularly fond of: the snapshot hash binds only the economic agreement, the user IDs and percentages. Payout wallets are deliberately excluded, because each collaborator's payout wallet is set from the address they signed with at approval time. Signer sets own wallet. That means one person can set or change their own payout destination without invalidating everyone else's signatures, while still guaranteeing that the wallet receiving the money is a wallet its owner actually controlled and signed from. Rejections require a signature too, for the same reason approvals do: the record of who declined what is part of the audit trail.

So by the time a track reaches finalized, the system can prove a specific claim: every active collaborator produced a valid EIP-712 signature over this exact cap table. Not a similar one. This one.

The server derives the payload, full stop

The second half of the fix is the inversion. The old model was: client proposes a payload, server forwards it. The new model is: the server derives the entire consent-bearing payload from the finalized cap table, and client input can never change who gets paid.

The heart of it is a function called deriveCreatorAllocations. At deploy time it rebuilds the onchain allocation set server-side from the current cap table. It validates that every payee has a syntactically valid, unique EVM payout wallet and a non-zero share, and that the cap table fits the contract's 1-to-50 bound. Then it converts percentages, stored to two decimal places, into basis points using largest-remainder rounding, so the set sums to exactly 10,000. The contract reverts on anything else, and floats do not divide cleanly, so the arithmetic is done in integers: floor every share, then hand the leftover basis points to the largest fractional remainders in stable order. If the result is anything other than exactly 10,000, the deploy fails closed rather than shipping an allocation set the contract would reject.

Whatever allocations the client sent are overwritten. There is a comment in the guard file that I left deliberately blunt: deploy must use this and never trust client-supplied splits.

One more layer, because a state machine alone is not enough. deriveCreatorAllocations also returns the snapshot hash of the live cap table, and the deploy gate refuses unless that hash equals the hash that was pinned at finalization. Suppose something mutated the cap table out of band, after everyone signed but before the deploy executed: a direct database write, a bug, a migration. The hashes diverge, the deploy refuses, and the only way forward is to re-finalize, which means re-consent. The window between "everyone agreed" and "it shipped" is closed at both ends.

The same derivation runs at finalize time, which gives it a second job: you cannot finalize a cap table that could not deploy. A missing wallet or a 0% payee is surfaced to the owner while it is still fixable, instead of stranding a fully-signed agreement that can never execute.

Verify what mined, not what you sent

Here is the wrinkle that makes this genuinely interesting: the server cannot sign the deploy transaction. The contract requires that the transaction sender is the artist, which is the right invariant, but it means the artist's own wallet signs client-side, in a browser, via Privy. The calldata is constructed in an environment the server does not control. The deploy authority hands the client a server-authoritative deploy config, the approved allocations and price, and the client is supposed to sign with those values. Supposed to.

So the last gate does not trust the send at all. It verifies the mine.

After the transaction is mined, the client posts the transaction hash to a confirm endpoint. An independent verifier reads the mined transaction over raw JSON-RPC and fails closed unless the receipt shows success with at least one confirmation, the sender is the artist's wallet, the recipient is the expected contract, and, this is the check that matters, the creator allocations and collector price decoded from the transaction's actual calldata exactly equal the approved set. Our security doc names it directly as the check that stops an owner collecting 50/50 signatures and then deploying 100/0. The deployed contract addresses are taken from the emitted events, never from anything the client claims. Only when every check passes does the intent confirm and any side effect apply. In live mode, a missing verifier is fatal: the confirm path refuses rather than fall back to trusting client input.

The test suite exercises this with real ABI-encoded stake() calldata served from a fake JSON-RPC node, and asserts that tampered allocations, tampered prices, wrong senders, reverted transactions, and unmined transactions are all rejected.

The end-to-end property, as the decision record states it: finalized means everyone signed the exact cap table, and deployed allocations equal that cap table. Consent is provable at both ends of the pipe.

What I'd tell anyone building "trustless" anything

The lesson I took from this is not "smart contracts are overrated." The contracts do their job well, and I would not want to reimplement no-double-stake or the 10,000 basis point invariant in application code. The lesson is about the shape of the trust boundary.

Contracts enforce structural invariants. Consent is not a structural invariant. It is a fact about what specific humans agreed to, off-chain, at a specific time, and no amount of Solidity can reach it. Any system that routes money based on human agreement needs an off-chain component that can prove the agreement and bind it, cryptographically and procedurally, to the exact bytes that reach the chain. Ours does it three times over: signatures bound to a snapshot hash, a server-derived payload checked against that hash, and a post-mine verifier that decodes what actually landed.

And the reason I can write about this calmly is the part I flagged at the top. We found this on paper, in mock mode, before a single real transaction existed, because we sat down and threat modeled the flow as if it were already live. The gap was never exploitable. It also absolutely would have shipped if we had treated the audit of the contracts as the end of the security story instead of the beginning.

The chain cannot know who agreed. Something in your stack has to. Decide what that something is before it matters, and make it refuse everything else.

smart contracts · threat modeling · base · revenue splits · security · eip-712