Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,35 @@ The P-Assets Standard is a framework for issuing and managing permissioned balan

## TLDR

1. Each address has a single chest (derived address, with easy discoverability). Objects can own chests as well. This enables with account abstractions / defi protocols implementations
2. Chest uses address (object) balances, so RPCs work out of the box (wallet just treats the chest address like a normal one). Wallets/explorers needs to query for the derived chest address to get balances.
3. Balances can only move from chest to chest (either by safe chest-to-chest deposits, or deriving the recipient with `unsafe_` calls)
1. Each address has a single account (derived address, with easy discoverability). Objects can own accounts as well. This enables with account abstractions / defi protocols implementations
2. Account uses address (object) balances, so RPCs work out of the box (wallet just treats the account address like a normal one). Wallets/explorers needs to query for the derived account address to get balances.
3. Balances can only move from account to account (either by safe account-to-account deposits, or deriving the recipient with `unsafe_` calls)
4. When a transfer is initiated, a `SendFunds` is issued, which can be resolved, on the PTB layer, calling the `Command` that is specified by the issuer. The issuer can "approve" it in their own package by presenting a witness. Any custom logic (KYC, checks) can be implemented there.
5. Clawback is available (chests are shared and a clawback can be initiated using the issuer's witness).
5. Clawback is available (accounts are shared and a clawback can be initiated using the issuer's witness).

(To be added: Issuers can attach "metadata" to user's Chests (such as `KYC` stamps or AML stamps they issue), which they can then check on their transfer functions to restrict movement. Since chests are shared, issuers can revoke these stamps at any moment).
(To be added: Issuers can attach "metadata" to user's Accounts (such as `KYC` stamps or AML stamps they issue), which they can then check on their transfer functions to restrict movement. Since accounts are shared, issuers can revoke these stamps at any moment).

## Key Features

- **Permissioned Transfers**: All transfers must go through chests and be approved by custom transfer rules
- **Chest-Based Architecture**: Tokens can only be held in chests, with automatic balance tracking
- **Permissioned Transfers**: All transfers must go through accounts and be approved by custom transfer rules
- **Account-Based Architecture**: Tokens can only be held in accounts, with automatic balance tracking
- **Flexible Policies System**: Each token type has associated rules that govern transfers with jurisdiction-specific compliance
- **Optional Clawback**: Regulatory compliance feature that allows token recovery when legally required

## How It Works

1. **Setup**: Registry is created as a shared object, token issuers register their tokens with rules
2. **Chest Creation**: Chests are derived for each address that needs to hold tokens
3. **Transfers**: Initiated from source chest, creating a transfer request that must be resolved by the policy
2. **Account Creation**: Accounts are derived for each address that needs to hold tokens
3. **Transfers**: Initiated from source account, creating a transfer request that must be resolved by the policy
4. **Resolution**: Token-specific smart contracts validate and approve transfers based on compliance rules

## Wallet & SDK Integration

### Simple Discovery
The standard uses derived objects for predictable addresses:
- **Single chest per user** which holds the balances of the user
- **No indexing required** - chest and policy addresses are deterministically computable
- **One query** to see all user balances via dynamic fields on their chest
- **Single account per user** which holds the balances of the user
- **No indexing required** - account and policy addresses are deterministically computable
- **One query** to see all user balances via dynamic fields on their account

### Easy Resolution

Expand Down
116 changes: 58 additions & 58 deletions packages/pas/sources/chest.move → packages/pas/sources/account.move
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Chest logic
module pas::chest;
/// Account logic
module pas::account;

use pas::{
clawback_funds::{Self, ClawbackFunds},
Expand All @@ -14,20 +14,20 @@ use sui::{balance::{Self, Balance}, derived_object};

use fun balance::withdraw_funds_from_object as UID.withdraw_funds_from_object;
#[error(code = 0)]
const ENotOwner: vector<u8> = b"The owner is not valid for the chest.";
const ENotOwner: vector<u8> = b"The owner is not valid for the account.";
#[error(code = 1)]
const EChestAlreadyExists: vector<u8> = b"The chest already exists.";

/// There is only one Chest per address (guaranteed by derived objects).
/// - Balances can only be transferred from Chest A to Chest B.
/// - Chests are shared by default.
/// - Chests creation is permission-less
/// - A `UID` (object) can also own a chest
public struct Chest has key {
const EAccountAlreadyExists: vector<u8> = b"The account already exists.";

/// There is only one Account per address (guaranteed by derived objects).
/// - Balances can only be transferred from Account A to Account B.
/// - Accounts are shared by default.
/// - Accounts creation is permission-less
/// - A `UID` (object) can also own a account
public struct Account has key {
id: UID,
/// The owner of the chest (address or object)
/// The owner of the account (address or object)
owner: address,
/// The ID of the namespace that created this chest.
/// The ID of the namespace that created this account.
/// There's ONLY ONE namespace in the system, but this helps us avoid having
/// `&Namespace` inputs in all functions that need to derive the IDs.
namespace_id: ID,
Expand All @@ -39,28 +39,28 @@ public struct Chest has key {
/// `UID` and `ctx.sender()` (keeping a single API for both).
public struct Auth(address) has drop;

/// Create a new chest for `owner`. This is a permission-less action.
public fun create(namespace: &mut Namespace, owner: address): Chest {
assert!(!namespace.chest_exists(owner), EChestAlreadyExists);
/// Create a new account for `owner`. This is a permission-less action.
public fun create(namespace: &mut Namespace, owner: address): Account {
assert!(!namespace.account_exists(owner), EAccountAlreadyExists);

let versioning = namespace.versioning();
versioning.assert_is_valid_version();

Chest {
id: derived_object::claim(namespace.uid_mut(), keys::chest_key(owner)),
Account {
id: derived_object::claim(namespace.uid_mut(), keys::account_key(owner)),
owner,
namespace_id: object::id(namespace),
versioning,
}
}

/// The only way to finalize the TX is by sharing the chest.
/// All chests are shared by default.
public fun share(chest: Chest) {
transfer::share_object(chest);
/// The only way to finalize the TX is by sharing the account.
/// All accounts are shared by default.
public fun share(account: Account) {
transfer::share_object(account);
}

/// Create and share a chest in a single step.
/// Create and share a account in a single step.
public fun create_and_share(namespace: &mut Namespace, owner: address) {
create(namespace, owner).share()
}
Expand All @@ -69,25 +69,25 @@ public fun create_and_share(namespace: &mut Namespace, owner: address) {
/// This is useful for assets that are not managed by a Policy within the system, or
/// if there's a special case where an issuer allows balances to flow out of the system.
public fun unlock_balance<C>(
chest: &mut Chest,
account: &mut Account,
auth: &Auth,
amount: u64,
_ctx: &mut TxContext,
): Request<UnlockFunds<Balance<C>>> {
auth.assert_is_valid_for_chest!(chest);
chest.versioning.assert_is_valid_version();
unlock_funds::new(chest.owner, chest.id.to_inner(), chest.withdraw_balance<C>(amount))
auth.assert_is_valid_for_account!(account);
account.versioning.assert_is_valid_version();
unlock_funds::new(account.owner, account.id.to_inner(), account.withdraw_balance<C>(amount))
}

/// Initiate a transfer from chest A to chest B.
/// Initiate a transfer from account A to account B.
public fun send_balance<C>(
from: &mut Chest,
from: &mut Account,
auth: &Auth,
to: &Chest,
to: &Account,
amount: u64,
_ctx: &mut TxContext,
): Request<SendFunds<Balance<C>>> {
auth.assert_is_valid_for_chest!(from);
auth.assert_is_valid_for_account!(from);
from.versioning.assert_is_valid_version();
from.internal_send_balance<C>(to.owner, amount)
}
Expand All @@ -97,27 +97,27 @@ public fun send_balance<C>(
///
/// This can only ever finalize if clawback is enabled in the policy.
public fun clawback_balance<C>(
from: &mut Chest,
from: &mut Account,
amount: u64,
_ctx: &mut TxContext,
): Request<ClawbackFunds<Balance<C>>> {
from.versioning.assert_is_valid_version();
clawback_funds::new(from.owner, from.id.to_inner(), from.withdraw_balance<C>(amount))
}

/// Transfer `amount` from chest to an address. This unlocks transfers to a chest before it has been created.
/// Transfer `amount` from account to an address. This unlocks transfers to a account before it has been created.
///
/// It's marked as `unsafe_` as it's easy to accidentally pick the wrong recipient address.
public fun unsafe_send_balance<C>(
from: &mut Chest,
from: &mut Account,
auth: &Auth,
// Recipients should always be the wallet or object address, not the chest ID.
// Recipients should always be the wallet or object address, not the account ID.
// It's recommended to use `transfer` instead for safer transfers.
recipient_address: address,
amount: u64,
_ctx: &mut TxContext,
): Request<SendFunds<Balance<C>>> {
auth.assert_is_valid_for_chest!(from);
auth.assert_is_valid_for_account!(from);
from.versioning.assert_is_valid_version();
from.internal_send_balance<C>(recipient_address, amount)
}
Expand All @@ -127,59 +127,59 @@ public fun new_auth(ctx: &TxContext): Auth {
Auth(ctx.sender())
}

/// Generate an ownership proof from a `UID` object, to allow objects to own chests.
/// Generate an ownership proof from a `UID` object, to allow objects to own accounts.
/// `&mut UID` is intentional — it serves as proof of ownership over the object.
public fun new_auth_as_object(uid: &mut UID): Auth {
Auth(uid.to_inner().to_address())
}

public fun owner(chest: &Chest): address {
chest.owner
public fun owner(account: &Account): address {
account.owner
}

public fun deposit_balance<C>(chest: &Chest, balance: Balance<C>) {
chest.versioning.assert_is_valid_version();
balance::send_funds(balance, object::id(chest).to_address());
public fun deposit_balance<C>(account: &Account, balance: Balance<C>) {
account.versioning.assert_is_valid_version();
balance::send_funds(balance, object::id(account).to_address());
}

/// Permission-less operation to bring versioning up-to-date with the namespace.
public fun sync_versioning(chest: &mut Chest, namespace: &Namespace) {
chest.versioning = namespace.versioning();
public fun sync_versioning(account: &mut Account, namespace: &Namespace) {
account.versioning = namespace.versioning();
}

public(package) fun withdraw_balance<C>(chest: &mut Chest, amount: u64): Balance<C> {
chest.versioning.assert_is_valid_version();
balance::redeem_funds(chest.id.withdraw_funds_from_object(amount))
public(package) fun withdraw_balance<C>(account: &mut Account, amount: u64): Balance<C> {
account.versioning.assert_is_valid_version();
balance::redeem_funds(account.id.withdraw_funds_from_object(amount))
}

public(package) fun versioning(chest: &Chest): Versioning {
chest.versioning
public(package) fun versioning(account: &Account): Versioning {
account.versioning
}

/// Verify that the ownership proof matches the chests owner.
macro fun assert_is_valid_for_chest($proof: &Auth, $chest: &Chest) {
/// Verify that the ownership proof matches the accounts owner.
macro fun assert_is_valid_for_account($proof: &Auth, $account: &Account) {
let proof = $proof;
let chest = $chest;
assert!(&proof.0 == &chest.owner, ENotOwner);
let account = $account;
assert!(&proof.0 == &account.owner, ENotOwner);
}

/// The internal implementation for transferring `amount` from Chest towards another address.
/// The internal implementation for transferring `amount` from Account towards another address.
///
/// INTERNAL WARNING: Callers must verify that `to` is the user address, NOT the chest address.
/// INTERNAL WARNING: Callers must verify that `to` is the user address, NOT the account address.
/// Failure to do so can cause assets to move out of the closed loop, breaking the system assurances
fun internal_send_balance<C>(
from: &mut Chest,
from: &mut Account,
to: address,
amount: u64,
): Request<SendFunds<Balance<C>>> {
let funds = from.withdraw_balance<C>(amount);
let recipient_chest_id = namespace::chest_address_from_id(from.namespace_id, to);
let recipient_account_id = namespace::account_address_from_id(from.namespace_id, to);

send_funds::new(
from.owner,
to,
from.id.to_inner(),
recipient_chest_id.to_id(),
recipient_account_id.to_id(),
funds,
)
}
6 changes: 3 additions & 3 deletions packages/pas/sources/keys.move
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ use sui::vec_set::{Self, VecSet};
/// Key for deriving `Policy<T>` from the namespace
public struct PolicyKey<phantom T>() has copy, drop, store;

/// Key for deriving `Chest` from the namespace
public struct ChestKey(address) has copy, drop, store;
/// Key for deriving `Account` from the namespace
public struct AccountKey(address) has copy, drop, store;

/// Key for deriving `Templates` from the namespace
public struct TemplateKey() has copy, drop, store;

/// WARNING: these should only be used internally.
public(package) fun policy_key<T>(): PolicyKey<T> { PolicyKey<T>() }

public(package) fun chest_key(owner: address): ChestKey { ChestKey(owner) }
public(package) fun account_key(owner: address): AccountKey { AccountKey(owner) }

public(package) fun template_key(): TemplateKey { TemplateKey() }

Expand Down
18 changes: 9 additions & 9 deletions packages/pas/sources/namespace.move
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// The Namespace module.
///
/// Namespace is responsible for creating objects that are easy to query & find:
/// 1. Chests
/// 1. Accounts
/// 2. Policies
/// ... any other module we might add in the future
module pas::namespace;
Expand All @@ -19,7 +19,7 @@ const EUpgradeCapPackageMismatch: vector<u8> =
const EUpgradeCapNotSet: vector<u8> =
b"The upgrade cap is not set for this namespace, making it unusable.";

/// The namespace is only used for address derivation of chests, policies, etc.
/// The namespace is only used for address derivation of accounts, policies, etc.
///
/// Namespace is a singleton -- there's one global version for it.
public struct Namespace has key {
Expand Down Expand Up @@ -79,17 +79,17 @@ public fun policy_address<T>(namespace: &Namespace): address {
derived_object::derive_address(namespace.id.to_inner(), keys::policy_key<T>())
}

public fun chest_exists(namespace: &Namespace, owner: address): bool {
derived_object::exists(&namespace.id, keys::chest_key(owner))
public fun account_exists(namespace: &Namespace, owner: address): bool {
derived_object::exists(&namespace.id, keys::account_key(owner))
}

public fun chest_address(namespace: &Namespace, owner: address): address {
derived_object::derive_address(namespace.id.to_inner(), keys::chest_key(owner))
public fun account_address(namespace: &Namespace, owner: address): address {
derived_object::derive_address(namespace.id.to_inner(), keys::account_key(owner))
}

// Given the name space ID, calculate the chest address.
public(package) fun chest_address_from_id(namespace_id: ID, owner: address): address {
derived_object::derive_address(namespace_id, keys::chest_key(owner))
// Given the name space ID, calculate the account address.
public(package) fun account_address_from_id(namespace_id: ID, owner: address): address {
derived_object::derive_address(namespace_id, keys::account_key(owner))
}

public(package) fun versioning(namespace: &Namespace): Versioning {
Expand Down
12 changes: 6 additions & 6 deletions packages/pas/sources/requests/clawback_funds.move
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ const EClawbackNotAllowed: vector<u8> =
b"Attempted to clawback tokens when clawback is not enabled for this policy.";

public struct ClawbackFunds<T: store> {
/// `owner` is the wallet OR object address, NOT the chest address
/// `owner` is the wallet OR object address, NOT the account address
owner: address,
/// The ID of the chest the funds are coming from
chest_id: ID,
/// The ID of the account the funds are coming from
account_id: ID,
/// The balance that is being clawed back.
funds: T,
}

public fun owner<T: store>(request: &ClawbackFunds<T>): address { request.owner }

public fun chest_id<T: store>(request: &ClawbackFunds<T>): ID { request.chest_id }
public fun account_id<T: store>(request: &ClawbackFunds<T>): ID { request.account_id }

public fun funds<T: store>(request: &ClawbackFunds<T>): &T { &request.funds }

Expand All @@ -36,8 +36,8 @@ public fun resolve<T: store>(request: Request<ClawbackFunds<T>>, policy: &Policy

public(package) fun new<T: store>(
owner: address,
chest_id: ID,
account_id: ID,
funds: T,
): Request<ClawbackFunds<T>> {
request::new(ClawbackFunds { owner, chest_id, funds })
request::new(ClawbackFunds { owner, account_id, funds })
}
Loading
Loading