Security
Where a payment is refused. And what we have not done.
Three separate checks stand between an agent and the money, and each refuses for its own reasons. Everything below names the file or the test behind it, so you can go and disagree with us.
Four keys
Four keys, and what each one cannot do.
Every action on a channel is signed by one of four keys. None of them can do another’s job, and none of them can change what the agent signed.
The owner’s wallet
Any Solana wallet: Phantom, Solflare, Backpack. It funds the channel and sets its terms.
Can
- Open a channel and deposit into it
- Add funds, raise the ceiling or extend the expiry
- Bring expiry forward to five minutes from now, with
request_close - Reclaim everything if nobody settles within an hour of expiry
Cannot
- Lower a ceiling
- Shorten an expiry, except by that five-minute notice
- Settle or propose before expiry
- Reclaim before the hour of grace has passed, or while a settlement is under dispute
The agent key
An ed25519 key the owner names when opening the channel. It signs meter states, not transactions, so it never needs SOL.
Can
- Sign a new running total for each charge
- Propose a settlement before expiry, open for ten minutes to a newer state
Cannot
- End its channel with an old state: anyone holding a newer one replaces the proposal
- Get a state over the ceiling settled
- Pay a wallet that is not one of the channel’s payees
- Change the deposit, the ceiling or the payees
The operator
An optional metering service the owner names when opening the channel.
Can
- Settle at once with a state the agent signed, when the owner or agent asks
- Challenge a stale proposal with the newest state it holds
Cannot
- Change an amount
- Sign a state of its own
- Overtake a proposal under dispute
- Touch the deposit any other way
The faucet
Dinara’s devnet key. It is the test dollar’s mint authority and has no role in the program.
Can
- Mint test dollars
- Send a little devnet SOL with them
Cannot
- Open, settle or reclaim a channel
- Move a test dollar out of anybody’s wallet
Three refusals
Three places can refuse a payment, and none trusts the others.
The SDK refuses before the agent signs. The payee refuses before it serves. The program refuses before money moves. Each runs its own checks, so a bug in one is not a hole in all three.
The agent’s SDK
A charge the channel would refuse is never signed.
MeterSession.chargechecks each charge against the channel’s terms before it signs: a payee that is not on the list, a charge that would pass the ceiling, an expired channel, or a quoted price above the agent’s ownmaxPrice. It throws aMeterRefusalwith the reason, and nothing exists to settle.In the repository
packages/sdk/src/agent.tsThe payee
An API serves only against a voucher it has checked itself.
verifyPaymentruns on the payee’s own server and asks Dinara for nothing. It refuses a bad signature, a voucher for another channel or payee, a closed or expired channel, a state over the ceiling, a charge below the price, and anything older than the newest voucher it already accepted.In the repository
packages/sdk/src/payee.tsThe program
Settlement pays what the agent signed, and nothing else.
settlereads the agent’s signed state back from the Ed25519 instruction just before it. It fails withMissingSignaturewhen that instruction is absent or not adjacent,WrongSignerfor any key but the agent’s,CeilingExceededfor a state over the ceiling,InvalidPayeeAccountfor a token account that is not the payee’s, andSettlerNotAllowedfor anyone but the operator. Everyone else proposes, and a proposal stays open to a newer state.In the repository
programs/dinara/src/instructions/settle.rsprograms/dinara/src/meter.rs
No layer trusts the answer of the one before it. The program checks the ceiling again although the SDK already did, because a payee can be handed a state that never went through the SDK.
The path of a paid call
Five steps between a call and a payment.
The API names a price and the agent signs for it. Three of the five steps can refuse on their own, and none of them takes the previous one’s word for anything. Refusing is not an error, which is why one of the two endings is amber and neither is red.
Five stages in order, then two endings. The third, fourth and fifth stages can each refuse on their own, and no stage trusts the answer of the one before it.
The call
The agent calls an API through
payingFetch, the way it would callfetch.Refuses nothing. Nothing has been asked for yet.
The 402
The API answers 402 Payment Required, with its price in
x-dinara-priceand its wallet inx-dinara-payee.Refuses nothing. It only names a price.
The SDK
The agent’s session checks the charge against the channel, signs a new running total and retries with it in
x-dinara-voucher.Refuses an unknown payee, a charge over the ceiling, an expired channel. Nothing signed.
The payee
verifyPaymentchecks the voucher on the API’s own server and keeps the newest one. Then the API serves.Refuses a bad signature, an underpaid or a stale voucher. Not served.
The program
At the end, one settlement carries the newest signed state on chain.
Refuses a wrong signer, a state over the ceiling, a wallet that is not a payee. No money moved.
Two endings
- Paid
$39.99 to three payees, in one transaction.
The receipt records what each payee’s token account gained and what came back to the owner, so the figures written down are the figures that moved.
- Refused
Nothing signed, nothing owed.
Each refusal carries its reason: a
MeterRefusalfrom the SDK, a reason fromverifyPayment, or the program’s own error. None of them leaves a payment half done.
Enforced on chain
The rules the program enforces.
Each rule is checked by the program that holds the deposit, and each has a test that it holds and a test that breaking it is refused with the program’s own error. The quoted names are the tests in packages/sdk/test/program.test.ts.
- The ceiling is funded
- A ceiling is never more than the deposit, so every metered dollar exists. Opening a channel with a ceiling above its deposit fails with
InvalidCeiling.programs/dinara/src/instructions/open_channel.rs“refuses a ceiling above the deposit” - Terms only widen
top_upadds funds, raises the ceiling or extends the expiry, and nothing else. A lower ceiling isCeilingLowered; an earlier expiry isExpiryShortened. Ending early goes throughrequest_close, which leaves five minutes to settle.programs/dinara/src/instructions/top_up.rs“never lowers a ceiling or shortens an expiry”- Only the agent’s signature counts
- The signed state must arrive in the Ed25519 precompile instruction directly before
settle. A state signed by any other key isWrongSigner, a missing or misplaced signature isMissingSignature, and a state signed for another channel isWrongChannel.programs/dinara/src/meter.rs“refuses a state signed by any key but the agent's” - Never over the ceiling
- A state whose total is above the ceiling is
CeilingExceeded, even one the agent signed.programs/dinara/src/instructions/settle.rs“refuses a state over the ceiling, even one the agent signed” - Only the channel’s payees
- Each line of a state names a payee by its place on the channel, and must arrive with that payee’s own token account. Anything else is
InvalidPayeeIndexorInvalidPayeeAccount.programs/dinara/src/instructions/settle.rs“refuses a payee token account that belongs to someone else” - Only the operator settles at once
settleis the operator’s alone; anyone else isSettlerNotAllowed, the agent included, before or after expiry. Everyone else usespropose_settlement: the agent or operator before expiry, anyone after, so a payee is never stuck behind a silent agent.programs/dinara/src/instructions/settle.rs“lets nobody but the operator settle at once: not the agent, the owner or a stranger, even after expiry”- A stale state cannot end a channel
- A proposal stays open for ten minutes. Anyone holding a later agent-signed state replaces it with
challenge_settlement; an earlier or equal one isStaleState. Only when the window closes can anyonefinalize_settlement, and the owner cannot reclaim while it runs (DisputeInProgress).programs/dinara/src/instructions/challenge_settlement.rs“stops an agent ending its channel with a stale state: a payee replaces it with the newest” - An hour of grace, then the owner
- If nobody settles within an hour of expiry,
reclaimreturns everything to the owner. Before then it fails withGraceNotOver.programs/dinara/src/instructions/reclaim.rs“returns everything to the owner once the grace period has passed, and not before” - Measured, not claimed
- Each payment is checked against the payee’s balance before and after it, and a difference fails with
MeasuredMismatch. The receipt stores those measured amounts, the refund, how many events were metered, and the head of their hash chain.programs/dinara/src/instructions/settle.rs“pays each payee its signed amount, refunds the rest, and writes a measured receipt” - Exactly once
- A channel ends in one receipt, at an address derived from the channel. A second settlement, or a settlement after a reclaim, is refused.
programs/dinara/src/instructions/settle.rs“settles exactly once”“cannot settle a channel that was reclaimed”
The program is deployed on Solana devnet at HDJy6t6uy5tAsiZrTqJU1HrEc6rnXptn2x4paydtgqd8 Open on the Solana explorer
Stated plainly
What is not true yet.
This is the half that usually gets left off. It is here at the same size as everything above it, because a page about what refuses a payment is not worth reading if it is quiet about what does not.
A payee has to answer within the window
A stale proposal is only replaced if someone holding a newer state challenges it within ten minutes. Dinara’s operator does so for the vouchers it has seen; a payee that keeps its vouchers to itself has to watch for proposals. Ten minutes suits a devnet demo; a real deployment would use hours.
Payees trust the operator they accept
The operator settles at once, without a window, with a state the agent signed. It cannot change an amount, but it could pick an older state, so a payee should only serve channels whose operator it trusts: the SDK’s
verifyPaymenttakestrustedOperators, and the demo APIs accept only Dinara’s. A channel with no operator always settles through the window.Payees must keep their newest voucher
The newest voucher is all a payee needs to be paid, and nobody holds it on its behalf. The app keeps a log of the vouchers its demo APIs accept, but that log is a convenience for the screen, not a guarantee: a payee that loses its newest voucher can only settle for less.
Devnet only, and the dollar is a test dollar
Everything runs on Solana devnet. The test dollar is a devnet token with six decimals, like USDC, minted by the faucet. It has no value, and nothing here should ever receive real funds.
Nobody outside this project has reviewed it
There has been no independent security review. The tests were written beside the code by the people who wrote the code, which is the arrangement least likely to find what the author did not think of. Read the program as unaudited work, because that is what it is.
If a claim on this page is not backed by a file you can open or a test you can run, it should not be here. Report a security issue to the 71Labs team where you found this project.