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 vault (derived address, with easy discoverability). Objects can own vaults as well. This enables with account abstractions / defi protocols implementations
2. Vault uses address (object) balances, so RPCs work out of the box (wallet just treats the vault address like a normal one). Wallets/explorers needs to query for the derived vault address to get balances.
3. Balances can only move from vault to vault (either by safe vault-to-vault deposits, or deriving the recipient with `unsafe_` calls)
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)
4. When a transfer is initiated, a `TransferFunds` 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 (vaults are shared and a clawback can be initiated using the issuer's witness).
5. Clawback is available (chests are shared and a clawback can be initiated using the issuer's witness).

(To be added: Issuers can attach "metadata" to user's Vaults (such as `KYC` stamps or AML stamps they issue), which they can then check on their transfer functions to restrict movement. Since vaults are shared, issuers can revoke these stamps at any moment).
(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).

## Key Features

- **Permissioned Transfers**: All transfers must go through vaults and be approved by custom transfer rules
- **Vault-Based Architecture**: Tokens can only be held in vaults, with automatic balance tracking
- **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
- **Flexible Rules 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. **Vault Creation**: Vaults are derived for each address that needs to hold tokens
3. **Transfers**: Initiated from source vault, creating a transfer request that must be resolved by the rule
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 rule
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 vault per user** which holds the balances of the user
- **No indexing required** - vault and rule addresses are deterministically computable
- **One query** to see all user balances via dynamic fields on their vault
- **Single chest per user** which holds the balances of the user
- **No indexing required** - chest and rule addresses are deterministically computable
- **One query** to see all user balances via dynamic fields on their chest

### Easy Resolution

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

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 = 1)]
const ENotOwner: vector<u8> = b"The owner is not valid for the vault.";
const ENotOwner: vector<u8> = b"The owner is not valid for the chest.";
#[error(code = 2)]
const EVaultAlreadyExists: vector<u8> = b"The vault already exists.";

/// There is only one Vault per address (guaranteed by derived objects).
/// - Balances can only be transferred from Vault A to Vault B.
/// - Vaults are shared by default.
/// - Vaults creation is permission-less
/// - A `UID` (object) can also own a vault
public struct Vault has key {
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 {
id: UID,
/// The owner of the vault (address or object)
/// The owner of the chest (address or object)
owner: address,
/// The ID of the namespace that created this vault.
/// The ID of the namespace that created this chest.
/// 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 Vault has key {
/// `UID` and `ctx.sender()` (keeping a single API for both).
public struct Auth(address) has drop;

/// Create a new vault for `owner`. This is a permission-less action.
public fun create(namespace: &mut Namespace, owner: address): Vault {
assert!(!namespace.vault_exists(owner), EVaultAlreadyExists);
/// 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);

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

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

/// The only way to finalize the TX is by sharing the vault.
/// All vaults are shared by default.
public fun share(vault: Vault) {
transfer::share_object(vault);
/// 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);
}

/// Create and share a vault in a single step.
/// Create and share a chest 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 Rule within the system, or
/// if there's a special case where an issuer allows balances to flow out of the system.
public fun unlock_funds<T>(
vault: &mut Vault,
chest: &mut Chest,
auth: &Auth,
amount: u64,
_ctx: &mut TxContext,
): Request<UnlockFunds<T>> {
auth.assert_is_valid_for_vault!(vault);
vault.versioning.assert_is_valid_version();
unlock_funds::new(vault.owner, vault.id.to_inner(), vault.withdraw(amount))
auth.assert_is_valid_for_chest!(chest);
chest.versioning.assert_is_valid_version();
unlock_funds::new(chest.owner, chest.id.to_inner(), chest.withdraw(amount))
}

/// Initiate a transfer from vault A to vault B.
/// Initiate a transfer from chest A to chest B.
public fun transfer_funds<T>(
from: &mut Vault,
from: &mut Chest,
auth: &Auth,
to: &Vault,
to: &Chest,
amount: u64,
_ctx: &mut TxContext,
): Request<TransferFunds<T>> {
auth.assert_is_valid_for_vault!(from);
auth.assert_is_valid_for_chest!(from);
from.versioning.assert_is_valid_version();
from.internal_transfer_funds<T>(to.owner, amount)
}
Expand All @@ -97,27 +97,27 @@ public fun transfer_funds<T>(
///
/// This can only ever finalize if clawback is enabled in the rule.
public fun clawback_funds<T>(
from: &mut Vault,
from: &mut Chest,
amount: u64,
_ctx: &mut TxContext,
): Request<ClawbackFunds<T>> {
from.versioning.assert_is_valid_version();
clawback_funds::new(from.owner, from.id.to_inner(), from.withdraw(amount))
}

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

/// Generate an ownership proof from a `UID` object, to allow objects to own vaults.
/// Generate an ownership proof from a `UID` object, to allow objects to own chests.
public fun new_auth_as_object(uid: &mut UID): Auth {
Auth(uid.to_inner().to_address())
}

public fun owner(vault: &Vault): address {
vault.owner
public fun owner(chest: &Chest): address {
chest.owner
}

public fun deposit_funds<T>(vault: &Vault, balance: Balance<T>) {
vault.versioning.assert_is_valid_version();
balance::send_funds(balance, object::id(vault).to_address());
public fun deposit_funds<T>(chest: &Chest, balance: Balance<T>) {
chest.versioning.assert_is_valid_version();
balance::send_funds(balance, object::id(chest).to_address());
}

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

public(package) fun withdraw<T>(vault: &mut Vault, amount: u64): Balance<T> {
vault.versioning.assert_is_valid_version();
balance::redeem_funds(vault.id.withdraw_funds_from_object(amount))
public(package) fun withdraw<T>(chest: &mut Chest, amount: u64): Balance<T> {
chest.versioning.assert_is_valid_version();
balance::redeem_funds(chest.id.withdraw_funds_from_object(amount))
}

public(package) fun versioning(vault: &Vault): Versioning {
vault.versioning
public(package) fun versioning(chest: &Chest): Versioning {
chest.versioning
}

/// Verify that the ownership proof matches the vaults owner.
macro fun assert_is_valid_for_vault($proof: &Auth, $vault: &Vault) {
/// Verify that the ownership proof matches the chests owner.
macro fun assert_is_valid_for_chest($proof: &Auth, $chest: &Chest) {
let proof = $proof;
let vault = $vault;
assert!(&proof.0 == &vault.owner, ENotOwner);
let chest = $chest;
assert!(&proof.0 == &chest.owner, ENotOwner);
}

/// The internal implementation for transferring `amount` from Vault towards another address.
/// The internal implementation for transferring `amount` from Chest towards another address.
///
/// INTERNAL WARNING: Callers must verify that `to` is the user address, NOT the vault address.
/// INTERNAL WARNING: Callers must verify that `to` is the user address, NOT the chest address.
/// Failure to do so can cause assets to move out of the closed loop, breaking the system assurances
fun internal_transfer_funds<T>(
from: &mut Vault,
from: &mut Chest,
to: address,
amount: u64,
): Request<TransferFunds<T>> {
let balance = from.withdraw<T>(amount);
let recipient_vault_id = namespace::vault_address_from_id(from.namespace_id, to);
let recipient_chest_id = namespace::chest_address_from_id(from.namespace_id, to);

transfer_funds::new(
from.owner,
to,
from.id.to_inner(),
recipient_vault_id.to_id(),
recipient_chest_id.to_id(),
balance,
)
}
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 `Rule<T>` from the namespace
public struct RuleKey<phantom T>() has copy, drop, store;

/// Key for deriving `Vault` from the namespace
public struct VaultKey(address) has copy, drop, store;
/// Key for deriving `Chest` from the namespace
public struct ChestKey(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 rule_key<T>(): RuleKey<T> { RuleKey<T>() }

public(package) fun vault_key(owner: address): VaultKey { VaultKey(owner) }
public(package) fun chest_key(owner: address): ChestKey { ChestKey(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. Vaults
/// 1. Chests
/// 2. Rules
/// ... 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 vaults, rules, etc.
/// The namespace is only used for address derivation of chests, rules, 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 rule_address<T>(namespace: &Namespace): address {
derived_object::derive_address(namespace.id.to_inner(), keys::rule_key<T>())
}

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

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

// Given the name space ID, calculate the vault address.
public(package) fun vault_address_from_id(namespace_id: ID, owner: address): address {
derived_object::derive_address(namespace_id, keys::vault_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))
}

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 @@ -8,28 +8,28 @@ const EClawbackNotAllowed: vector<u8> =
b"Attempted to clawback tokens when clawback is not enabled for this rule.";

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

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

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

public fun amount<T>(request: &ClawbackFunds<T>): u64 { request.balance.value() }

public(package) fun new<T>(
owner: address,
vault_id: ID,
chest_id: ID,
balance: Balance<T>,
): Request<ClawbackFunds<T>> {
request::new(ClawbackFunds {
owner,
vault_id,
chest_id,
balance,
})
}
Expand Down
Loading