Model
A mem-key is two objects. MemKey is the NFT, owned by the holder. KeyState is a shared object linked to it, so approved apps can write to it without owning the NFT. A holder proves they hold the key by passing the owned MemKey in their transaction.
- Holder.
KeyState.holder, set at mint and byclaim. - Approvals. A map of app address to expiry in ms. An app counts as approved while
now < expiry. - Memory. Pointers to Walrus blobs encrypted with Seal: one slot for the holder, one per approved app.
- Records. Dynamic fields keyed by your module's witness type, so your app's data lives in its own namespace.
Ids · testnet
| what | id | use |
|---|---|---|
| memkey package | 0xaa0f369c7ee5ff9b46f8045f05930bb5d0ce5240a17fac9add3f315ea5c2cabb | types, events and call targets |
| memkey latest (call target) | 0x95f43e777c56e7f8f3a2e1cf87601c532eba8c8f4eca05c899e3cf6fb6b60885 | mint_with_referrer, wallet |
| Mint (shared) | 0x67385d65afcdf2d3fc163a579c467f7700cedfcef5db40959d699e5c49d0deb5 | open mint |
| Seal key servers | 0x73d05d62…db75, 0xf5d14a81…23c8 | Mysten testnet, threshold 1 |
Mainnet ids are published at launch. The mint price there is 5 SUI.
Holder calls
// mint: pays the posted price, sends the key and any change to the sender mint::mint(m: &mut Mint, payment: Coin<SUI>, ctx) // same, and pays the referrer 10% of the price in the same transaction (self-referral pays nothing) mint::mint_with_referrer(m: &mut Mint, payment: Coin<SUI>, referrer: address, ctx) // approvals (sender must be the holder) memkey::approve(key: &MemKey, state: &mut KeyState, app: address, expires_ms: u64, ctx) memkey::revoke(key: &MemKey, state: &mut KeyState, app: address, ctx) memkey::revoke_all(key: &MemKey, state: &mut KeyState, ctx) // after receiving a key: become the holder, clear old approvals memkey::claim(key: &MemKey, state: &mut KeyState, ctx)
App calls
An app is an address the holder approves, usually the signer your agent runs with. Check approval on chain before doing anything on the holder's behalf. Writes to a key (app memory, records) need the sender to be the holder or an approved, unexpired app.
memkey::is_approved(state: &KeyState, addr: address, clock: &Clock): bool memkey::set_app_memory(state: &mut KeyState, blob: String, clock: &Clock, ctx) // approved app; writes the sender's own slot memkey::app_memory(state: &KeyState, app: address): Option<String> memkey::app_memory_version(state: &KeyState, app: address): u64 memkey::ext_add<W, T>(w: W, state: &mut KeyState, value: T, clock: &Clock, ctx) // holder or approved app memkey::ext_borrow_mut<W, T>(w: W, state: &mut KeyState, clock: &Clock, ctx): &mut T // holder or approved app memkey::ext_borrow<W, T>(state: &KeyState): &T // open read memkey::ext_exists<W, T>(state: &KeyState): bool // open read memkey::holder(state: &KeyState): address memkey::approval_expiry(state: &KeyState, app: address): Option<u64>
Key wallet
The key is a wallet. Coins sit on the KeyState as balances, one per coin type. Objects sent to the key's address wait there until the holder claims them. Selling the key sells the agent with everything it holds.
The key's receiving address is the KeyState id (wallet::wallet_address). Send coins or NFTs there with a normal transfer. Anyone can sweep a sent coin into the balance with accept.
Proof of agent: the key's address is the agent's identity, not the holder's wallet.
// anyone wallet::deposit<T>(state: &mut KeyState, coin: Coin<T>) wallet::accept<T>(state: &mut KeyState, r: Receiving<Coin<T>>) // sweep a coin sent to the key's address // holder (owned key + state.holder == sender) wallet::withdraw<T>(key: &MemKey, state: &mut KeyState, amount: u64, ctx): Coin<T> wallet::claim_object<T: key + store>(key: &MemKey, state: &mut KeyState, r: Receiving<T>, ctx): T wallet::set_allowance<T>(key: &MemKey, state: &mut KeyState, app: address, per_day: u64, ctx) // 0 removes wallet::clear_allowance<T>(key: &MemKey, state: &mut KeyState, app: address, ctx) // approved, unexpired app, within today's allowance wallet::spend<T>(state: &mut KeyState, amount: u64, clock: &Clock, ctx): Coin<T> // views wallet::balance<T>(state: &KeyState): u64 wallet::allowance<T>(state: &KeyState, app: address): (u64, u64, u64) // per_day, used, day wallet::wallet_address(state: &KeyState): address
Allowances are per app and per coin type, counted in the coin's base units. The day is the UTC day (timestamp_ms / 86_400_000); usage resets when the day changes. used in the view counts only while day is today. An app needs both an allowance and a live approval. claim clears approvals, so after a sale no app can spend until the new holder approves one again. Old allowances stay on the key but do nothing without approval; the new holder can clear them.
Events: Deposited, Withdrawn, Spent, AllowanceSet, ObjectClaimed, each with the key id and the coin type name.
Records
Store your app's data on the key with a witness type from your own module. Only code that can construct the witness can write under it, and only in a transaction sent by the holder or an approved, unexpired app.
public struct MyApp has drop {} public struct Score has store { points: u64 } public fun attach(key: &MemKey, state: &mut KeyState, clock: &Clock, ctx: &TxContext) { // your own rule on top, e.g. holder only assert!(memkey::holder(state) == ctx.sender()); if (!memkey::ext_exists<MyApp, Score>(state)) memkey::ext_add(MyApp {}, state, Score { points: 0 }, clock, ctx); } public fun score(state: &mut KeyState, clock: &Clock, ctx: &TxContext) { // ext_borrow_mut aborts unless the sender is the holder or an approved app memkey::ext_borrow_mut<MyApp, Score>(MyApp {}, state, clock, ctx).points = ...; }
Anyone can read a record with ext_borrow<W, T>, or off chain from the KeyState's dynamic fields.
Memory
Encrypt with Seal using the memkey package as the policy package. Each key has two kinds of memory slot, each with its own identity format:
- Holder memory. Identity
state_id ‖ 0x00 ‖ nonce. Point the key at the blob withset_memory, which only the holder can call (it takes the owned key). - App memory. Identity
state_id ‖ 0x01 ‖ app_address (32 bytes) ‖ nonce. An approved app stores its blob id withset_app_memory, in a slot keyed by its own address (dynamic fieldAppMemory { app }).
The holder can decrypt every identity under the key, its own and every app's. An approved app can decrypt only identities under its own prefix, and only while its approval is unexpired. claim clears approvals, so earlier apps lose access; their blobs stay on the key.
// holder only: pass the owned key memkey::set_memory(key: &MemKey, state: &mut KeyState, blob: String, ctx) // approved app only: writes AppMemory { app: sender } memkey::set_app_memory(state: &mut KeyState, blob: String, clock: &Clock, ctx) // seal: the holder decrypts any identity that starts with the state id entry fun seal_approve(id: vector<u8>, key: &MemKey, state: &KeyState) // seal: an approved, unexpired app decrypts only state_id ‖ 0x01 ‖ sender ‖ … entry fun seal_approve_app(id: vector<u8>, state: &KeyState, clock: &Clock, ctx)
const id = toHex([...fromHex(stateId), 1, ...fromHex(appAddress), ...nonce]); const { encryptedObject } = await seal.encrypt({ threshold: 1, packageId: MEMKEY, id, data }); // PUT encryptedObject to a Walrus publisher, then: tx.moveCall({ target: `${MEMKEY}::memkey::set_app_memory`, arguments: [tx.object(stateId), tx.pure.string(blobId), tx.object.clock()] }); // decrypt: build a seal_approve_app call with the id, the state and the clock
Reading
Everything is public chain state. Sui GraphQL works from a browser with no key.
{ address(address: "0x…") { objects(filter: { type: "0xaa0f…2cabb::memkey::MemKey" }) {
nodes { address contents { json } } } } }Mints emit mint::KeyMinted { key, state, number, price }. Memory writes emit MemoryUpdated { key, blob, version, by } (holder) and AppMemoryUpdated { key, app, blob, version }.
Trading limits
af_policy is an open module for agents that trade Aftermath perps. The holder locks an Aftermath assistant permission inside a policy bound to their key. The agent's runner can only trade through the policy, which checks on chain: runner, pause, expiry, markets, size per order, size per UTC day and leverage. Reduce-only orders always pass.
Trading record. Every checked trade also updates the key's record in the same transaction: trades, volume, realized gains and losses (fees included), and wins and losses on closing trades. It counts only value that changes inside a checked trade, so deposits, withdrawals and transfers between trades can't pad it. Read it with af_policy::record_of(state). The runner must be an app the holder approved on the key. It goes live with mainnet testing.
Embed the leaderboard
The leaderboard ranks keys by the trading record their policy modules write on chain. A compact top list fits in an iframe. venue picks one venue (Aftermath or DeepBook; anything else shows all), n sets how many rows, 1 to 10, default 5.
<iframe src="https://memkey.org/embed?venue=DeepBook&n=5" width="360" height="280" style="border:0" loading="lazy" title="mem-key leaders"></iframe>
It reads Sui GraphQL from the viewer's browser and shows key numbers and art only, never holders.