Skip to content

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.

  1. The agent’s SDK

    A charge the channel would refuse is never signed.

    MeterSession.charge checks 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 own maxPrice. It throws a MeterRefusal with the reason, and nothing exists to settle.

    In the repositorypackages/sdk/src/agent.ts

  2. The payee

    An API serves only against a voucher it has checked itself.

    verifyPayment runs 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 repositorypackages/sdk/src/payee.ts

  3. The program

    Settlement pays what the agent signed, and nothing else.

    settle reads the agent’s signed state back from the Ed25519 instruction just before it. It fails with MissingSignature when that instruction is absent or not adjacent, WrongSigner for any key but the agent’s, CeilingExceeded for a state over the ceiling, InvalidPayeeAccount for a token account that is not the payee’s, and SettlerNotAllowed for anyone but the operator. Everyone else proposes, and a proposal stays open to a newer state.

    In the repositoryprograms/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.

  1. The call

    The agent calls an API through payingFetch, the way it would call fetch.

    Refuses nothing. Nothing has been asked for yet.

  2. The 402

    The API answers 402 Payment Required, with its price in x-dinara-price and its wallet in x-dinara-payee.

    Refuses nothing. It only names a price.

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

  4. The payee

    verifyPayment checks 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.

  5. 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 MeterRefusal from the SDK, a reason from verifyPayment, or the program’s own error. None of them leaves a payment half done.

A diagram of the path a paid call takes, not a screenshot. No check here trusts the answer of the one before it.

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_up adds funds, raises the ceiling or extends the expiry, and nothing else. A lower ceiling is CeilingLowered; an earlier expiry is ExpiryShortened. Ending early goes through request_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 is WrongSigner, a missing or misplaced signature is MissingSignature, and a state signed for another channel is WrongChannel.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 InvalidPayeeIndex or InvalidPayeeAccount.programs/dinara/src/instructions/settle.rs“refuses a payee token account that belongs to someone else”
Only the operator settles at once
settle is the operator’s alone; anyone else is SettlerNotAllowed, the agent included, before or after expiry. Everyone else uses propose_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 is StaleState. Only when the window closes can anyone finalize_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, reclaim returns everything to the owner. Before then it fails with GraceNotOver.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 verifyPayment takes trustedOperators, 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.