Where the spending rules live: agent authorization on AWS vs Sui
A bot that can pay you can also drain you.
That sentence is the whole problem. The moment you give an autonomous agent a wallet, you have handed a non-deterministic process the ability to move money. The interesting failure mode is not that the agent is slow. It is that the agent is wrong, or confused, or quietly steered by a poisoned web page it read three tool-calls ago, and it signs a payment it never should have signed.
A lot of the Basecamp coverage I read fixated on throughput. And yes, there was a headline number: a CertiK-verified 40,614,180 TPS. The precise version of that claim matters, because most recaps skip it. It was not base-layer, on-chain throughput. It was an off-chain "Sui tunnels" agent-simulation stress test, more than 10,000 parallel tunnels, with the result settled to mainnet. Impressive, but it answers "how fast can agents transact?" and that is not the hard question.
The hard question is authorization. When an agent is allowed to spend, who or what enforces the limit, and can that enforcement be talked out of? Throughput is the recap everyone writes. Authorization is the part nobody wants to do, because it is unglamorous and it is where the money actually leaks.
So I built a small thing and compared it against a real thing I shipped earlier. The real thing is an AWS AgentCore Payments demo where an agent paid for an API by itself. The small new thing is a Sui Move module that enforces a delegated spending allowance on-chain. They are two credible answers to the same question, and they land in genuinely different places. This post is the comparison.
Up front, here is what is real and what is not. The AWS side was built, deployed, and spent real (testnet) money. The Sui side compiles and passes its full unit-test suite, and I deployed it to Sui testnet and exercised it on-chain. That means a real publish, a real successful spend, and a real over-budget abort, all with transaction digests you can look up yourself. The logic is proven by the tests and by live transactions, not by a hand-waved claim. Every digest below is real.
Two philosophies of "trust the rules"
Both systems agree on one thing. You do not trust the agent, you trust the rules. They disagree completely on where the rules live.
AWS AgentCore Payments puts the rules off-chain, at the service layer. The budget is a server-side check inside a trusted admin path. The wallet keys live in Secrets Manager. The agent never holds the credentials and never evaluates the limit itself; it asks AWS to pay, and AWS says yes or no.
Sui puts the rules on-chain, in the type system and the Move runtime. The budget is a set of fields on a capability object. The check is bytecode that runs inside the transaction. There is no admin service to ask; possession of the object is the authorization, and the object carries its own rules into every spend.
One trusts a provider to enforce a policy faithfully. The other trusts published bytecode and a validator set. Neither is "trustless." The honest question is whom exactly are you trusting, and what can that party do if it is wrong or compromised? Hold that question. It is the spine of the comparison table later.
The AWS side (shipped, and here is what broke)
This part I actually ran. On Base Sepolia testnet, with a Stripe/Privy wallet, an agent hit a paywalled endpoint, got an HTTP 402 challenge, settled a stablecoin microtransaction, retried with proof, and got its content back. The payment took about two seconds and cost $0.002 of fake USDC against a $5.00 session budget.
The mechanism is the x402 protocol, which is the payment equivalent of a 401 → auth header → retry dance. Server returns 402 with a signed challenge (amount, asset, recipient, network). The client signs, retries with the proof, gets 200 OK. AgentCore sits in the middle as a resource hierarchy. There is a Payment Manager, a Payment Connector (the wallet-provider link), a Payment Instrument (the embedded per-user wallet), and a Payment Session (the time-bounded budget). The session has exactly two caps: maxSpendAmount and an expiry. maxSpendAmount accumulates within a session, but there is no cross-session lifetime ceiling in AgentCore itself; a longer-term bound comes from how sessions get minted, one layer up. The agent can't extend its own session or spend past its caps.
For this comparison, the key is where the limit gets enforced. I wrote this at the time:
The budget enforcement isn't in the prompt. It's not a guardrail the model might ignore. When a payment would exceed
maxSpendAmountor the session has expired, AgentCore rejects theProcessPaymentcall before anything gets signed. The model never sees the wallet credentials, they live in Secrets Manager and AgentCore retrieves them at signing time.
That is the strong claim, and it holds up. The cap is a deterministic check at the API layer, not a sentence in a system prompt. Prompt injection cannot spend past the budget, because the model is not the thing holding the budget.
Here are the limits I documented at the time.
- Delegation needs a browser. The wallet exists, but the agent cannot sign until the wallet owner logs into Privy's reference frontend and clicks "Give access." The agent becomes an authorized signer; the owner keeps ownership and can revoke anytime. There is no CLI-only path for the full setup. I burned an hour here.
-
Coinbase CDP was geo-blocked. From Kenya, the Coinbase developer portal returned
405 Not Allowed. Privy worked globally, which is why I switched. - Testnet only for free. Mainnet means real USDC plus gas.
- Stablecoin only. The agent pays in USDC. No fiat card-to-API path.
- The merchant side is tiny. A handful of sandbox endpoints. The protocol is sound; adoption is early.
So the AWS model comes down to an owner-revocable delegated signer and a deterministic server-side budget, with keys in a trusted admin path and a human-in-the-browser step you cannot script away.
The Sui side (the new thing)
Now the on-chain version. I wanted to test one thesis. Can the budget be the object, so there is no service to ask and no prompt to subvert?
The module is agent_auth::agent_auth. It has three objects. A Vault is a shared object holding a Balance<SUI>. An OwnerCap is the owner's authority over one vault. A SpendingCap is the delegated, bounded allowance that gets transferred to the agent. The rules travel on the SpendingCap:
public struct SpendingCap has key, store {
id: UID,
vault_id: ID,
per_period_limit: u64,
period_spent: u64,
period_epoch: u64,
total_cap: u64,
total_spent: u64,
expiry_epoch: u64,
revoked: bool,
}
There is no address list and no stored key anywhere. Authority is object possession. Whoever holds the SpendingCap can spend, but only within the fields baked into it, and only against the one vault its vault_id is bound to.
The enforcement is the spend function. Every call re-checks every rule and aborts with a specific named code on any violation. The abort codes are real constants in the source:
const ECapRevoked: u64 = 1;
const ECapExpired: u64 = 2;
const EExceedsPeriodLimit: u64 = 3;
const EExceedsTotalCap: u64 = 4;
const EWrongVault: u64 = 5;
const EInsufficientVault: u64 = 6;
const ECapVaultMismatch: u64 = 7;
const EZeroAmount: u64 = 8;
And here is the actual body of spend, checks in order, nothing hidden:
public entry fun spend(
cap: &mut SpendingCap,
vault: &mut Vault,
amount: u64,
recipient: address,
ctx: &mut TxContext,
) {
assert!(!cap.revoked, ECapRevoked);
let now = tx_context::epoch(ctx);
assert!(now <= cap.expiry_epoch, ECapExpired);
assert!(cap.vault_id == object::id(vault), EWrongVault);
assert!(amount > 0, EZeroAmount);
// Per-period reset: a new epoch starts a fresh period budget.
if (now > cap.period_epoch) {
cap.period_epoch = now;
cap.period_spent = 0;
};
// Per-period limit (checked against the possibly-reset counter).
assert!(cap.period_spent + amount <= cap.per_period_limit, EExceedsPeriodLimit);
// Lifetime total cap.
assert!(cap.total_spent + amount <= cap.total_cap, EExceedsTotalCap);
// Funds available.
assert!(balance::value(&vault.balance) >= amount, EInsufficientVault);
// Effects: take funds, bump counters, transfer out.
let paid = coin::take(&mut vault.balance, amount, ctx);
cap.period_spent = cap.period_spent + amount;
cap.total_spent = cap.total_spent + amount;
transfer::public_transfer(paid, recipient);
}
Read that against the AWS model. The AWS budget check lives in a service you call. This budget check lives in bytecode that is the transaction. There is no ProcessPayment endpoint to talk to and no model in the loop that could be convinced to call it with the wrong number. If the agent tries to overspend, the transaction does not "get rejected by a gateway"; it aborts, atomically, and nothing moved.
Revocation is meant to be an owner-only kill switch, gated by the OwnerCap:
public entry fun revoke(owner_cap: &OwnerCap, cap: &mut SpendingCap) {
assert!(owner_cap.vault_id == cap.vault_id, ECapVaultMismatch);
cap.revoked = true;
}
It flips a flag instead of deleting the capability, so a revoked cap stays inspectable and the next spend aborts with ECapRevoked. This looks like the AWS "owner revokes the signer" property, but writing the comparison surfaced a real asymmetry. revoke needs &mut SpendingCap, and once issue_cap transfers the cap to the delegate, only the delegate's own transactions can supply that &mut. On a live network the owner cannot flip the flag on an object they no longer own, so this kill switch is not actually owner-initiated after delegation. The revoke test passes only because the test harness can reach any object by address, a power no on-chain signer has. To make revocation genuinely unilateral you keep the SpendingCap owned and move the kill switch out: a shared RevocationRegistry that revoke writes and spend reads. The owner then kills the cap with a capability they already hold, no browser and no provider, while spend stays a cheap owned-object path. I would make that change before calling this production-ready. The AWS side does not have this problem: revoking the signer is unilateral and never requires the delegate to hand anything back.
It actually runs
The module compiles and the full suite passes. This is the real sui move test output on sui 1.81.0-bf0c491c17b8, pasted verbatim, not a paraphrase:
INCLUDING DEPENDENCY MoveStdlib
INCLUDING DEPENDENCY Sui
BUILDING agent_auth
Running Move unit tests
[ PASS ] agent_auth::agent_auth_tests::test_exceeds_period_limit_aborts
[ PASS ] agent_auth::agent_auth_tests::test_exceeds_total_cap_aborts
[ PASS ] agent_auth::agent_auth_tests::test_expired_cap_aborts
[ PASS ] agent_auth::agent_auth_tests::test_happy_path_spend
[ PASS ] agent_auth::agent_auth_tests::test_insufficient_vault_aborts
[ PASS ] agent_auth::agent_auth_tests::test_period_reset
[ PASS ] agent_auth::agent_auth_tests::test_revoked_cap_aborts
[ PASS ] agent_auth::agent_auth_tests::test_wrong_vault_aborts
[ PASS ] agent_auth::agent_auth_tests::test_zero_amount_aborts
Test result: OK. Total tests: 9; passed: 9; failed: 0
Total number of linter warnings suppressed: 4 (unique lints: 1)
Each expected-failure test asserts the exact named abort code, not "any failure." The revoked-cap test aborts with ECapRevoked. The over-budget test aborts with EExceedsPeriodLimit. The lifetime-cap test advances epochs so the per-period gate is not the thing that trips, then overspends the total_cap and aborts with EExceedsTotalCap. The reset test proves a new epoch gives a fresh per-period budget while total_spent keeps accumulating. That last one is the only "clever" behavior, and it falls straight out of epoch-keyed accounting, with no special code behind it.
And it ran on-chain
I deployed this to Sui testnet and ran it. The package is live, and here are the digests you can look up in a Sui explorer.
-
Package ID:
0x834d12ff622efe5c3b125bf3f28ab6c86c7ba9d3ccdc64de37f80ee2374e6fac -
Publish transaction:
4gFB9WpDq4GBetxnfEoRQBdV5QxDNZWmkG6zc5cAvxWD
Then I exercised the exact behavior the unit tests assert, but against real validators. I created a vault funded with 100000000 MIST, issued a SpendingCap with per_period_limit = 20000000, and made two spends.
-
Successful spend:
9xbNKenFryZaMqNMAJYqhMDJDTEb37Ww2EFabMugF7K5. Spent 10000000 MIST, within the per-period limit. The vault balance went from 100000000 to 90000000 MIST on-chain, and a payout coin was created. -
Over-budget spend (aborted):
EHm57ZAV2n8p3SgPXTnHEuZFukfiuxoUfoWDU3PRorH8. Attempted 15000000 MIST, which would have taken the period total to 25000000 against a 20000000 limit. The transaction aborted on-chain with the raw error1st command aborted within function '0x834d12ff622efe5c3b125bf3f28ab6c86c7ba9d3ccdc64de37f80ee2374e6fac::agent_auth::spend' at instruction 95 with code 3. Code3isEExceedsPeriodLimit, the exact constant in the source. The vault balance stayed at 90000000 MIST. The abort reverted every effect atomically.
That abort is the whole thesis made concrete. The over-spend did not get "rejected by a gateway." The chain refused to execute the state transition and nothing moved. The named code 3 matches the bytecode constant, so the on-chain failure is the same failure the unit test asserts, just signed, settled, and publicly inspectable.
One honest note on gas. The testnet faucet pays into the address balance (an accumulator), not as an owned Coin<SUI> object, so sui client gas showed gasCoins: [] with a 1.00 SUI addressMistBalance. The Sui 1.81.0 CLI auto-sources gas from that address balance for publish and every call, and I funded create_vault by splitting a 100000000 MIST coin off gas inside a PTB. No Move logic changed to make this work.
Head to head
| Dimension | AWS AgentCore Payments | Sui agent_auth
|
|---|---|---|
| Where enforcement lives | Off-chain, service/API layer | On-chain, in the spend bytecode + type system |
| What authorizes the agent | A delegated signer + a time-bounded session | Possession of a SpendingCap object |
| Budget shape | Per-session maxSpendAmount + expiry; no in-service lifetime cap |
Per-epoch per_period_limit and lifetime total_cap, both re-checked every spend
|
| Delegation mechanism | Browser click in Privy's frontend |
transfer a capability object (programmatic) |
| Revocation | Owner revokes signer in Privy (unilateral) |
revoked flag + OwnerCap, but owner-initiated revoke on a delegated owned cap needs a shared registry (see the walkthrough note) |
| Key custody | Keys in Secrets Manager; agent never sees them | No stored keys; delegate never holds owner keys |
| Auditability | CloudWatch audit trail | Public chain state + typed abort codes |
| Trust assumptions | Trust AWS + Privy + Secrets Manager to enforce faithfully | Trust the published Move bytecode + validator set |
| Failure mode | Server rejects ProcessPayment before signing |
Transaction aborts atomically with a named code |
| Prompt-injection resistance | Budget not in prompt; model can't exceed it | Rule is bytecode; model is not in the enforcement path at all |
| Portability | AWS-specific resources + Privy | Any Sui client/PTB; no provider |
| Cost / latency | ~$0.005/wallet op; ~2s payment | A transaction's gas + finality; near-zero to run tests |
| Developer experience | Rich SDK, but a browser step you can't script | Pure code path, but you own key mgmt and gas |
The table flattens things, so here is the prose underneath it.
Prompt injection is the sharpest contrast. In both systems the model cannot overspend, but for different reasons. In AWS, the model is not holding the budget; a trusted service is. In Sui, there is no budget-holding service at all; the limit is a property of the transaction, and the "agent" is just whatever constructs a PTB. A compromised AWS agent still cannot exceed maxSpendAmount, but it is operating against a service that could, in principle, be misconfigured. A compromised Sui delegate cannot exceed the cap because the chain will refuse to execute the state transition. Same outcome, different thing you are trusting.
Trust assumptions cut the other way than people assume. "On-chain" sounds more trustless, and in the narrow sense of enforcement it is. The rule is public and immutable once published, and no admin can quietly raise your cap. But you are now trusting that the Move code is correct, because a bug in spend is law. The AWS model lets a provider patch a bug. The Sui model makes you get it right before publish, or build an upgrade path, which reintroduces an admin you trust. Immutability is a feature and a liability.
Delegation and portability favor Sui; custody and ergonomics favor AWS. Handing an agent a SpendingCap is a transfer call with no browser, no provider account, no linked email. That is genuinely nicer. But AWS keeps your keys in Secrets Manager and gives you a real SDK, CloudWatch trails, and recipient allow/deny policies out of the box. My Sui demo hands you a raw Balance<SUI> and leaves key management entirely to you.
Honest limitations of the Sui demo
I want to be exact about what this is and is not.
-
It is a minimal test case, not production. It uses plain
SUI, not a stablecoin, because that keeps the demo zero-dependency. A real version would spend a stablecoin coin type. -
It was published and exercised on testnet, not mainnet, and not at scale. The package is live at
0x834d12ff622efe5c3b125bf3f28ab6c86c7ba9d3ccdc64de37f80ee2374e6fac(publish digest4gFB9WpDq4GBetxnfEoRQBdV5QxDNZWmkG6zc5cAvxWD), with a real successful spend (9xbNKenFryZaMqNMAJYqhMDJDTEb37Ww2EFabMugF7K5) and a real over-budget abort (EHm57ZAV2n8p3SgPXTnHEuZFukfiuxoUfoWDU3PRorH8, code3). That is a single run on testnet, not a mainnet deployment and not a load test. The README has the exact commands to fund, publish, and run a real spend yourself. - No key management or encryption. There is no Seal integration, no sealed secrets, no encrypted off-chain payload. A production system that needs to protect data (not just cap spend) would want something like Seal for key management; this demo deliberately does not go there.
- What I did not test. Concurrent spends racing on the same shared vault under real validator conditions, gas-cost behavior at scale, upgrade or migration of the capability, and anything about front-running a shared-object transaction. Unit tests prove the rules. They do not prove operational behavior on a live network.
- The epoch model is coarse. "Per period" means "per Sui epoch," which is roughly a day, not an arbitrary rolling window. That is a design choice that is fine for a demo and probably wrong for a real spending policy.
The AWS demo has its own ceiling, already listed above. It is free only on testnet, pays only in stablecoin, needs a browser delegation step, got geo-blocked in a way that forced a provider switch, and sits on a merchant ecosystem that barely exists yet.
Takeaway
Authorization, not throughput, is the real frontier for agentic payments. Once an agent can move money, the only question that matters is what stops it from moving the wrong amount to the wrong place, and whether that stop can be argued with.
I now have two working answers, and they are honestly different. AWS puts the rule in a trusted service, with your keys in a vault and a provider you lean on. It is ergonomic, auditable, and already shipping. The cost is trusting that whole stack and clicking a browser button you can't script. Sui puts the rule in bytecode that is the transaction. It is portable, provider-free, and nothing in the prompt can touch it. The cost is that you own your key management, your gas, and the correctness of code you can't easily patch.
If I were building this for real, I would start from the Sui enforcement model, where the cap is the limit and the abort is atomic, then bolt on the AWS custody and observability story. That means a capability object for the rule, a key-management layer like Seal for the secrets, a stablecoin coin type for the money, and a real audit pipeline. Neither demo is that system yet. What they do show is the two places the rules can live, and the two different parties you end up trusting depending on which you pick.
Top comments (1)
Two things I'd want to check against the module, because both land on your own question of who holds the switch.
Revocation may not be unilateral on the Sui side.
revoke(owner_cap: &OwnerCap, cap: &mut SpendingCap)needs a mutable reference to the SpendingCap, and the delegation story is that the cap gets transferred to the agent. If it's an owned object after that, only whoever currently holds it can pass&mut, so the owner's kill switch stops working the moment delegation succeeds. The AWS row (owner revokes the signer in Privy) doesn't have that problem: revocation is unilateral and does not require the delegate to hand anything back. The Revocation row reads as parity, but the two aren't equivalent on the dimension the post is actually about. If the cap is shared instead, then every spend goes through consensus and you lose the cheap owned-object path, which is the usual reason to move the switch out rather than share the object: keep the cap owned, and put a sharedRevocationRegistry(or cap registry) thatspendreads andrevokewrites. Kill switch where the delegate cannot reach it, spend stays single-writer.A per-session budget bounds a burst, not a total.
maxSpendAmountis enforced against a Payment Session, and the agent is the thing that opens sessions. The obvious way around a per-session cap is to open another session. Your Move struct carries bothper_period_limitandtotal_cap; the AWS side of the table only shows the session budget. If AgentCore has a lifetime or per-period accumulator somewhere, I'd put it in the table, because that asymmetry currently reads as a point in Sui's favor that you may not have intended.Both are your own question pointed back at your own demo: not "is the rule enforced" but "can the party being limited reach the enforcement."
The bit worth stealing regardless of which side wins is the abort-code discipline. Asserting the named code instead of "any failure" is what makes the unit test and the on-chain abort the same object rather than two claims that happen to agree. That is the part that survives the move from testnet to a real policy.