[Proposal] New NEP: NFTs as the title of an AI agent account (builds on NEP-171)

TL;DR

I’d like to propose a new, separate standard that sits alongside NEP-171, the same way NEP-177 and NEP-178 do. It doesn’t change NEP-171. A NEP-171 token becomes the title to an agent account: whoever owns the token controls the account, and a sale hands control over safely, including the awkward cases: pending payouts, nft_transfer_call rollbacks, and a buyer inspecting a state that then changes underneath them.

Existing NEP-171 tokens and marketplaces keep working unchanged. Contracts that don’t implement this standard are just not agent titles.

It is live on testnet now: nivry.testnet, agent token 1, treasury 00000001.nivry.testnet. The treasury has no access keys. Reference implementation: GitHub repo Nebulaz7/Nivry, commit b083020. (Links removed because the forum blocks them for new accounts. I’ll add them once I can.) I’d like feedback on the design before opening a draft NEP PR.

Problem

Agents hold funds and act. If an NFT represents one, a sale has to guarantee three things:

  1. after the sale, only the new owner can move value held by the agent,
  2. nothing the seller started before the sale completes under the buyer, and
  3. the buyer gets the state they inspected.

NEP-171 gives ownership. NEP-177 gives display metadata. NEP-178 gives approvals. None of them define any of the above. Today every project invents its own scheme, marketplaces cannot compare agents, and there is no shared call that answers “who controls this agent right now?”.

Concretely, without this: a seller lists an agent, the buyer verifies the treasury balance, and between verification and purchase the seller withdraws. Or the seller leaves an approved marketplace account that can still move the title. Or nft_transfer_call resolves as a rollback while a custodian still holds a cached, now-stale authorization.

The mechanism

Two monotonic counters and one flag, per token.

Field Meaning
authority_epoch: u64 Monotonic authority generation. Increases on owner change (including a nft_transfer_call rollback), on executor grant install/revoke, and on freeze change. Grants and in-flight commands carry it, and a stale epoch is rejected. Never decreases, so A to B to A does not revive A’s old grant.
version: u64 Buyer-visible state version. Increases on owner change, freeze change, grant change, every scheduled outflow, and delisting. Deliberately does not increase on incoming payments, so nobody can grief a purchase by sending dust.
frozen: bool A single “stop / for sale” lock. Blocks every outflow, never blocks inflows. Owner-only, exactly 1 yoctoNEAR, requires expected_version, and is publicly readable.

Reading the names

The names are the ones actually deployed, and they carry enough meaning that I’d rather justify them than leave them to taste.

authority_epoch, not owner_epoch. It is not only an owner counter. A grant is invalidated when the owner changes, but also when the owner replaces or revokes that grant, and when the owner freezes the agent. All three mean “the authority this grant was issued under is gone”, so the counter belongs to authority, not to ownership. “Epoch” here is a generation number, not a timestamp and not NEAR’s blockchain epoch. I know that collides with chain vocabulary, and if reviewers think it reads as time progression I’d rename it authority_generation.

version, kept separate from the epoch on purpose. Both move on an ownership change, but they answer different questions. authority_epoch answers “is this grant still valid?”, so it must move on anything that could invalidate authority. version answers “is what the buyer inspected still true?”, so it must also move on outflows, which should invalidate nothing. Merging them would make every routine payout look like a hostile ownership change and force the owner to re-issue the grant after every payment.

version ignores incoming payments deliberately. Anyone can send NEAR to the treasury. If inflow bumped version, anyone could send a dust payment at any moment and make every in-flight purchase abort, which is a cheap denial of service against the market. Inflow events still exist, they just don’t participate in the version.

frozen, not locked, paused or for_sale. One flag, one meaning: no value leaves. It is deliberately not a status enum, because “paused” and “for sale” are not different enforcement states. Both must block outflows, and both must be visible to a buyer. Listing is not a second lock, it is a use of the lock: list_agent refuses unless frozen is already true. That keeps one rule to reason about and one rule to test.

treasury_account_id, not agent_account. The bound account is not a general-purpose agent account. It holds no keys, cannot be upgraded, and has a non-spendable storage plus operating reserve, so its balance is never fully withdrawable. “Treasury” describes what a buyer is actually acquiring: a pot of value with a fixed spending policy. Calling it an “agent account” would imply the token carries the agent’s runtime, which it does not (see limitations).

executor_grant, not operator or delegate. A grant is a bounded, expiring capability: one executor, 1 to 8 allowed recipients, a per-payment cap, a total budget and a maximum 90-day lifetime. “Operator” suggests standing authority. This is closer to a prepaid allowance.

ownership_change_count is separate from both counters. version moves for many reasons that are not sales, and a passport wants “how many times did this agent change hands”. Keeping them apart means neither has to be reinterpreted.

Keyless treasury. create_agent builds the treasury in one receipt (create account, transfer, deploy, init) with no key actions at all. There is no add_access_key, no upgrade method, no delete, and no rebind (new is #[init]). The treasury never caches an owner and never accepts commands from an owner or an executor. dispatch_native is callable only by the immutable registry, permits one unresolved command at a time, requires a monotonic sequence, and refuses any amount that would eat into storage plus a 0.1 NEAR operating reserve.

Settlement fails closed. The registry records the pending operation and the version at dispatch before scheduling anything, and rejects set_frozen while an operation is unresolved, so version cannot move in flight. If the treasury’s callback is unreadable, the registry queries the treasury’s own persisted operation_status: null proves nothing was ever dispatched, a terminal status settles, and anything else leaves the token locked rather than guessing. It never retries and never refunds on uncertainty, because “transfer succeeded, then finalization failed” looks the same as failure to a naive caller.

Listing and purchase.

  • set_frozen(true) also clears the executor grant and bumps authority_epoch. Freezing can be undone, but a frozen agent has no working operator.
  • list_agent(token_id, expected_version, price) refuses unless the agent is already frozen, and captures the version and manifest_hash at listing time.
  • buy_agent(token_id, listing_id, expected_version, expected_manifest_hash) aborts atomically if the agent’s version, the listed version, the manifest hash, or the lock state has changed. Title transfer, grant removal, epoch and version bumps, listing consumption and seller credit all happen in one receipt, so there is no window where the buyer owns the token but the seller is unpaid.
  • cancel_listing clears both the listing and the lock, so a failed sale cannot trap the agent.

Method surface

Method Notes
create_agent(manifest, expected_cost) -> String payable, exact published cost, no key actions
set_executor(token_id, expected_epoch, grant) / revoke_executor(token_id, expected_epoch) one executor per agent, 1 to 8 recipient allowlist, per-payment cap, total budget, max 90-day lifetime
execute_payment(token_id, expected_owner, authority_epoch, nonce, recipient, amount, deadline_ns) authenticated by the predecessor, not by a supplied owner string
owner_withdraw(token_id, expected_epoch, nonce, recipient, amount) same dispatch path, no grant required
set_frozen(token_id, expected_version, frozen) owner-only, exactly 1 yoctoNEAR
list_agent / cancel_listing / buy_agent inline market, see above
agent_info(token_id) -> AgentInfo owner_id, authority_epoch, version, frozen, treasury_account_id, grant, pending operation, listing, manifest
wallet_code_hash(), provisioning_cost(), treasury_account_id(token_id), listing_deposit(), seller_credit(account_id) everything a client needs to verify or price a call

Mutating methods authenticate the predecessor, so an approved marketplace account can move the title and nothing else. It cannot spend, grant, freeze or list. nft_transfer, nft_transfer_call and set_frozen additionally require exactly 1 yoctoNEAR.

Events are NEP-297-shaped (standard: "nivry", version: "1.0.0"): agent_reserved, agent_ready, executor_authorized, executor_revoked, freeze_changed, ownership_changed, dispatch_scheduled, dispatch_settled, dispatch_uncertain, agent_listed, agent_sold, transfer_call_started, transfer_call_returned, alongside the standard nft_mint / nft_transfer.

Live on testnet

Source Nebulaz7/Nivry @ b083020
Registry nivry.testnet, code ee320975…, 984,505 B
Treasury 00000001.nivry.testnet, code ce3b675e…, 274,121 B
wallet_code_hash() ce3b675e2b3d9ab5a11d8840f0e1cd48471438f6a418ddc3bafce21fca4d8e5e
Explorer contract checksum matches wallet_code_hash()
Treasury access keys none (“Account is locked (no access keys)”)
provisioning_cost() 3.03 NEAR (3 NEAR code stake + registry storage)

Transactions (search the hash on the testnet explorer): deploy + init AhPAc9yuGQmHDCFPVRPikseb1k1qgzbRP9ghq3N6BQ5a, create_agent to token 1 J6Qd2jmNT4osYzDQq2uvZ2x3EFv4pB4EF5YRq1dW7bns

Deployment notes, including the exact commands and what this deployment does not prove, are in docs/deployment.md in the repo.

What this does not do

It does not transfer the agent’s brain, memory or prompts, and it cannot un-copy them. Revocation removes access to the wallet, title and history, not to a copy of the code. It cannot revoke keys that live outside the treasury. Enforcement covers only value that passes through that account, and only if the off-chain runtime actually reads the on-chain state. A frozen agent can still receive deposits, so a buyer must read treasury state at purchase time, not a listing snapshot. recorded_payment_amount is a self-reported receipt, not proof of organic revenue, and the payer appears in the event so indexers can discount self-payments. And the deployed contract is not immutable: the registry still holds a full-access key, so the code can be replaced.

Prior art

ERC-6551 gives a token an account. This does something different: the token is the title to an account it does not hold keys for, and the standard covers the control-transfer guarantees rather than account creation. ERC-8004 clears the agent wallet on transfer, which is the same intuition with different enforcement. ERC-7857 moves encrypted agent metadata with the token. That is a privacy problem, genuinely out of scope here, and I don’t want this standard to imply a privacy guarantee it can’t keep. (Check each ERC’s header for its current status.)

On NEAR itself, NEP-171, NEP-177, NEP-178 and NEP-297 are the pieces this builds on, and the 177/178 extension pattern is the model for the shape. As far as I can tell there’s no accepted NEP for this on NEAR. If there is prior work on agent registries, token-bound identity or agent ownership that I should align with, please point me at it. I’d rather adopt existing terminology than invent a parallel one.

Questions for reviewers

  1. Should the executor grant survive a sale? This draft always clears it. The alternative is to keep an executor when the treasury is a contract that obeys the title. That’s convenient, but it means a buyer inherits an operator they did not choose. I chose the conservative default and I’m not certain it’s right.
  2. Is one frozen flag enough, or should pause and for-sale be separate states? One flag is simple, but it also means an agent can’t be “listed but still operating”.
  3. Should the treasury be one account for the agent’s whole life, or a fresh account per owner? A single immutable treasury keeps the history continuous and auditable. A fresh account per sale would let each buyer start clean, but it destroys the “buy the track record” proposition.
  4. Is Standards Track / Contract the right category for a new NEP that requires 171 and 297, and who should review it?

Status

  • Built during the NEAR HACKATHON. Implemented and deployed on testnet, unaudited.
  • Unit tests cover the transfer rules, freezing, grants, settlement proofs, listing/purchase and the nft_transfer_call return path. A sandbox test runs the full cross-contract flow against a real node.
  • Reference implementation: Nebulaz7/Nivry, commit b083020.
  • Next step once the design settles: a draft NEP PR in near/NEPs following NEP-1.

Thanks for reading :blush:. I’m most interested in being told where this is wrong.

1 Like