diff --git a/docs/pas-architecture.mdx b/docs/pas-architecture.mdx new file mode 100644 index 0000000..e24168f --- /dev/null +++ b/docs/pas-architecture.mdx @@ -0,0 +1,429 @@ +# PAS Architecture Overview + +Deep dive into the Permissioned Asset Standard — how it enforces restricted asset movement on Sui. + +--- + +## Motivation + +On Sui, any object with the `store` ability can be freely transferred by its owner. +e.g. `Balance` and `Coin` both have `store`, meaning if you hold them, you can send them +anywhere on the network with no restrictions. + +This is great for general-purpose assets, but a problem for regulated assets that need +transfer controls, compliance checks, or issuer oversight. + +**PAS solves this by proxying asset ownership through Chests, objects that hold assets +and enforce a closed-loop system where every movement is gated by programmable approval logic.** + +## Core Concept + +For each wallet address (or object ID), PAS creates a **1:1 derived Chest**, a shared object +that holds assets on behalf of that address. The owner can prove ownership via an `Auth` proof, +but they cannot freely transfer the assets. In this way, you create a proxy of ownership where +the assets follow the constraints of PAS modules, and sequentially, the rules that an issuer has +defined across one or more packages via approval witnesses. + +Every movement of funds goes through **hot potato Requests** that must collect a predefined set of **approval stamps** (witness structs) +before they can resolve within the transaction. + +``` +Without PAS: Wallet → owns T → can transfer freely +With PAS: Wallet → proves ownership via Auth → Chest holds T → Request needed to move +``` + +Each asset type can have its own **Policy** that defines which witnesses are required to approve +each action. This means different assets can enforce completely different rules, one might require +a single compliance stamp, another might need approvals from multiple independent contracts. + +The result: assets are held in a closed system where every movement is gated by programmable, +composable approval logic that the issuer defines at the Policy level. + +There is no way to transfer a managed `Balance` out of the system without going through +a request that collects the required approvals. + +For Currency, this is enforced at the Move type level: +- `Balance` is stored inside Chests using Sui's `balance::send_funds` / `balance::redeem_funds` (derived object storage) +- The only way to move funds is through **Request hot potatoes** that must be resolved in the same transaction +- Resolution requires matching the approval set defined in the `Policy>` + +--- + +## Object Model + +``` +Namespace (shared, singleton) +├── Chest (@0xAlice) ← derived from (namespace_id, ChestKey(alice_addr)) +├── Chest (@0xBob) ← derived from (namespace_id, ChestKey(bob_addr)) +├── Policy> ← derived from (namespace_id, PolicyKey>) +│ └── PolicyCap> ← derived from (policy_id, PolicyCapKey) +└── Templates ← derived from (namespace_id, TemplateKey) +``` + +All objects use **derived addresses** (`sui::derived_object`), making them deterministic and queryable +without on-chain lookups. + +### Namespace + +The Namespace is the root of the system. Responsibilities: + +- Derives addresses for Chests, Policies, and Templates +- Holds the `Versioning` state for emergency version blocking +- Keeps the `UpgradeCap` UID to gate admin operations (version blocking, setup) + +### Chest + +Chests are **shared objects** derived from the Namespace's UID and the owner's address. + +| Property | Detail | +|---|---| +| **Creation** | Permissionless — anyone can create a Chest for any address | +| **Ownership** | Wallet address (`ctx.sender()`) or Object (`UID`) | +| **Storage** | Holds `Balance` as object balance, or `T` directly as objects on the Chest's UID //TODO Check again | +| **Derivation** | `derived_object::claim(namespace_uid, ChestKey(owner))` | + +### Policy + +A `Policy` defines resolvable actions for a managed asset type T. + +- **Required approvals** per action type (`send_funds`, `unlock_funds`, `clawback_funds`) +- **Clawback flag** — whether admin clawback is allowed +- **Versioning** — synced from Namespace, can block package versions + +For currencies, you create a `Policy>` specifically: +Created via `policy::new_for_currency(&mut namespace, &mut treasury_cap, clawback_allowed)`. +Requires `TreasuryCap` as proof of currency ownership. + +### PolicyCap + +The capability to manage a Policy. Derived 1:1 from the Policy UID and PolicyCapKey. Used to: + +- Set/update required approvals per action +- Remove action approvals (makes requests for that action unresolvable) + +--- + +## The Request Pattern + +Every state-changing operation in PAS follows the **Request hot potato pattern**: + +``` +Create Request → Approve (1..N) → Resolve +``` + +### How It Works + +1. **Create**: A chest method wraps data `T` into a `Request>`. + The Request starts with an empty approval set. + +2. **Approve**: Your package calls `request.approve(MyWitness())` to stamp the Request with a + type-level proof. Multiple approvals can be collected from different packages. + +3. **Resolve**: A resolution function verifies that the collected approvals **exactly match** + the required approvals in the Policy, destroys the request object and or executes an action or unwraps data. + +The Request is a **hot potato**, it has no `drop` or `store` ability, so the transaction will abort +if it's not resolved. + +### Request Types + +```move +Request> // Transfer between chests +Request> // Issuer funds withdrawal +Request> // Withdraw from system as the owner of funds +``` + +### Approval Matching + +Approvals are matched by **type identity** using `TypeName`. The approval set must be **exactly equal** +(same types, same count, same order via `VecSet` insertion) to the Policy's required approvals. + +> **v1 limitation**: In the current version, each action supports only a **single approval witness**. +> Multi-approval support (requiring stamps from multiple independent contracts) is planned for a future release. + +e.g. TransferApproval witness struct defined in contracts + +```move +// Policy requires: { TransferApproval } +// Request has: { TransferApproval } ← resolves +// Request has: { TransferApproval, ExtraApproval } ← aborts (count mismatch) +// Request has: { WrongApproval } ← aborts (type mismatch) +``` + +--- + +## Actions in Detail + +### SendFunds (Transfer) + +Moves `T` from one Chest to another. + +| Field | Description | +|---|---| +| `sender` | Wallet/object address (NOT chest address) | +| `recipient` | Wallet/object address (NOT chest address) | +| `sender_chest_id` | ID of the source Chest | +| `recipient_chest_id` | Derived ID of the destination Chest | +| `funds` | The `T` being transferred | + +**Resolution**: `clawback_funds::resolve(request, &policy)` — verifies approvals, then **returns `T` to the caller**. + +When `T` is `Balance`, use `send_funds::resolve_balance(request, &policy)` — this verifies approvals +and sends the balance directly to the recipient Chest via `balance::send_funds`. + +**Key insight**: `resolve_balance` sends the balance directly. The caller doesn't get it back — +it goes straight to the destination Chest. + +### ClawbackFunds (Issuer Withdrawal) + +Forcibly withdraws `T` from a Chest. No `Auth` required. + +| Field | Description | +|---|---| +| `owner` | Wallet/object address of the source Chest | +| `chest_id` | ID of the source Chest | +| `funds` | The `T` being clawed back | + +**Resolution**: `clawback_funds::resolve(request, &policy)` — verifies approvals AND +`policy.clawback_allowed`, then **returns `T` to the caller**. + +**Key insight**: Unlike SendFunds, the caller receives the funds and decides what to do with them +(burn, deposit elsewhere, etc.). + +### UnlockFunds (Closed-Loop Exit) + +Removes `T` from the closed-loop system entirely. `Auth` required. + +> **Warning**: Enabling `unlock_funds` allows assets to leave the closed-loop system completely, +> bypassing all future transfer controls. Once unlocked, the asset becomes a regular on-chain object +> with no PAS restrictions. Only allow this action if your use case explicitly requires users to +> withdraw assets from the managed system. + +**Two resolution paths:** + +| Path | When | Resolution | +|---|---|---| +| `unlock_funds::resolve(request, &policy)` | Managed asset (`Policy` exists) | Requires matching approvals | +| `unlock_funds::resolve_unrestricted_balance(request, &namespace)` | Unmanaged balance (no Policy) | No approvals needed | + +The unrestricted path is `Balance`-specific — it exists for assets like SUI that may accidentally +end up in a Chest. It **aborts** if a `Policy>` exists for the type — you can't bypass managed asset controls. + +--- + +## Resolution: Move vs PTB + +### Resolving Actions in Move (Inside Your Contract) + +Your contract calls approve + resolve within its own function. The balance never leaves your control: + +//TODO sendFunds request probably better example +```move +public fun burn( + policy: &Policy>, + mut request: Request>>, +) { + // Approve inside your contract + request.approve(ClawbackApproval()); + + // Resolve — balance returned to this function + let balance = clawback_funds::resolve(request, policy); + + // Burn the balance + treasury_cap.burn(balance.into_coin(ctx)); +} +``` + +**Use case**: Burn, seize, or any operation where your contract needs the balance. + +### Resolving in a PTB (Client-Side) + +The request is created and approved across multiple Move calls in a single PTB, then resolved +as a separate Move call: + +``` +PTB: + 1. chest::new_auth() → auth + 2. chest.send_balance(auth, to, amount) → request + 3. your_contract::approve_transfer(&mut request) → approves internally + 4. send_funds::resolve_balance(request, policy) → sends to recipient +``` + +**Use case**: Transfers, where the compliance contract approves and PAS handles delivery. + +**Important**: Steps 3 and 4 **must** be in the same PTB. The request is a hot potato — if the +transaction ends without resolution, it aborts. + +--- + +## Template Commands + +Templates store pre-built Move Call command structures on-chain for client-side automation. They allow +SDKs to construct the correct Move calls for approval resolution without hardcoding package IDs or +function signatures. + +> **Important**: Templates are purely an **off-chain utility**. They do not affect on-chain execution +> or security. They simply describe which Move calls an SDK should make. This is what allows a single +> generic SDK to support any PAS-managed asset, regardless of its specific approval logic. + +### Why Templates? + +When a transfer happens, the PAS SDK needs to know: +1. Which contract to call for approval (step 3 in the PTB above) +2. What arguments that contract expects +3. What type parameters to use + +Templates store this as an on-chain `Command` object keyed by the approval witness type. + +**Key benefit**: When an issuer upgrades their approval logic (e.g. from `TransferApproval` to +`TransferApprovalV2`), they update the template on-chain. All clients automatically pick up the new +resolution path. No client-side updates, no redeployments, no coordination across wallets and frontends. + +### Setting a Template + +A template command is a `MoveCall` that describes the approval function to call. You build it with +`ptb::move_call` and register it via `set_template_command`: + +```move +public fun set_template_command( + templates: &mut Templates, + _: internal::Permit, + command: Command, +) +``` + +The template is keyed by `A`, the witness type used to approve requests. +This creates a 1:1 mapping: for each approval type, there's exactly one template command. + +#### Example + +Given an example approval function in your package: + +```move +public fun approve_transfer(request: &mut Request>>, _clock: &Clock) { + // Your validation logic here + request.approve(TransferApproval()); +} +``` + +You register a template that describes how to call it: + +```move +let type_name = type_name::with_defining_ids(); + +let cmd = ptb::move_call( + type_name.address_string().to_string(), // package ID + "my_module", // module + "approve_transfer", // function + vector[ + ptb::ext_input("pas:request"), // the request (resolved by SDK) + ptb::object_by_id(@0x6.to_id()), // Clock object + ], + vector[(*type_name.as_string()).to_string()], // type arguments +); + +templates.set_template_command(internal::permit(), cmd); +``` + +#### Common Argument Types for Templates + +| Constructor | Description | +|---|---| +| `ptb::ext_input(name)` | Custom input resolved off-chain by the SDK (see below) | +| `ptb::object_by_id(id)` | Object resolved off-chain by its ID | +| `ptb::object_by_type()` | Object resolved off-chain by its Move type | +| `ptb::receiving_object_by_id(id)` | Receiving object resolved off-chain by its ID | +| `ptb::pure(value)` | BCS-encoded pure value | + +For the full list of `ptb` constructors (including system shorthands and fully-resolved refs), see the PTB package. //TODO link the package + +#### Supported `ext_input` Names + +These are the custom input names the PAS SDK resolves at PTB construction time: + +| Name | Resolves to | +|---|---| +| `"pas:request"` | The active `Request` object being approved | +| `"pas:policy"` | The `Policy` object for the managed asset | +| `"pas:sender_chest"` | The sender's `Chest` object | +| `"pas:receiver_chest"` | The receiver's `Chest` object | + +### How Clients Use Templates + +1. Client reads the template from the `Templates` shared object +2. Template describes the Move call needed (package, module, function, args) +3. Client builds the PTB: create request → template command (approval) → resolve + +This enables generic wallets and frontends to execute compliant transfers without knowing the +specific compliance contract details. + +--- + +## Balance Tracking + +PAS uses Sui's [address balances](https://docs.sui.io/guides/developer/address-balances-migration): + +### How Balances Are Stored + +``` +Chest (shared object) + └── UID + └── Balance stored via balance::send_funds(balance, chest_object_address) +``` + +Balances are **not** stored as fields on the Chest struct. They're stored as object balance +on the Chest's `UID`, using `balance::send_funds` to send funds to the Chest's object address +and `balance::withdraw_funds_from_object` (via `UID.withdraw_funds_from_object`) to pull them out. + +//TODO Explain the need for internal balance tracking + +### Balance Flow + +``` +Deposit: Balance → balance::send_funds(balance, chest_addr) → stored on Chest UID +Withdraw: Chest UID → balance::withdraw_funds_from_object(amount) → Balance +``` + +Deposits are permissionless (anyone can deposit into any Chest). +Withdrawals are internal (`public(package)`) — only PAS modules can withdraw, and only through Requests. + +--- + +## Security Model + +### What PAS Guarantees + +1. **Closed loop**: Managed assets cannot leave the system without going through a Request with + matching approvals +2. **Type-safe approvals**: Approval witnesses are checked by `TypeName` — you can't forge an + approval from a different package +3. **Atomic resolution**: Requests are hot potatoes. They must be resolved in the same transaction + or the transaction aborts +4. **Deterministic addressing**: All objects use derived addresses. No hidden state, no + non-deterministic object creation + +### What PAS Does NOT Do + +- **Access control**: PAS doesn't decide WHO can transfer. That's your contract's job (via approval witnesses) +- **Compliance rules**: PAS doesn't rules. Your contract implements those + before calling `request.approve()` + +### Trust Boundaries + +| Component | Trust Level | +|---|---| +| `PolicyCap` holder | Can change approval requirements for T | +| `TreasuryCap` holder | Can create a Policy (one-time) for Balance | +| Chest owner (`Auth`) | Can initiate send/unlock from their chest | +| Anyone | Can create chests, deposit, sync versioning | +| Approval witness package | Controls who can approve requests | +| PAS package `UpgradeCap` holder | Can manage the `Versioning` system to coordinate safe package upgrades and migrations | + +## Integration Checklist + +When building on PAS: + +1. **Define your approval witness**: `public struct MyApproval() has drop;` +2. **Create a Policy** with `policy::new_for_currency` and set required approvals per action +3. **Build your approval logic**: A function that takes `&mut Request<...>`, validates, and calls `request.approve(MyApproval())` +4. **Set template commands** so clients can build PTBs automatically \ No newline at end of file diff --git a/docs/pas-quickstart.mdx b/docs/pas-quickstart.mdx new file mode 100644 index 0000000..6b2d208 --- /dev/null +++ b/docs/pas-quickstart.mdx @@ -0,0 +1,170 @@ +# PAS Quickstart + +Minimal examples to get you moving assets through PAS (Permissioned Asset Standard). + +## What is PAS? + +PAS is a permissioned asset framework on Sui. Assets of different types live +under **Chests** (one per address independent of types) and can only move between +Chests through hot potato **Requests**, that must be approved and resolved in the same PTB. + +Every action follows the same pattern: **Create Request → Approve → Resolve**. + +## Setup + +### 1. Create a Policy for a Currency + +A `Policy>` defines which approvals are required for each action type. +You need a `TreasuryCap` to prove ownership of the currency: + +use in a package: + +```move +let (mut policy, policy_cap) = policy::new_for_currency( + &mut namespace, + &mut treasury_cap, + true, // clawback_allowed +); + +// Require `MyApproval` witness for transfers +policy.set_required_approval, MyApproval>(&policy_cap, "send_funds"); + +// Require `MyApproval` witness for clawbacks +policy.set_required_approval, MyApproval>(&policy_cap, "clawback_funds"); + +policy.share(); +``` + +**Supported actions:** `"send_funds"`, `"unlock_funds"`, `"clawback_funds"` + +### 3. Create Chests + +Chest creation is permissionless. One chest per address and can hold : + +```move +chest::create_and_share(&mut namespace, @0xAlice); +chest::create_and_share(&mut namespace, @0xBob); +``` + +--- + +## Transfer (Send Balance) + +Transfer balance of permissioned assets from Chest A to Chest B. Requires the owner's `Auth` proof. + +### Move + +```move +// 1. Create auth proof +let auth = chest::new_auth(ctx); + +// 2. Create the transfer request (withdraws Balance from sender chest) +let request = from_chest.send_balance( + &auth, + &to_chest, + amount, + ctx, +); + +// 3. Approve with your witness +request.approve(MyApproval()); + +// 4. Resolve — sends Balance to recipient chest +send_funds::resolve_balance(request, &policy); +``` + +--- + +## Clawback (Admin Withdrawal) + +Forcibly withdraw tokens from a chest. No `Auth` required — this is an admin action. +The policy must have `clawback_allowed: true`. + +### Move + +```move +// 1. Create clawback request (no Auth needed) +let mut request = from_chest.clawback_balance(amount, ctx); + +// 2. Approve +request.approve(MyApproval()); + +// 3. Resolve — returns the Balance to the caller +let balance: Balance = clawback_funds::resolve(request, &policy); + +// 4. Do something with the balance (deposit elsewhere, burn, etc.) +to_chest.deposit_balance(balance); +``` + +--- + +## Unlock (Withdraw from System) + +Remove tokens from the closed-loop system entirely. + +### Managed Assets (Policy exists) + +```move +let auth = chest::new_auth(ctx); +let mut request = chest.unlock_balance(&auth, amount, ctx); + +request.approve(MyApproval()); +let balance: Balance = unlock_funds::resolve(request, &policy); +// Balance is now outside the system +``` + +### Unmanaged Assets (No Policy — e.g., SUI accidentally sent to a Chest) + +```move +let auth = chest::new_auth(ctx); +let request = chest.unlock_balance(&auth, amount, ctx); + +// No approval needed — resolves with empty approval set +let balance: Balance = unlock_funds::resolve_unrestricted_balance(request, &namespace); +``` + +--- + +## Deposit + +Deposit tokens into a chest. No request needed: + +```move +chest.deposit_balance(balance); +``` + +Or send to a chest address directly (useful before the chest object is available in the transaction): + +```move +balance.send_funds(namespace.chest_address(owner)); +``` + +--- + +## Auth: Wallet vs Object Ownership + +Chests can be owned by wallet addresses or objects. + +```move +// Wallet-owned: proves ownership via transaction sender +let auth = chest::new_auth(ctx); + +// Object-owned: proves ownership via UID reference +let auth = chest::new_auth_as_object(&mut my_object_uid); +``` + +--- + +## Derived Object Addresses + +All PAS objects (Chests, Policies) use deterministic derived addresses. You can compute them off-chain: + +```move +// Get the chest address for an owner +let chest_addr: address = namespace.chest_address(@0xAlice); + +// Get the policy address for a type +let policy_addr: address = namespace.policy_address>(); +``` + +--- \ No newline at end of file