diff --git a/README.md b/README.md index be4228a..cfdda38 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The P-Assets Standard is a framework for issuing and managing permissioned balan 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) -4. When a transfer is initiated, a `TransferFundsRequest` 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. +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). (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). diff --git a/packages/pas/sources/keys.move b/packages/pas/sources/keys.move index cf228b5..9bb9d63 100644 --- a/packages/pas/sources/keys.move +++ b/packages/pas/sources/keys.move @@ -1,12 +1,42 @@ module pas::keys; +use std::string::String; +use sui::vec_set::{Self, VecSet}; + /// Key for deriving `Rule` from the namespace public struct RuleKey() has copy, drop, store; /// Key for deriving `Vault` from the namespace public struct VaultKey(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(): RuleKey { RuleKey() } public(package) fun vault_key(owner: address): VaultKey { VaultKey(owner) } + +public(package) fun template_key(): TemplateKey { TemplateKey() } + +const TRANSFER_FUNDS_ACTION_TYPE: vector = b"transfer_funds"; +const UNLOCK_FUNDS_ACTION_TYPE: vector = b"unlock_funds"; +const CLAWBACK_FUNDS_ACTION_TYPE: vector = b"clawback_funds"; + +public fun transfer_funds_action(): String { TRANSFER_FUNDS_ACTION_TYPE.to_string() } + +public fun unlock_funds_action(): String { UNLOCK_FUNDS_ACTION_TYPE.to_string() } + +public fun clawback_funds_action(): String { CLAWBACK_FUNDS_ACTION_TYPE.to_string() } + +public fun actions(): VecSet { + vec_set::from_keys(vector[ + TRANSFER_FUNDS_ACTION_TYPE.to_string(), + UNLOCK_FUNDS_ACTION_TYPE.to_string(), + CLAWBACK_FUNDS_ACTION_TYPE.to_string(), + ]) +} + +public fun is_valid_action(action: String): bool { + actions().contains(&action) +} diff --git a/packages/pas/sources/namespace.move b/packages/pas/sources/namespace.move index 3c9a217..35087d8 100644 --- a/packages/pas/sources/namespace.move +++ b/packages/pas/sources/namespace.move @@ -6,20 +6,69 @@ /// ... any other module we might add in the future module pas::namespace; -use pas::keys; -use sui::derived_object; +use pas::{keys, versioning::{Self, Versioning}}; +use std::type_name; +use sui::{derived_object, package::UpgradeCap}; + +#[error(code = 0)] +const EUpgradeCapAlreadySet: vector = b"The upgrade cap is already set for this namespace."; +#[error(code = 1)] +const EUpgradeCapPackageMismatch: vector = + b"The upgrade cap package does not match the package."; +#[error(code = 2)] +const EUpgradeCapNotSet: vector = + 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. +/// +/// Namespace is a singleton -- there's one global version for it. public struct Namespace has key { id: UID, + /// The UpgradeCap of the package, used as the "ownership" capability, mainly to + /// block versions of the package in case of emergency. + upgrade_cap_id: Option, + /// Enables "blocking" versions of the package + versioning: Versioning, } +// We publish the Namespace in the `init` function, since it's "singleton". fun init(ctx: &mut TxContext) { transfer::share_object(Namespace { id: object::new(ctx), + upgrade_cap_id: option::none(), + versioning: versioning::new(), }); } +/// Setup the namespace (links the `UpgradeCap`) once after publishing. This makes the UpgradeCap the "admin" capability +/// (which can set the blocked versions of a package). +entry fun setup(namespace: &mut Namespace, cap: &UpgradeCap) { + // setup is already done for upgrade cap + assert!(namespace.upgrade_cap_id.is_none(), EUpgradeCapAlreadySet); + + // Verify the `UpgradeCap` is correct for this package. + assert!( + type_name::with_defining_ids().address_string() == cap.package().to_address().to_ascii_string(), + EUpgradeCapPackageMismatch, + ); + + namespace.upgrade_cap_id = option::some(object::id(cap)); +} + +/// Allows the package admin to block a version of the package. +/// +/// This is only used in case of emergency (e.g. security consideration), or if there is a breaking change +public fun block_version(namespace: &mut Namespace, cap: &UpgradeCap, version: u64) { + assert!(namespace.is_valid_upgrade_cap(cap), EUpgradeCapPackageMismatch); + namespace.versioning.block_version(version); +} + +/// Allows the package admin to unblock a version of the package. +public fun unblock_version(namespace: &mut Namespace, cap: &UpgradeCap, version: u64) { + assert!(namespace.is_valid_upgrade_cap(cap), EUpgradeCapPackageMismatch); + namespace.versioning.unblock_version(version); +} + /// Check if `Rule` exists in the namespace public fun rule_exists(namespace: &Namespace): bool { derived_object::exists(&namespace.id, keys::rule_key()) @@ -43,11 +92,21 @@ public(package) fun vault_address_from_id(namespace_id: ID, owner: address): add derived_object::derive_address(namespace_id, keys::vault_key(owner)) } +public(package) fun versioning(namespace: &Namespace): Versioning { + namespace.versioning +} + /// Expose `uid_mut` so we can claim derived objects from other modules. public(package) fun uid_mut(namespace: &mut Namespace): &mut UID { + // We can only do it after we have set the upgrade cap (to prevent usage of the system before it has been set up). + assert!(namespace.upgrade_cap_id.is_some(), EUpgradeCapNotSet); &mut namespace.id } +fun is_valid_upgrade_cap(namespace: &Namespace, cap: &UpgradeCap): bool { + namespace.upgrade_cap_id.is_some_and!(|id| id == object::id(cap)) +} + #[test_only] public fun init_for_testing(ctx: &mut TxContext) { init(ctx); @@ -57,6 +116,8 @@ public fun init_for_testing(ctx: &mut TxContext) { public fun create_for_testing(ctx: &mut TxContext): Namespace { Namespace { id: object::new(ctx), + upgrade_cap_id: option::none(), + versioning: versioning::new(), } } diff --git a/packages/pas/sources/requests/clawback_funds.move b/packages/pas/sources/requests/clawback_funds.move new file mode 100644 index 0000000..2d85613 --- /dev/null +++ b/packages/pas/sources/requests/clawback_funds.move @@ -0,0 +1,48 @@ +module pas::clawback_funds; + +use pas::{keys::clawback_funds_action, request::{Self, Request}, rule::Rule}; +use sui::balance::Balance; + +#[error(code = 1)] +const EClawbackNotAllowed: vector = + b"Attempted to clawback tokens when clawback is not enabled for this rule."; + +public struct ClawbackFunds { + /// `owner` is the wallet OR object address, NOT the vault address + owner: address, + /// The ID of the vault the funds are coming from + vault_id: ID, + /// The balance that is being clawed back. + balance: Balance, +} + +public fun owner(request: &ClawbackFunds): address { request.owner } + +public fun vault_id(request: &ClawbackFunds): ID { request.vault_id } + +public fun amount(request: &ClawbackFunds): u64 { request.balance.value() } + +public(package) fun new( + owner: address, + vault_id: ID, + balance: Balance, +): Request> { + request::new(ClawbackFunds { + owner, + vault_id, + balance, + }) +} + +/// Resolve a clawback funds request by: +/// 1. Verify rule is valid +/// 2. Verify rule has clawback enabled +/// 3. Make sure rule has enabled clawback resolution +public fun resolve(request: Request>, rule: &Rule): Balance { + rule.versioning().assert_is_valid_version(); + assert!(rule.is_fund_clawback_allowed(), EClawbackNotAllowed); + let data = request.resolve(rule.required_approvals(clawback_funds_action())); + + let ClawbackFunds { balance, .. } = data; + balance +} diff --git a/packages/pas/sources/requests/request.move b/packages/pas/sources/requests/request.move new file mode 100644 index 0000000..d03eae0 --- /dev/null +++ b/packages/pas/sources/requests/request.move @@ -0,0 +1,47 @@ +module pas::request; + +use std::type_name::{Self, TypeName}; +use sui::vec_set::{Self, VecSet}; + +#[error(code = 0)] +const EInsufficientApprovals: vector = + b"Cannot resolve request: insufficient approvals received."; + +/// A base request type. +/// Examples: +/// `Request>` +/// `Request>` +public struct Request { + /// The collected approvals for this request + approvals: VecSet, + data: K, +} + +/// Adds an approval to a request. Can be called to resolve rules +public fun approve(request: &mut Request, _approval: U) { + request.approvals.insert(type_name::with_defining_ids()); +} + +public fun data(request: &Request): &K { + &request.data +} + +public fun approvals(request: &Request): VecSet { + request.approvals +} + +public(package) fun new(data: K): Request { + Request { + approvals: vec_set::empty(), + data, + } +} + +/// An internal function to resolve a request. +public(package) fun resolve(request: Request, required_approvals: VecSet): K { + required_approvals.keys().do_ref!(|approval| { + assert!(request.approvals.contains(approval), EInsufficientApprovals); + }); + let Request { data, .. } = request; + data +} diff --git a/packages/pas/sources/requests/transfer_funds_request.move b/packages/pas/sources/requests/transfer_funds.move similarity index 60% rename from packages/pas/sources/requests/transfer_funds_request.move rename to packages/pas/sources/requests/transfer_funds.move index bb9725a..40fed98 100644 --- a/packages/pas/sources/requests/transfer_funds_request.move +++ b/packages/pas/sources/requests/transfer_funds.move @@ -1,5 +1,6 @@ -module pas::transfer_funds_request; +module pas::transfer_funds; +use pas::{keys::transfer_funds_action, request::{Self, Request}, rule::Rule}; use sui::balance::{Self, Balance}; /// A transfer request that is generated once a Permissioned Funds Transfer is initiated. @@ -16,7 +17,7 @@ use sui::balance::{Self, Balance}; /// - Emit regulatory events /// - Handle dividends/distributions /// - Implement any jurisdiction-specific rules -public struct TransferFundsRequest { +public struct TransferFunds { /// `sender` is the wallet OR object address, NOT the vault address sender: address, /// `recipient` is the wallet OR object address, NOT the vault address @@ -31,17 +32,17 @@ public struct TransferFundsRequest { balance: Balance, } -public fun sender(request: &TransferFundsRequest): address { request.sender } +public fun sender(request: &TransferFunds): address { request.sender } -public fun recipient(request: &TransferFundsRequest): address { request.recipient } +public fun recipient(request: &TransferFunds): address { request.recipient } -public fun sender_vault_id(request: &TransferFundsRequest): ID { request.sender_vault_id } +public fun sender_vault_id(request: &TransferFunds): ID { request.sender_vault_id } -public fun recipient_vault_id(request: &TransferFundsRequest): ID { +public fun recipient_vault_id(request: &TransferFunds): ID { request.recipient_vault_id } -public fun amount(request: &TransferFundsRequest): u64 { request.amount } +public fun amount(request: &TransferFunds): u64 { request.amount } public(package) fun new( sender: address, @@ -49,21 +50,23 @@ public(package) fun new( sender_vault_id: ID, recipient_vault_id: ID, balance: Balance, -): TransferFundsRequest { - TransferFundsRequest { +): Request> { + request::new(TransferFunds { sender, recipient, sender_vault_id, recipient_vault_id, amount: balance.value(), balance, - } + }) } -/// Internal function to resolve a transfer request. -/// WARNING: This must only be called by `rule.move` after verifying the witness, -/// it should never become public. -public(package) fun resolve(request: TransferFundsRequest) { - let TransferFundsRequest { balance, recipient_vault_id, .. } = request; +/// resolve a transfer request, if funds management is enabled & there are enough approvals. +public fun resolve(request: Request>, rule: &Rule) { + rule.versioning().assert_is_valid_version(); + rule.assert_is_fund_management_enabled(); + let data = request.resolve(rule.required_approvals(transfer_funds_action())); + + let TransferFunds { balance, recipient_vault_id, .. } = data; balance::send_funds(balance, recipient_vault_id.to_address()); } diff --git a/packages/pas/sources/requests/unlock_funds_request.move b/packages/pas/sources/requests/unlock_funds.move similarity index 55% rename from packages/pas/sources/requests/unlock_funds_request.move rename to packages/pas/sources/requests/unlock_funds.move index b83eccb..1fba14f 100644 --- a/packages/pas/sources/requests/unlock_funds_request.move +++ b/packages/pas/sources/requests/unlock_funds.move @@ -1,7 +1,7 @@ -module pas::unlock_funds_request; +module pas::unlock_funds; -use pas::namespace::Namespace; -use sui::balance::Balance; +use pas::{keys::unlock_funds_action, namespace::Namespace, request::{Self, Request}, rule::Rule}; +use sui::{balance::Balance, vec_set}; #[error(code = 0)] const ECannotResolveManagedAssets: vector = @@ -12,8 +12,8 @@ const ECannotResolveManagedAssets: vector = /// This can be resolved in two ways: /// 1. If the asset is `permissioned` (there's a `Rule` for that asset), it can only be resolved by the creator /// by calling `rule::resolve_unlock_funds` -/// 2. If the asset is not permissioned, it can be resolved by any address by calling `unlock_funds_request::resolve_unrestricted` -public struct UnlockFundsRequest { +/// 2. If the asset is not permissioned, it can be resolved by any address by calling `unlock_funds::resolve_unrestricted` +public struct UnlockFunds { /// `from` is the wallet OR object address, NOT the vault address owner: address, /// The ID of the vault the funds are coming from @@ -24,11 +24,11 @@ public struct UnlockFundsRequest { balance: Balance, } -public fun owner(request: &UnlockFundsRequest): address { request.owner } +public fun owner(request: &UnlockFunds): address { request.owner } -public fun vault_id(request: &UnlockFundsRequest): ID { request.vault_id } +public fun vault_id(request: &UnlockFunds): ID { request.vault_id } -public fun amount(request: &UnlockFundsRequest): u64 { request.amount } +public fun amount(request: &UnlockFunds): u64 { request.amount } /// This enables unlocking assets that are not managed by a Rule within the system. /// If a `Rule` exists, they can only be resolved from within the system. @@ -36,30 +36,36 @@ public fun amount(request: &UnlockFundsRequest): u64 { request.amount } /// For example, `SUI` will never be a managed asset, so the owner needs to be able /// to withdraw if anyone transfers some to their vault. public fun resolve_unrestricted( - request: UnlockFundsRequest, + request: Request>, namespace: &Namespace, ): Balance { assert!(!namespace.rule_exists(), ECannotResolveManagedAssets); - request.resolve() + namespace.versioning().assert_is_valid_version(); + let data = request.resolve(vec_set::empty()); + let UnlockFunds { balance, .. } = data; + balance } public(package) fun new( owner: address, vault_id: ID, balance: Balance, -): UnlockFundsRequest { - UnlockFundsRequest { +): Request> { + request::new(UnlockFunds { owner, vault_id, amount: balance.value(), balance, - } + }) } -/// Internal function to resolve a transfer request. -/// WARNING: This must only be called by `rule.move` after verifying the witness, -/// it should never become public. -public(package) fun resolve(request: UnlockFundsRequest): Balance { - let UnlockFundsRequest { balance, .. } = request; +/// Resolve an unlock funds request as long as funds management is enabled and +/// there are enough valid approvals. +public fun resolve(request: Request>, rule: &Rule): Balance { + rule.versioning().assert_is_valid_version(); + rule.assert_is_fund_management_enabled(); + let data = request.resolve(rule.required_approvals(unlock_funds_action())); + + let UnlockFunds { balance, .. } = data; balance } diff --git a/packages/pas/sources/rule.move b/packages/pas/sources/rule.move index 5e0e7f6..746b507 100644 --- a/packages/pas/sources/rule.move +++ b/packages/pas/sources/rule.move @@ -1,35 +1,27 @@ module pas::rule; -use pas::{ - keys, - namespace::Namespace, - transfer_funds_request::TransferFundsRequest, - unlock_funds_request::UnlockFundsRequest, - vault::Vault -}; -use ptb::ptb::Command; +use pas::{keys, namespace::Namespace, versioning::Versioning}; use std::{string::String, type_name::{Self, TypeName}}; use sui::{ - balance::Balance, coin::TreasuryCap, derived_object, dynamic_field, - vec_map::{Self, VecMap} + vec_map::{Self, VecMap}, + vec_set::{Self, VecSet} }; -#[error(code = 0)] -const EInvalidProof: vector = - b"The authorization witness does not match the rule's expected witness type."; #[error(code = 1)] -const EClawbackNotAllowed: vector = - b"Attempted to clawback tokens when clawback is not enabled for this rule."; -#[error(code = 2)] const ERuleAlreadyExists: vector = b"A rule for this token type already exists."; -#[error(code = 3)] +#[error(code = 2)] const EFundManagementNotEnabled: vector = b"Fund management is not enabled for this rule."; -#[error(code = 4)] +#[error(code = 3)] const EFundManagementAlreadyEnabled: vector = b"Fund management is already enabled for this rule."; +#[error(code = 4)] +const EInvalidAction: vector = b"Invalid action type."; +#[error(code = 5)] +const ENotSupportedAction: vector = + b"The requested action type is not supported by the issuer."; /// A rule is set by the owner of `T`, and points to a `TypeName` that needs /// to be verified by the entity's contract. @@ -37,17 +29,22 @@ const EFundManagementAlreadyEnabled: vector = /// This is derived from `namespace, TypeName` public struct Rule has key { id: UID, - /// The typename used to prove that the "smart contract" agrees with an action for a given `T`. - /// Initially, this only means it approves "transfers", "clawbacks" and "mints (managed scenario)". - /// In the future, there might be NFT version of these rules. - auth_witness: TypeName, + /// The required approvals per request type. + /// The key must be one of the request types (e.g. `transfer_funds`, `unlock_funds` or `clawback_funds`). + /// + /// The value is a vector of approvals that need to be gather to resolve the request. + required_approvals: VecMap>, + /// Block versions to break backwards compatibility -- only used in case of emergency. + versioning: Versioning, } -/// This is the key under which we save a DF that stores the resolution info. -/// This is stored as a DF to: -/// 1. Enable offline calculation -/// 2. Allow for easy replacement of the Command object -public struct ResolutionInfo() has copy, drop, store; +/// Capability for managing a `Rule`. It's 1:1. +public struct RuleCap has key, store { + id: UID, +} + +/// Key that is used to derive the RuleCap ID from `Rule` +public struct RuleCapKey() has copy, drop, store; /// A flag saved as to check if claw-backs are enabled /// for a given asset. @@ -55,27 +52,23 @@ public struct FundsClawbackState() has copy, drop, store; /// Create a new `Rule` for `T`. /// We use `Permit` as the proof of ownership for `T`. -public fun new( - namespace: &mut Namespace, - _: internal::Permit, - // The author can specify a custom witness type `U` for approving actions of the system. - // That could also be `Permit` if there's no need for separation. - _stamp: U, -): Rule { +public fun new(namespace: &mut Namespace, _: internal::Permit): (Rule, RuleCap) { assert!(!namespace.rule_exists(), ERuleAlreadyExists); + let versioning = namespace.versioning(); + versioning.assert_is_valid_version(); + let mut rule = Rule { id: derived_object::claim(namespace.uid_mut(), keys::rule_key()), - auth_witness: type_name::with_defining_ids(), + required_approvals: vec_map::empty(), + versioning, }; - dynamic_field::add<_, VecMap>( - &mut rule.id, - ResolutionInfo(), - vec_map::empty(), - ); + let rule_cap = RuleCap { + id: derived_object::claim(&mut rule.id, RuleCapKey()), + }; - rule + (rule, rule_cap) } public fun share(rule: Rule) { @@ -90,75 +83,60 @@ public fun enable_funds_management( clawback_allowed: bool, ) { assert!(!rule.is_fund_management_enabled(), EFundManagementAlreadyEnabled); + rule.versioning.assert_is_valid_version(); dynamic_field::add(&mut rule.id, FundsClawbackState(), clawback_allowed); } -/// Resolve an unlock funds request by verifying the authorization witness and finalizing the unlock. -public fun resolve_unlock_funds( - rule: &Rule, - request: UnlockFundsRequest, - _stamp: U, -): Balance { - rule.assert_is_valid_issuer_proof!<_, U>(); - rule.assert_is_fund_management_enabled!(); - request.resolve() +/// Get the set of required approvals for a given action. +public fun required_approvals(rule: &Rule, action_type: String): VecSet { + assert!(rule.required_approvals.contains(&action_type), ENotSupportedAction); + *rule.required_approvals.get(&action_type) } -/// Resolve a transfer request by verifying the authorization witness and finalizing the transfer. -/// Aborts with `EInvalidProof` if the witness does not match the rule's authorization witness. -public fun resolve_transfer_funds( - rule: &Rule, - request: TransferFundsRequest, - _stamp: U, +/// For a set of actions, set the approvals required to conclude the action. +/// +/// Supported actions: ["transfer_funds", "unlock_funds", "clawback_funds"] +public(package) fun set_required_approvals( + rule: &mut Rule, + _: &RuleCap, + action: String, + approvals: VecSet, ) { - rule.assert_is_valid_issuer_proof!<_, U>(); - rule.assert_is_fund_management_enabled!(); - // destructuring the request to finalize the transfer. - request.resolve(); + rule.versioning.assert_is_valid_version(); + assert!(keys::is_valid_action(action), EInvalidAction); + + if (rule.required_approvals.contains(&action)) { + rule.required_approvals.remove(&action); + }; + rule.required_approvals.insert(action, approvals); } -/// Clawbacks `amount` of balance from a Vault, returning `Balance` by value. -/// -/// WARNING: This does not guarantee that the funds will not go out of the controlled system. -/// Use with caution. -public fun clawback_funds( - rule: &Rule, - from: &mut Vault, - amount: u64, - _stamp: U, -): Balance { - assert!(rule.is_fund_clawback_allowed(), EClawbackNotAllowed); - rule.assert_is_valid_issuer_proof!<_, U>(); - - from.withdraw(amount) +public fun set_required_approval(rule: &mut Rule, cap: &RuleCap, action: String) { + rule.set_required_approvals( + cap, + action, + vec_set::singleton(type_name::with_defining_ids()), + ); +} + +/// Remove the action approval for a given action (this will make all requests not resolve). +public fun remove_action_approval(rule: &mut Rule, _: &RuleCap, action: String) { + rule.versioning.assert_is_valid_version(); + rule.required_approvals.remove(&action); } /// Check if clawback is allowed or not. /// Aborts early if the management for funds has not been enabled for `T`. public fun is_fund_clawback_allowed(rule: &Rule): bool { - rule.assert_is_fund_management_enabled!(); + rule.assert_is_fund_management_enabled(); + rule.versioning.assert_is_valid_version(); *dynamic_field::borrow(&rule.id, FundsClawbackState()) } -/// Set the move command for a specific action type. -/// NOTE: If the action type already exists, it will be replaced. -public fun set_action_command(rule: &mut Rule, command: Command, _stamp: U) { - rule.assert_is_valid_issuer_proof!<_, U>(); - let action_type = type_name::with_defining_ids(); - - let action_type_str = (*action_type.as_string()).to_string(); - - let info_map: &mut VecMap = dynamic_field::borrow_mut( - &mut rule.id, - ResolutionInfo(), - ); - - // Remove if already exists (as this is a setter). - if (info_map.contains(&action_type_str)) { - info_map.remove(&action_type_str); - }; - - info_map.insert(action_type_str, command); +/// Allows syncing the versioning of a rule to the namespace's versioning. +/// This is permission-less and can be done +public fun sync_versioning(rule: &mut Rule, namespace: &Namespace) { + rule.versioning = namespace.versioning(); } /// Check if fund management is enabled for a given `T`. @@ -166,14 +144,8 @@ public(package) fun is_fund_management_enabled(rule: &Rule): bool { dynamic_field::exists_(&rule.id, FundsClawbackState()) } -public fun auth_witness(rule: &Rule): TypeName { rule.auth_witness } +public(package) fun versioning(rule: &Rule): Versioning { rule.versioning } -macro fun assert_is_fund_management_enabled<$T>($rule: &Rule<$T>) { - let rule = $rule; +public fun assert_is_fund_management_enabled(rule: &Rule) { assert!(rule.is_fund_management_enabled(), EFundManagementNotEnabled); } - -macro fun assert_is_valid_issuer_proof<$T, $U: drop>($rule: &Rule<$T>) { - let rule = $rule; - assert!(type_name::with_defining_ids<$U>() == rule.auth_witness, EInvalidProof); -} diff --git a/packages/pas/sources/templates.move b/packages/pas/sources/templates.move new file mode 100644 index 0000000..fc03e85 --- /dev/null +++ b/packages/pas/sources/templates.move @@ -0,0 +1,44 @@ +/// Template stores all the Command templates for PAS. +/// +/// This is the lookup point for PTB resolution on the client-side! +/// There's no versioning enforcement here, as this is purely an off-chain used endpoint. +module pas::templates; + +use pas::{keys, namespace::Namespace}; +use ptb::ptb::Command; +use std::type_name; +use sui::{derived_object, dynamic_field}; + +#[error(code = 0)] +const ETemplateNotSet: vector = b"Template not set for this action."; + +public struct Templates has key { + id: UID, +} + +// Create the templates registry +entry fun setup(namespace: &mut Namespace) { + transfer::share_object(Templates { + id: derived_object::claim(namespace.uid_mut(), keys::template_key()), + }) +} + +// Sets the PTB template for a given Action. +public fun set_template_command( + templates: &mut Templates, + _: internal::Permit, + command: Command, +) { + let key = type_name::with_defining_ids(); + if (dynamic_field::exists_(&templates.id, key)) { + let _ = dynamic_field::remove<_, Command>(&mut templates.id, key); + }; + + dynamic_field::add(&mut templates.id, key, command); +} + +public fun unset_template_command(templates: &mut Templates, _: internal::Permit) { + let key = type_name::with_defining_ids(); + assert!(dynamic_field::exists_(&templates.id, key), ETemplateNotSet); + dynamic_field::remove<_, Command>(&mut templates.id, key); +} diff --git a/packages/pas/sources/vault.move b/packages/pas/sources/vault.move index 1839c2f..64f6d22 100644 --- a/packages/pas/sources/vault.move +++ b/packages/pas/sources/vault.move @@ -2,10 +2,13 @@ module pas::vault; use pas::{ + clawback_funds::{Self, ClawbackFunds}, keys, namespace::{Self, Namespace}, - transfer_funds_request::{Self, TransferFundsRequest}, - unlock_funds_request::{Self, UnlockFundsRequest} + request::Request, + transfer_funds::{Self, TransferFunds}, + unlock_funds::{Self, UnlockFunds}, + versioning::Versioning }; use sui::{balance::{Self, Balance}, derived_object}; @@ -28,6 +31,8 @@ public struct Vault has key { /// 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, + /// Block versions to break backwards compatibility -- only used in case of emergency. + versioning: Versioning, } /// A proof that address has authenticated. This allows for uniform access control between both @@ -38,10 +43,14 @@ public struct Auth(address) has drop; public fun create(namespace: &mut Namespace, owner: address): Vault { assert!(!namespace.vault_exists(owner), EVaultAlreadyExists); + let versioning = namespace.versioning(); + versioning.assert_is_valid_version(); + Vault { id: derived_object::claim(namespace.uid_mut(), keys::vault_key(owner)), owner, namespace_id: object::id(namespace), + versioning, } } @@ -64,9 +73,10 @@ public fun unlock_funds( auth: &Auth, amount: u64, _ctx: &mut TxContext, -): UnlockFundsRequest { +): Request> { auth.assert_is_valid_for_vault!(vault); - unlock_funds_request::new(vault.owner, vault.id.to_inner(), vault.withdraw(amount)) + vault.versioning.assert_is_valid_version(); + unlock_funds::new(vault.owner, vault.id.to_inner(), vault.withdraw(amount)) } /// Initiate a transfer from vault A to vault B. @@ -76,11 +86,25 @@ public fun transfer_funds( to: &Vault, amount: u64, _ctx: &mut TxContext, -): TransferFundsRequest { +): Request> { auth.assert_is_valid_for_vault!(from); + from.versioning.assert_is_valid_version(); from.internal_transfer_funds(to.owner, amount) } +/// Initiate a clawback request for an amount of funds. +/// This takes no `Auth`, as it's an admin action. +/// +/// This can only ever finalize if clawback is enabled in the rule. +public fun clawback_funds( + from: &mut Vault, + amount: u64, + _ctx: &mut TxContext, +): Request> { + 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. /// /// It's marked as `unsafe_` as it's easy to accidentally pick the wrong recipient address. @@ -92,8 +116,9 @@ public fun unsafe_transfer_funds( recipient_address: address, amount: u64, _ctx: &mut TxContext, -): TransferFundsRequest { +): Request> { auth.assert_is_valid_for_vault!(from); + from.versioning.assert_is_valid_version(); from.internal_transfer_funds(recipient_address, amount) } @@ -112,13 +137,24 @@ public fun owner(vault: &Vault): address { } public fun deposit_funds(vault: &Vault, balance: Balance) { + vault.versioning.assert_is_valid_version(); balance::send_funds(balance, object::id(vault).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(package) fun withdraw(vault: &mut Vault, amount: u64): Balance { + vault.versioning.assert_is_valid_version(); balance::redeem_funds(vault.id.withdraw_funds_from_object(amount)) } +public(package) fun versioning(vault: &Vault): Versioning { + vault.versioning +} + /// Verify that the ownership proof matches the vaults owner. macro fun assert_is_valid_for_vault($proof: &Auth, $vault: &Vault) { let proof = $proof; @@ -134,11 +170,11 @@ fun internal_transfer_funds( from: &mut Vault, to: address, amount: u64, -): TransferFundsRequest { +): Request> { let balance = from.withdraw(amount); let recipient_vault_id = namespace::vault_address_from_id(from.namespace_id, to); - transfer_funds_request::new( + transfer_funds::new( from.owner, to, from.id.to_inner(), diff --git a/packages/pas/sources/versioning.move b/packages/pas/sources/versioning.move new file mode 100644 index 0000000..a627b70 --- /dev/null +++ b/packages/pas/sources/versioning.move @@ -0,0 +1,47 @@ +/// Versioning module. +/// +/// This module is responsible for managing the versioning of the package. +/// +/// It allows for blocking specific versions of the package in case of emergency, or to slowly deprecate an earlier feature. +module pas::versioning; + +use sui::vec_set::{Self, VecSet}; + +#[error(code = 0)] +const EInvalidVersion: vector = + b"This version of the core package (pas) is no longer supported. Please use the latest version of the package."; + +public struct Versioning has copy, drop, store { + blocked_versions: VecSet, +} + +public(package) fun new(): Versioning { + Versioning { + blocked_versions: vec_set::empty(), + } +} + +public(package) fun block_version(versioning: &mut Versioning, version: u64) { + versioning.blocked_versions.insert(version); +} + +public(package) fun unblock_version(versioning: &mut Versioning, version: u64) { + versioning.blocked_versions.remove(&version); +} + +/// Verify that a version is not part of the blocked version list. +public fun is_valid_version(versioning: &Versioning, version: u64): bool { + !versioning.blocked_versions.contains(&version) +} + +public fun assert_is_valid_version(versioning: &Versioning) { + assert!(versioning.is_valid_version(breaking_version!()), EInvalidVersion); +} + +/// The current package's breaking version. +/// +/// A breaking version is not equal to the released version. It acts as a marker to allow +/// disabling specific packages. +/// +/// This is bumped only in case of emergency, or to slowly deprecate an earlier feature. +public macro fun breaking_version(): u64 { 1 } diff --git a/packages/pas/tests/clawback_tests.move b/packages/pas/tests/clawback_tests.move new file mode 100644 index 0000000..bf1aade --- /dev/null +++ b/packages/pas/tests/clawback_tests.move @@ -0,0 +1,73 @@ +#[test_only, allow(unused_variable, unused_mut_ref, dead_code)] +module pas::clawback_tests; + +use pas::{ + clawback_funds, + e2e::{test_tx, a_witness, A, b_witness, B, AWitness}, + rule::RuleCap, + vault +}; +use std::{type_name, unit_test::assert_eq}; +use sui::balance; + +#[test] +fun clawback_managed_assets() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + let mut vault = vault::create(namespace, @0x1); + vault.deposit_funds(balance::create_for_testing(100)); + + let mut clawback_request = vault.clawback_funds(50, scenario.ctx()); + assert_eq!(clawback_request.data().amount(), 50); + assert_eq!(clawback_request.data().owner(), @0x1); + assert_eq!(clawback_request.data().vault_id(), namespace.vault_address(@0x1).to_id()); + + clawback_request.approve(a_witness()); + + assert_eq!(clawback_request.approvals().length(), 1); + assert!(clawback_request.approvals().contains(&type_name::with_defining_ids())); + + let balance = clawback_funds::resolve(clawback_request, managed_rule); + + assert_eq!(balance.value(), 50); + + vault.share(); + + balance.send_funds(@0x10); + }); +} + +#[test, expected_failure(abort_code = ::pas::rule::ENotSupportedAction)] +fun try_to_clawback_when_clawback_stamp_is_not_set() { + test_tx!(@0x1, |namespace, managed_rule, _r, scenario| { + scenario.next_tx(@0x1); + + let rule_cap = scenario.take_from_sender>(); + managed_rule.remove_action_approval(&rule_cap, "clawback_funds"); + + let mut vault = vault::create(namespace, @0x1); + vault.deposit_funds(balance::create_for_testing(100)); + + let mut clawback_request = vault.clawback_funds(50, scenario.ctx()); + clawback_request.approve(a_witness()); + + let balance = clawback_funds::resolve(clawback_request, managed_rule); + abort + }); +} + +#[test, expected_failure(abort_code = ::pas::clawback_funds::EClawbackNotAllowed)] +fun try_to_clawback_unmanaged_assets() { + test_tx!(@0x1, |namespace, _managed_rule, unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + let mut vault = vault::create(namespace, @0x1); + vault.deposit_funds(balance::create_for_testing(100)); + + let mut clawback_request = vault.clawback_funds(50, scenario.ctx()); + clawback_request.approve(b_witness()); + + let _balance = clawback_funds::resolve(clawback_request, unmanaged_rule); + + abort + }); +} diff --git a/packages/pas/tests/e2e.move b/packages/pas/tests/e2e.move index a5120d0..665a716 100644 --- a/packages/pas/tests/e2e.move +++ b/packages/pas/tests/e2e.move @@ -1,7 +1,7 @@ #[test_only, allow(unused_variable, unused_mut_ref, dead_code)] module pas::e2e; -use pas::{rule, vault::{Self, Vault}}; +use pas::{rule, transfer_funds, unlock_funds, vault::{Self, Vault}}; use std::unit_test::{assert_eq, destroy}; use sui::{balance::{Self, send_funds}, sui::SUI, test_scenario::return_shared}; @@ -43,17 +43,22 @@ fun e2e() { .to_id()); let auth = vault::new_auth(scenario.ctx()); - let transfer_request = vault.transfer_funds(&auth, &another_vault, 50, scenario.ctx()); + let mut transfer_request = vault.transfer_funds( + &auth, + &another_vault, + 50, + scenario.ctx(), + ); - // Stamp the request (authorized action) - managed_rule.resolve_transfer_funds(transfer_request, AWitness()); + transfer_request.approve(AWitness()); + transfer_funds::resolve(transfer_request, managed_rule); return_shared(vault); return_shared(another_vault); }); } -#[test, expected_failure(abort_code = ::pas::rule::EInvalidProof)] +#[test, expected_failure(abort_code = ::pas::request::EInsufficientApprovals)] fun try_to_approve_transfer_with_invalid_witness() { test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { let namespace_id = object::id(namespace); @@ -69,15 +74,17 @@ fun try_to_approve_transfer_with_invalid_witness() { .to_id()); let auth = vault::new_auth(scenario.ctx()); - let transfer_request = vault.unsafe_transfer_funds( + let mut transfer_request = vault.unsafe_transfer_funds( &auth, @0x2, 50, scenario.ctx(), ); - // Stamp the request (authorized action) - managed_rule.resolve_transfer_funds(transfer_request, BWitness()); + // Add an invalid approval to the request + transfer_request.approve(BWitness()); + transfer_funds::resolve(transfer_request, managed_rule); + abort }); } @@ -106,11 +113,11 @@ fun test_address_and_derivation_matches() { scenario.ctx(), ); - assert_eq!(transfer_request.sender(), @0x1); - assert_eq!(transfer_request.recipient(), @0x2); - assert_eq!(transfer_request.sender_vault_id(), user_one_vault_id); - assert_eq!(transfer_request.recipient_vault_id(), user_two_vault_id); - assert_eq!(transfer_request.amount(), 50); + assert_eq!(transfer_request.data().sender(), @0x1); + assert_eq!(transfer_request.data().recipient(), @0x2); + assert_eq!(transfer_request.data().sender_vault_id(), user_one_vault_id); + assert_eq!(transfer_request.data().recipient_vault_id(), user_two_vault_id); + assert_eq!(transfer_request.data().amount(), 50); // Both scenarios must calculate the from/to equivalent. let safe_request = user_one_vault.transfer_funds( @@ -119,11 +126,11 @@ fun test_address_and_derivation_matches() { 50, scenario.ctx(), ); - assert_eq!(safe_request.sender(), @0x1); - assert_eq!(safe_request.recipient(), @0x2); - assert_eq!(safe_request.sender_vault_id(), user_one_vault_id); - assert_eq!(safe_request.recipient_vault_id(), user_two_vault_id); - assert_eq!(safe_request.amount(), 50); + assert_eq!(safe_request.data().sender(), @0x1); + assert_eq!(safe_request.data().recipient(), @0x2); + assert_eq!(safe_request.data().sender_vault_id(), user_one_vault_id); + assert_eq!(safe_request.data().recipient_vault_id(), user_two_vault_id); + assert_eq!(safe_request.data().amount(), 50); destroy(transfer_request); destroy(safe_request); @@ -141,55 +148,19 @@ fun unlock_funds_successfully() { vault.deposit_funds(balance::create_for_testing(100)); let auth = vault::new_auth(scenario.ctx()); - let unlock_request = vault.unlock_funds(&auth, 50, scenario.ctx()); + let mut unlock_request = vault.unlock_funds(&auth, 50, scenario.ctx()); + + unlock_request.approve(AWitness()); + let balance = unlock_funds::resolve(unlock_request, managed_rule); - let balance = managed_rule.resolve_unlock_funds(unlock_request, AWitness()); assert_eq!(balance.value(), 50); vault.share(); - balance.send_funds(@0x10); }); } -#[test, expected_failure(abort_code = ::pas::vault::ENotOwner)] -fun try_to_auth_to_another_owners_vault() { - test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { - scenario.next_tx(@0x1); - vault::create_and_share(namespace, @0x1); - - scenario.next_tx(@0x2); - - let mut vault = scenario.take_shared_by_id(namespace - .vault_address( - @0x1, - ) - .to_id()); - - let auth = vault::new_auth(scenario.ctx()); - - let transfer_request = vault.unsafe_transfer_funds( - &auth, - @0x2, - 50, - scenario.ctx(), - ); - - abort - }); -} - -#[test, expected_failure(abort_code = ::pas::vault::EVaultAlreadyExists)] -fun try_to_create_vault_with_same_owner() { - test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { - scenario.next_tx(@0x1); - vault::create_and_share(namespace, @0x1); - vault::create_and_share(namespace, @0x1); - abort - }); -} - -#[test, expected_failure(abort_code = ::pas::unlock_funds_request::ECannotResolveManagedAssets)] +#[test, expected_failure(abort_code = ::pas::unlock_funds::ECannotResolveManagedAssets)] fun try_to_resolve_unlock_funds_request_for_managed_assets() { test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { scenario.next_tx(@0x1); @@ -199,7 +170,8 @@ fun try_to_resolve_unlock_funds_request_for_managed_assets() { let auth = vault::new_auth(scenario.ctx()); let unlock_request = vault.unlock_funds(&auth, 50, scenario.ctx()); - let _balance = unlock_request.resolve_unrestricted(namespace); + let _balance = unlock_funds::resolve_unrestricted(unlock_request, namespace); + abort }); } @@ -213,7 +185,7 @@ fun unlock_non_managed_funds() { let auth = vault::new_auth(scenario.ctx()); let unlock_request = vault.unlock_funds(&auth, 100, scenario.ctx()); - let balance = unlock_request.resolve_unrestricted(namespace); + let balance = unlock_funds::resolve_unrestricted(unlock_request, namespace); balance.send_funds(@0x1); @@ -240,7 +212,7 @@ fun try_to_transfer_unmanaged_assets() { scenario.next_tx(@0x1); // create a rule but do not enable funds management. - let rule = rule::new(namespace, internal::permit(), internal::permit()); + let (rule, cap) = rule::new(namespace, internal::permit()); // somehow transfer balance to vault a let mut vault = vault::create(namespace, @0x1); @@ -256,8 +228,7 @@ fun try_to_transfer_unmanaged_assets() { scenario.ctx(), ); - rule.resolve_transfer_funds(transfer_request, internal::permit()); - + transfer_funds::resolve(transfer_request, &rule); abort }); } @@ -268,7 +239,7 @@ fun try_to_unlock_unmanaged_assets() { scenario.next_tx(@0x1); // create a rule but do not enable funds management. - let rule = rule::new(namespace, internal::permit(), internal::permit()); + let (rule, cap) = rule::new(namespace, internal::permit()); // somehow transfer balance to vault a let mut vault = vault::create(namespace, @0x1); @@ -282,36 +253,7 @@ fun try_to_unlock_unmanaged_assets() { scenario.ctx(), ); - let _balance = rule.resolve_unlock_funds(unlock_request, internal::permit()); - - abort - }); -} - -#[test] -fun clawback_managed_assets() { - test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { - scenario.next_tx(@0x1); - let mut vault = vault::create(namespace, @0x1); - vault.deposit_funds(balance::create_for_testing(100)); - - let balance = managed_rule.clawback_funds(&mut vault, 50, AWitness()); - assert_eq!(balance.value(), 50); - - vault.share(); - - balance.send_funds(@0x10); - }); -} - -#[test, expected_failure(abort_code = ::pas::rule::EClawbackNotAllowed)] -fun try_to_clawback_unmanaged_assets() { - test_tx!(@0x1, |namespace, _managed_rule, unmanaged_rule, scenario| { - scenario.next_tx(@0x1); - let mut vault = vault::create(namespace, @0x1); - vault.deposit_funds(balance::create_for_testing(100)); - - let _balance = unmanaged_rule.clawback_funds(&mut vault, 50, BWitness()); + let _balance = unlock_funds::resolve(unlock_request, &rule); abort }); @@ -330,50 +272,6 @@ fun derivation_is_consistent() { }); } -#[test] -fun authenticate_with_uid() { - test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { - let namespace_id = object::id(namespace); - scenario.next_tx(@0x1); - - // create a UID. - let mut uid = object::new(scenario.ctx()); - - let uid_address = uid.to_inner().to_address(); - vault::create_and_share(namespace, uid_address); - - scenario.next_tx(@0x1); - - let mut vault = scenario.take_shared(); - - assert_eq!(vault.owner(), uid_address); - assert_eq!(object::id(&vault).to_address(), namespace.vault_address(uid_address)); - - let auth = vault::new_auth_as_object(&mut uid); - - let transfer_request = vault.unsafe_transfer_funds( - &auth, - @0x2, - 50, - scenario.ctx(), - ); - - assert_eq!(transfer_request.sender(), uid_address); - assert_eq!(transfer_request.recipient(), @0x2); - assert_eq!( - transfer_request.sender_vault_id(), - namespace.vault_address(uid_address).to_id(), - ); - assert_eq!(transfer_request.recipient_vault_id(), namespace.vault_address(@0x2).to_id()); - assert_eq!(transfer_request.amount(), 50); - - destroy(transfer_request); - - return_shared(vault); - uid.delete(); - }); -} - #[test] fun test_unlock_request_getters() { test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { @@ -385,9 +283,9 @@ fun test_unlock_request_getters() { let unlock_request = vault.unlock_funds(&auth, 50, scenario.ctx()); - assert_eq!(unlock_request.owner(), @0x1); - assert_eq!(unlock_request.vault_id(), namespace.vault_address(@0x1).to_id()); - assert_eq!(unlock_request.amount(), 50); + assert_eq!(unlock_request.data().owner(), @0x1); + assert_eq!(unlock_request.data().vault_id(), namespace.vault_address(@0x1).to_id()); + assert_eq!(unlock_request.data().amount(), 50); destroy(unlock_request); vault.share(); @@ -398,12 +296,34 @@ fun test_unlock_request_getters() { fun try_to_create_duplicate_rule() { test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { scenario.next_tx(@0x1); - let rule = rule::new(namespace, internal::permit(), AWitness()); + let (rule, rule_cap) = rule::new(namespace, internal::permit()); abort }); } +public fun package_id(): ID { + sui::address::from_ascii_bytes(std::type_name::with_defining_ids() + .address_string() + .as_bytes()).to_id() +} + +public fun a_permit(): internal::Permit { + internal::permit() +} + +public fun b_permit(): internal::Permit { + internal::permit() +} + +public fun a_witness(): AWitness { + AWitness() +} + +public fun b_witness(): BWitness { + BWitness() +} + /// A test_tx already set up for convenience. public macro fun test_tx( $admin: address, @@ -422,17 +342,35 @@ public macro fun test_tx( let mut namespace = scenario.take_shared(); - let mut rule_a = pas::rule::new(&mut namespace, internal::permit(), AWitness()); + let package_id = pas::e2e::package_id(); + + let upgrade_cap = sui::package::test_publish(package_id, scenario.ctx()); + namespace.setup(&upgrade_cap); + sui::transfer::public_transfer(upgrade_cap, $admin); + + pas::templates::setup(&mut namespace); - let mut cap_a = sui::coin::create_treasury_cap_for_testing(scenario.ctx()); - rule_a.enable_funds_management(&mut cap_a, true); - std::unit_test::destroy(cap_a); + let (mut rule_a, rule_cap_a) = pas::rule::new(&mut namespace, pas::e2e::a_permit()); + + let mut treasury_cap_a = sui::coin::create_treasury_cap_for_testing(scenario.ctx()); + rule_a.enable_funds_management(&mut treasury_cap_a, true); + rule_a.set_required_approval<_, AWitness>(&rule_cap_a, "transfer_funds"); + rule_a.set_required_approval<_, AWitness>(&rule_cap_a, "unlock_funds"); + rule_a.set_required_approval<_, AWitness>(&rule_cap_a, "clawback_funds"); + // rule_a. + sui::transfer::public_transfer(rule_cap_a, $admin); + std::unit_test::destroy(treasury_cap_a); rule_a.share(); - let mut rule_b = pas::rule::new(&mut namespace, internal::permit(), BWitness()); - let mut cap_b = sui::coin::create_treasury_cap_for_testing(scenario.ctx()); - rule_b.enable_funds_management(&mut cap_b, false); - std::unit_test::destroy(cap_b); + let (mut rule_b, rule_cap_b) = pas::rule::new(&mut namespace, pas::e2e::b_permit()); + let mut treasury_cap_b = sui::coin::create_treasury_cap_for_testing(scenario.ctx()); + + rule_b.set_required_approval<_, BWitness>(&rule_cap_b, "transfer_funds"); + rule_b.set_required_approval<_, BWitness>(&rule_cap_b, "unlock_funds"); + + rule_b.enable_funds_management(&mut treasury_cap_b, false); + std::unit_test::destroy(treasury_cap_b); + std::unit_test::destroy(rule_cap_b); rule_b.share(); scenario.next_tx($admin); diff --git a/packages/pas/tests/rule_setup_tests.move b/packages/pas/tests/rule_setup_tests.move new file mode 100644 index 0000000..7cd9796 --- /dev/null +++ b/packages/pas/tests/rule_setup_tests.move @@ -0,0 +1,52 @@ +#[test_only, allow(unused_variable, unused_mut_ref, dead_code)] +module pas::rule_setup_tests; + +use pas::{e2e::{test_tx, A}, rule::RuleCap, transfer_funds, vault}; +use sui::balance; + +public struct InvalidActionApproval() has drop; + +public struct NewActionApproval() has drop; + +#[test] +fun override_action_approval() { + test_tx!(@0x1, |namespace, managed_rule, unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + + let rule_cap = scenario.take_from_sender>(); + managed_rule.set_required_approval<_, NewActionApproval>(&rule_cap, "transfer_funds"); + + // Do a test transfer to verify the override auth works + { + let mut vault = vault::create(namespace, @0x1); + + vault.deposit_funds(balance::create_for_testing(100)); + + let auth = vault::new_auth(scenario.ctx()); + let mut transfer_request = vault.unsafe_transfer_funds( + &auth, + @0x2, + 50, + scenario.ctx(), + ); + transfer_request.approve(NewActionApproval()); + transfer_funds::resolve(transfer_request, managed_rule); + + vault.share(); + }; + + scenario.return_to_sender(rule_cap); + }); +} + +#[test, expected_failure(abort_code = ::pas::rule::EInvalidAction)] +fun set_invalid_action_approval() { + test_tx!(@0x1, |namespace, managed_rule, unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + + let rule_cap = scenario.take_from_sender>(); + managed_rule.set_required_approval<_, InvalidActionApproval>(&rule_cap, "invalid_action"); + + abort + }); +} diff --git a/packages/pas/tests/vault_auth_tests.move b/packages/pas/tests/vault_auth_tests.move new file mode 100644 index 0000000..4664b97 --- /dev/null +++ b/packages/pas/tests/vault_auth_tests.move @@ -0,0 +1,110 @@ +#[test_only, allow(unused_variable, unused_mut_ref, dead_code)] +module pas::vault_auth_tests; + +use pas::{e2e::{test_tx, A}, vault::{Self, Vault}}; +use std::unit_test::{assert_eq, destroy}; +use sui::test_scenario::return_shared; + +#[test] +fun authenticate_with_uid() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + let namespace_id = object::id(namespace); + scenario.next_tx(@0x1); + + // create a UID. + let mut uid = object::new(scenario.ctx()); + + let uid_address = uid.to_inner().to_address(); + vault::create_and_share(namespace, uid_address); + + scenario.next_tx(@0x1); + + let mut vault = scenario.take_shared(); + + assert_eq!(vault.owner(), uid_address); + assert_eq!(object::id(&vault).to_address(), namespace.vault_address(uid_address)); + + let auth = vault::new_auth_as_object(&mut uid); + + let transfer_request = vault.unsafe_transfer_funds( + &auth, + @0x2, + 50, + scenario.ctx(), + ); + + assert_eq!(transfer_request.data().sender(), uid_address); + assert_eq!(transfer_request.data().recipient(), @0x2); + assert_eq!( + transfer_request.data().sender_vault_id(), + namespace.vault_address(uid_address).to_id(), + ); + assert_eq!( + transfer_request.data().recipient_vault_id(), + namespace.vault_address(@0x2).to_id(), + ); + assert_eq!(transfer_request.data().amount(), 50); + + destroy(transfer_request); + + return_shared(vault); + uid.delete(); + }); +} + +#[test, expected_failure(abort_code = ::pas::vault::ENotOwner)] +fun try_to_auth_to_another_owners_vault() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + vault::create_and_share(namespace, @0x1); + + scenario.next_tx(@0x2); + + let mut vault = scenario.take_shared_by_id(namespace + .vault_address( + @0x1, + ) + .to_id()); + + let auth = vault::new_auth(scenario.ctx()); + + let _transfer_request = vault.unsafe_transfer_funds( + &auth, + @0x2, + 50, + scenario.ctx(), + ); + + abort + }); +} + +#[test, expected_failure(abort_code = ::pas::vault::ENotOwner)] +fun try_to_auth_to_another_uid_vault() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + let mut vault = vault::create(namespace, @0x1); + + let mut uid = object::new(scenario.ctx()); + + let auth = vault::new_auth_as_object(&mut uid); + + let transfer_request = vault.unlock_funds( + &auth, + 50, + scenario.ctx(), + ); + + abort + }); +} + +#[test, expected_failure(abort_code = ::pas::vault::EVaultAlreadyExists)] +fun try_to_create_vault_with_same_owner() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + vault::create_and_share(namespace, @0x1); + vault::create_and_share(namespace, @0x1); + abort + }); +} diff --git a/packages/pas/tests/versioning_tests.move b/packages/pas/tests/versioning_tests.move new file mode 100644 index 0000000..b1e1b2c --- /dev/null +++ b/packages/pas/tests/versioning_tests.move @@ -0,0 +1,129 @@ +#[test_only, allow(unused_variable, unused_mut_ref, dead_code)] +module pas::versioning_tests; + +use pas::{ + e2e::{package_id, test_tx, A}, + namespace::{Self, Namespace}, + vault, + versioning::breaking_version +}; +use ptb::ptb::Command; +use std::unit_test::assert_eq; +use sui::{package::UpgradeCap, test_scenario::Scenario}; + +#[test, expected_failure(abort_code = ::pas::namespace::EUpgradeCapAlreadySet)] +fun tries_to_setup_namespace_twice() { + let mut scenario = sui::test_scenario::begin(@0x0); + namespace::init_for_testing(scenario.ctx()); + scenario.next_tx(@0x0); + + let mut namespace = scenario.take_shared(); + + let package_id = package_id(); + + let upgrade_cap = sui::package::test_publish(package_id, scenario.ctx()); + namespace.setup(&upgrade_cap); + namespace.setup(&upgrade_cap); + + abort +} + +#[test, expected_failure(abort_code = ::pas::namespace::EUpgradeCapPackageMismatch)] +fun tries_to_setup_namespace_with_invalid_upgrade_cap() { + let mut scenario = sui::test_scenario::begin(@0x0); + namespace::init_for_testing(scenario.ctx()); + scenario.next_tx(@0x0); + + let mut namespace = scenario.take_shared(); + + // create the upgrade cap from a type coming from a dependency. + let package_id = package_id(); + + let upgrade_cap = sui::package::test_publish(package_id, scenario.ctx()); + namespace.setup(&upgrade_cap); + + abort +} + +#[test, expected_failure(abort_code = ::pas::namespace::EUpgradeCapPackageMismatch)] +fun tries_to_block_version_with_invalid_upgrade_cap() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + + let upgrade_cap = sui::package::test_publish(package_id(), scenario.ctx()); + namespace.block_version(&upgrade_cap, 1); + + abort + }); +} + +#[test, expected_failure(abort_code = ::pas::namespace::EUpgradeCapPackageMismatch)] +fun tries_to_unblock_version_with_invalid_upgrade_cap() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + + let upgrade_cap = sui::package::test_publish(package_id(), scenario.ctx()); + namespace.unblock_version(&upgrade_cap, 1); + + abort + }); +} + +#[test] +fun block_unblock_versions_and_sync_with_vaults_and_rules() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + scenario.next_tx(@0x1); + let upgrade_cap = scenario.take_from_sender(); + + let mut vault = vault::create(namespace, @0x1); + + namespace.block_version(&upgrade_cap, 1); + assert!(!namespace.versioning().is_valid_version(1)); + vault.sync_versioning(namespace); + managed_rule.sync_versioning(namespace); + assert_eq!(vault.versioning(), namespace.versioning()); + assert!(!vault.versioning().is_valid_version(1)); + assert!(!managed_rule.versioning().is_valid_version(1)); + + namespace.unblock_version(&upgrade_cap, 1); + vault.sync_versioning(namespace); + managed_rule.sync_versioning(namespace); + assert!(namespace.versioning().is_valid_version(1)); + assert!(vault.versioning().is_valid_version(1)); + assert!(managed_rule.versioning().is_valid_version(1)); + + vault.share(); + scenario.return_to_sender(upgrade_cap); + }); +} + +#[test, expected_failure(abort_code = ::pas::versioning::EInvalidVersion)] +fun try_to_create_vault_with_invalid_version() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + namespace.block_current_version(scenario); + + let _vault = vault::create(namespace, @0x1); + abort + }); +} + +#[test, expected_failure(abort_code = ::pas::versioning::EInvalidVersion)] +fun try_unlock_funds_invalid_version_on_vault() { + test_tx!(@0x1, |namespace, managed_rule, _unmanaged_rule, scenario| { + let mut vault = vault::create(namespace, @0x1); + + namespace.block_current_version(scenario); + vault.sync_versioning(namespace); + let auth = vault::new_auth(scenario.ctx()); + let req = vault.unlock_funds(&auth, 50, scenario.ctx()); + abort + }); +} + +use fun block_current_version as Namespace.block_current_version; + +fun block_current_version(namespace: &mut Namespace, scenario: &Scenario) { + let upgrade_cap = scenario.take_from_sender(); + namespace.block_version(&upgrade_cap, breaking_version!()); + scenario.return_to_sender(upgrade_cap); +} diff --git a/packages/testing/demo_usd/Move.lock b/packages/testing/demo_usd/Move.lock index f333d7b..2054416 100644 --- a/packages/testing/demo_usd/Move.lock +++ b/packages/testing/demo_usd/Move.lock @@ -5,13 +5,13 @@ version = 4 [pinned.testnet.MoveStdlib] -source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "563c15820b27dec9cbe75f826a3b6243ef44da1a" } +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } use_environment = "testnet" manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" deps = {} [pinned.testnet.Sui] -source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "563c15820b27dec9cbe75f826a3b6243ef44da1a" } +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } use_environment = "testnet" manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" deps = { MoveStdlib = "MoveStdlib" } diff --git a/packages/testing/demo_usd/sources/demo_usd.move b/packages/testing/demo_usd/sources/demo_usd.move index dd42d00..20f4266 100644 --- a/packages/testing/demo_usd/sources/demo_usd.move +++ b/packages/testing/demo_usd/sources/demo_usd.move @@ -9,8 +9,10 @@ module demo_usd::demo_usd; use pas::namespace::Namespace; -use pas::rule::{Self, Rule}; -use pas::transfer_funds_request::TransferFundsRequest; +use pas::request::Request; +use pas::rule::{Self, Rule, RuleCap}; +use pas::templates::Templates; +use pas::transfer_funds::TransferFunds; use ptb::ptb; use std::type_name; use sui::balance::Balance; @@ -23,6 +25,9 @@ const EInvalidAmount: vector = b"Any amount over 10K is not allowed in this #[error(code = 1)] const ECannotSelfTransfer: vector = b"Transfers cannot be made to the same address as the sender."; +#[error(code = 2)] +const ENotAllowedRecipient: vector = + b"Transfers to the address 0x2 are not allowed in this demo."; /// One-time witness for the demo_usd package public struct DEMO_USD has drop {} @@ -32,11 +37,17 @@ public struct Faucet has key { id: UID, cap: TreasuryCap, metadata: MetadataCap, + rule_cap: Option>, } /// Stamp used in PAS for authorizing any admin action. public struct ActionStamp() has drop; +public struct TransferApproval() has drop; +public struct TransferApprovalV2() has drop; + +public struct UnlockApproval() has drop; + public fun faucet_mint_balance(faucet: &mut Faucet, amount: u64): Balance { faucet.cap.mint_balance(amount) } @@ -59,39 +70,61 @@ fun init(otw: DEMO_USD, ctx: &mut TxContext) { id: object::new(ctx), cap, metadata, + rule_cap: option::none(), }); } -entry fun setup(namespace: &mut Namespace, faucet: &mut Faucet) { - let mut rule = rule::new(namespace, internal::permit(), ActionStamp()); +entry fun setup(namespace: &mut Namespace, templates: &mut Templates, faucet: &mut Faucet) { + let (mut rule, cap) = rule::new(namespace, internal::permit()); + // Enable funds management (with clawbacks!) rule.enable_funds_management(&mut faucet.cap, true); + rule.set_required_approval<_, TransferApproval>(&cap, "transfer_funds"); + + faucet.rule_cap.fill(cap); + let type_name = type_name::with_defining_ids(); let cmd = ptb::move_call( type_name.address_string().to_string(), "demo_usd", - "resolve_transfer", - vector[ - ptb::ext_input("pas:request"), - ptb::ext_input("pas:rule"), - ptb::object_by_id(@0x6.to_id()), - ], + "approve_transfer", + vector[ptb::ext_input("pas:request"), ptb::object_by_id(@0x6.to_id())], vector[(*type_name.as_string()).to_string()], ); - rule.set_action_command<_, _, TransferFundsRequest>(cmd, ActionStamp()); + templates.set_template_command(internal::permit(), cmd); rule.share(); } +/// starts using v2 approve transfer to test upgradeability. +public fun use_v2(rule: &mut Rule, templates: &mut Templates, faucet: &mut Faucet) { + let cmd = ptb::move_call( + type_name::with_defining_ids().address_string().to_string(), + "demo_usd", + "approve_transfer_v2", + vector[ptb::ext_input("pas:request"), ptb::object_by_id(object::id(faucet))], + vector[], + ); + + templates.set_template_command(internal::permit(), cmd); + + rule.set_required_approval<_, TransferApprovalV2>(faucet.rule_cap.borrow(), "transfer_funds"); +} + /// Resolver function for transfer requests - simply approves all transfers -public fun resolve_transfer(request: TransferFundsRequest, rule: &Rule, _clock: &Clock) { +public fun approve_transfer(request: &mut Request>, _clock: &Clock) { // We only allow transfers with value less than 10K. // NOTE: This is only for testing, this is not really enforceable like this as you could batch multiple in a PTB. - assert!(request.amount() < 10_000 * 1_000_000, EInvalidAmount); - assert!(request.sender() != request.recipient(), ECannotSelfTransfer); + assert!(request.data().amount() < 10_000 * 1_000_000, EInvalidAmount); + assert!(request.data().sender() != request.data().recipient(), ECannotSelfTransfer); + + request.approve(TransferApproval()); +} - // Resolve the transfer! - rule.resolve_transfer_funds(request, ActionStamp()) +/// V2 function allows all transfers, besides transferring to 0x2. +public fun approve_transfer_v2(request: &mut Request>, _faucet: &Faucet) { + assert!(request.data().recipient() != @0x2, ENotAllowedRecipient); + request.approve(TransferApprovalV2()); } diff --git a/sdk/example-app/package.json b/sdk/example-app/package.json index 963c6b4..3fcdbde 100644 --- a/sdk/example-app/package.json +++ b/sdk/example-app/package.json @@ -11,7 +11,7 @@ "@mysten/sui": "workspace:^" }, "devDependencies": { - "@mysten/sui": "^2.0.1", + "@mysten/sui": "^2.4.0", "@types/node": "^25.0.8", "tsx": "^4.19.2", "typescript": "^5.9.3" diff --git a/sdk/example-app/src/extension-example.ts b/sdk/example-app/src/extension-example.ts index 3c1cc92..c796743 100644 --- a/sdk/example-app/src/extension-example.ts +++ b/sdk/example-app/src/extension-example.ts @@ -5,8 +5,8 @@ * Example demonstrating PAS SDK usage with the SDK v2.0 $extend pattern */ -const assetType = '0xf4874d6d4854f92019a2b3914d3838522a72f3c02658893488417ac90c00189b::demo_usd::DEMO_USD'; -const demoAssetFaucet = '0x6e48b7accee3e69f3b2ac3d86b9496dac059bd5b7ee3e8869a70ceb1f78ee20b' +const assetType = '0xbcd1cffae40317c7870e55c65af7fc20a8c46ce0ed1a1b24b1edf576480e2fa8::demo_usd::DEMO_USD'; +const demoAssetFaucet = '0x9d1fb399a8748a6afdd687a93c4c9303e6f7787c860d5e705136f7b979a3b4d7' import { SuiGrpcClient } from '@mysten/sui/grpc'; import { decodeSuiPrivateKey, Signer } from '@mysten/sui/cryptography'; @@ -26,6 +26,7 @@ async function main(): Promise { baseUrl: 'https://fullnode.devnet.sui.io:443', }).$extend(pas()); + // await finalizeTestAssetSetup(client); // console.log(await getBalancesForAddress(client, sender)); // await createVaultForAddress(client, sender); @@ -87,7 +88,7 @@ async function finalizeTestAssetSetup(client: PasClientType) { tx.moveCall({ target: assetType.split('::')[0] + '::demo_usd::setup', - arguments: [tx.object(client.pas.getPackageConfig().namespaceId)] + arguments: [tx.object(client.pas.getPackageConfig().namespaceId), tx.object(client.pas.deriveTemplateRegistryAddress()), tx.object(demoAssetFaucet)] }); await signAndExecute(client, tx); @@ -95,7 +96,7 @@ async function finalizeTestAssetSetup(client: PasClientType) { async function createVaultForAddress(client: PasClientType, address: string) { const tx = new Transaction(); - tx.add(client.pas.call.createAndShareVault(address)); + tx.add(client.pas.tx.vaultForAddress(address)); return signAndExecute(client, tx); } diff --git a/sdk/package.json b/sdk/package.json index 4b6b06f..76992f8 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -10,12 +10,12 @@ "author": "Mysten Labs", "license": "Apache-2.0", "devDependencies": { - "@mysten/sui": "^2.0.1", - "@mysten/bcs": "^2.0.1", + "@mysten/sui": "^2.4.0", + "@mysten/bcs": "^2.0.2", "@mysten/codegen": "^0.6.0" }, "peerDependencies": { - "@mysten/sui": "^2.0.0", - "@mysten/bcs": "^2.0.0" + "@mysten/sui": "^2.4.0", + "@mysten/bcs": "^2.0.2" } } diff --git a/sdk/pas/package.json b/sdk/pas/package.json index cd30348..71ff774 100644 --- a/sdk/pas/package.json +++ b/sdk/pas/package.json @@ -43,8 +43,8 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mysten/codegen": "^0.6.0", - "@mysten/sui": "^2.0.1", - "@mysten/bcs": "^2.0.1", + "@mysten/sui": "^2.4.0", + "@mysten/bcs": "^2.0.2", "@testcontainers/postgresql": "^11.11.0", "@types/node": "^25.0.8", "@types/ws": "^8.18.1", diff --git a/sdk/pas/src/bcs.ts b/sdk/pas/src/bcs.ts index c9c1337..bae713c 100644 --- a/sdk/pas/src/bcs.ts +++ b/sdk/pas/src/bcs.ts @@ -2,26 +2,6 @@ import { bcs, BcsType } from '@mysten/sui/bcs'; import { MoveStruct } from './contracts/utils/index.js'; -/** An entry in the map */ -export function Entry, V extends BcsType>(...typeParameters: [K, V]) { - return new MoveStruct({ - name: `0x2::vec_map::Entry<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, - fields: { - key: typeParameters[0], - value: typeParameters[1], - }, - }); -} -/* VecMap representation */ -export function VecMap, V extends BcsType>(...typeParameters: [K, V]) { - return new MoveStruct({ - name: `0x2::vec_map::VecMap<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, - fields: { - contents: bcs.vector(Entry(typeParameters[0], typeParameters[1])), - }, - }); -} - /** dynamic Field representation */ export function Field, Value extends BcsType>( ...typeParameters: [Name, Value] diff --git a/sdk/pas/src/client.ts b/sdk/pas/src/client.ts index 3be25e8..da177af 100644 --- a/sdk/pas/src/client.ts +++ b/sdk/pas/src/client.ts @@ -2,25 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import type { ClientWithCoreApi } from '@mysten/sui/client'; -import { Transaction } from '@mysten/sui/transactions'; -import { deriveDynamicFieldID } from '@mysten/sui/utils'; import { DEVNET_PAS_PACKAGE_CONFIG, MAINNET_PAS_PACKAGE_CONFIG, TESTNET_PAS_PACKAGE_CONFIG, } from './constants.js'; -import { ResolutionInfo } from './contracts/pas/rule.js'; -import { resolveUnrestricted } from './contracts/pas/unlock_funds_request.js'; -import * as Vault from './contracts/pas/vault.js'; -import { deriveRuleAddress, deriveVaultAddress } from './derivation.js'; -import { PASClientError, RuleNotFoundError, VaultNotFoundError } from './error.js'; import { - addMoveCallFromCommand, - buildActionTypeName, - getCommandForAction, - PASActionType, -} from './resolution.js'; + deriveRuleAddress, + deriveTemplateAddress, + deriveTemplateRegistryAddress, + deriveVaultAddress, +} from './derivation.js'; +import { PASClientError } from './error.js'; +import { + transferFundsIntent, + unlockFundsIntent, + unlockUnrestrictedFundsIntent, + vaultForAddressIntent, +} from './intents.js'; import type { PASClientConfig, PASOptions, PASPackageConfig } from './types.js'; export function pas({ @@ -124,314 +124,79 @@ export class PASClient { } /** - * Get the PTB resolution map for a given rule. - * @param assetType - The full type of the asset (e.g. `0x2::sui::SUI`) - * @returns the ID of the resolution map + * Derives the templates object address for a given package configuration. + * + * @returns The derived templates object ID */ - deriveRuleResolutionInfoAddress(assetType: string): string { - const ruleAddress = this.deriveRuleAddress(assetType); - return deriveDynamicFieldID( - ruleAddress, - `${this.#packageConfig.packageId}::rule::ResolutionInfo`, - ResolutionInfo.serialize([false]).toBytes(), - ); + deriveTemplateRegistryAddress(): string { + return deriveTemplateRegistryAddress(this.#packageConfig); } - call = { - createVault: (owner: string) => { - return (tx: Transaction) => { - return Vault.create({ - package: this.#packageConfig.packageId, - arguments: [this.#packageConfig.namespaceId, owner], - })(tx); - }; - }, - createAndShareVault: (owner: string) => { - return (tx: Transaction) => { - return Vault.createAndShare({ - package: this.#packageConfig.packageId, - arguments: [this.#packageConfig.namespaceId, owner], - })(tx); - }; - }, - }; - /** - * Methods that create transactions without executing them + * Derives the template DF address for a given approval type name. + * + * @param approvalTypeName - The fully qualified approval type name + * @returns The derived dynamic field object ID */ - tx = { - /** - * Creates a transfer funds transaction. It auto-resolves the creator's transfer function. - * - * @param options - Transfer options - * @param options.from - The sender's address (owner of the source vault) - * @param options.to - The receiver's address (owner of the destination vault) - * @param options.amount - The amount to transfer - * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") - * @returns An async thunk that takes a Transaction and executes the transfer - */ - transferFunds: (options: { - from: string; - to: string; - amount: number | bigint; - assetType: string; - }) => { - const { from, to, amount, assetType } = options; - - return async (tx: Transaction) => { - // 1. Derive addresses - const fromVaultId = this.deriveVaultAddress(from); - const toVaultId = this.deriveVaultAddress(to); - const ruleId = this.deriveRuleAddress(assetType); - const resolutionInfoId = this.deriveRuleResolutionInfoAddress(assetType); - - // 2. Fetch all objects in a single batch call - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [resolutionInfoId, fromVaultId, toVaultId], - include: { content: true }, - }); - - // 3. Find objects by ID - const resolutionInfoResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === resolutionInfoId, - ); - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - const toVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === toVaultId, - ); - - if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) { - throw new VaultNotFoundError(from); - } - - // 4. Validate and parse rule - if ( - !resolutionInfoResult || - resolutionInfoResult instanceof Error || - !resolutionInfoResult.content - ) { - throw new RuleNotFoundError(assetType); - } - - // 6. Check if recipient vault exists - const toVaultExists = - toVaultResult && !(toVaultResult instanceof Error) && toVaultResult.content !== null; - - // 6. Create auth proof from transaction sender - const auth = Vault.newAuth({ - package: this.#packageConfig.packageId, - })(tx); - - // 7. Create recipient vault if needed - let toVault; - let shouldShareVault = false; - if (toVaultExists) { - toVault = tx.object(toVaultId); - } else { - toVault = Vault.create({ - package: this.#packageConfig.packageId, - arguments: [this.#packageConfig.namespaceId, to], - })(tx); - shouldShareVault = true; - } - - // 8. Create the transfer request using vault::transfer_funds - const transferRequest = Vault.transferFunds({ - package: this.#packageConfig.packageId, - arguments: [tx.object(fromVaultId), auth, toVault, amount], - typeArguments: [assetType], - })(tx); - - // 9. Get the command for TransferFunds action - const actionTypeName = buildActionTypeName( - PASActionType.TransferFunds, - assetType, - this.#packageConfig, - ); - - const command = getCommandForAction(resolutionInfoResult, actionTypeName); - - if (!command) { - throw new PASClientError( - `No command found for TransferFunds action in Rule for ${assetType}`, - ); - } - - // 10. Build the PTB from the command - const result = addMoveCallFromCommand(command, { - tx, - senderVault: tx.object(fromVaultId), - receiverVault: toVault, - rule: tx.object(ruleId), - request: transferRequest, - systemType: assetType, - }); - - // 11. Share the vault if it was just created - if (shouldShareVault) { - Vault.share({ - package: this.#packageConfig.packageId, - arguments: [toVault], - })(tx); - } - - return result; - }; - }, - - /** - * Creates an unlock funds transaction. It is quite likely that this won't succeed - * unless the issuer has specific circumstances under which they allow unlocks. - * - * @param options - Unlock options - * @param options.from - The sender's address (owner of the source vault) - * @param options.amount - The amount to unlock - * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") - * @returns An async thunk that takes a Transaction and executes the unlock - */ - unlockFunds: (options: { from: string; amount: number | bigint; assetType: string }) => { - const { from, amount, assetType } = options; - - return async (tx: Transaction) => { - // 1. Derive addresses - const fromVaultId = this.deriveVaultAddress(from); - const ruleId = this.deriveRuleAddress(assetType); - const resolutionInfoId = this.deriveRuleResolutionInfoAddress(assetType); - - // 2. Fetch all objects in a single batch call - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [resolutionInfoId, fromVaultId], - include: { content: true }, - }); - - // 3. Find objects by ID - const resolutionInfoResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === resolutionInfoId, - ); - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - - if ( - !resolutionInfoResult || - resolutionInfoResult instanceof Error || - !resolutionInfoResult.content - ) { - throw new PASClientError( - `Rule does not exist for asset type ${assetType}. - That means that the issuer has not yet enabled funds management for this asset. - If this is a non-managed asset, you can use the unrestricted unlock flow by calling unlockUnrestrictedFunds() instead.`, - ); - } - - if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) { - throw new VaultNotFoundError(from); - } - - // 4. Create auth proof from transaction sender - const auth = Vault.newAuth({ - package: this.#packageConfig.packageId, - })(tx); - - // 5. Create the unlock request using vault::unlock_funds - const unlockRequest = Vault.unlockFunds({ - package: this.#packageConfig.packageId, - arguments: [tx.object(fromVaultId), auth, amount], - typeArguments: [assetType], - })(tx); - - // 6. Get the command for UnlockFunds action - const actionTypeName = buildActionTypeName( - PASActionType.UnlockFunds, - assetType, - this.#packageConfig, - ); - const command = getCommandForAction(resolutionInfoResult, actionTypeName); - - if (!command) { - throw new PASClientError( - `No command found for UnlockFunds action in Rule for ${assetType}. That means that the issuer has not enabled unlocks.`, - ); - } - - // 7. Build the PTB from the command - return addMoveCallFromCommand(command, { - tx, - senderVault: tx.object(fromVaultId), - rule: tx.object(ruleId), - request: unlockRequest, - systemType: assetType, - }); - }; - }, - - /** - * Creates an unlock funds transaction for unrestricted assets. - * Unrestricted are assets that are not managed by the system, with this offering - * a way to unlock funds, when a rule does not exist. - * - * @param options - Unlock options - * @param options.from - The sender's address (owner of the source vault) - * @param options.amount - The amount to unlock - * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") - * @returns An async thunk that takes a Transaction and executes the unlock - */ - unlockUnrestrictedFunds: (options: { - from: string; - amount: number | bigint; - assetType: string; - }) => { - const { from, amount, assetType } = options; - - return async (tx: Transaction) => { - // 1. Derive addresses - const fromVaultId = this.deriveVaultAddress(from); - const ruleId = this.deriveRuleAddress(assetType); - - // 2. fetch objects - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [ruleId, fromVaultId], - include: { content: true }, - }); - - // 3. Find objects by ID - const ruleResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === ruleId, - ); - - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - - // If `from` vault does not exist, error out. - if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) - throw new VaultNotFoundError(from); - - if (ruleResult) { - throw new PASClientError( - `A rule exists for asset type ${assetType}. That means that the issuer has enabled funds management for this asset and you can no longer use the unrestricted unlock flow.`, - ); - } - - const auth = Vault.newAuth({ - package: this.#packageConfig.packageId, - })(tx); - - // 4. Create the unlock request using vault::unlock_funds - const unlockRequest = Vault.unlockFunds({ - package: this.#packageConfig.packageId, - arguments: [tx.object(fromVaultId), auth, amount], - typeArguments: [assetType], - })(tx); + deriveTemplateAddress(approvalTypeName: string): string { + return deriveTemplateAddress(this.deriveTemplateRegistryAddress(), approvalTypeName); + } - return resolveUnrestricted({ - package: this.#packageConfig.packageId, - arguments: [unlockRequest, this.#packageConfig.namespaceId], - typeArguments: [assetType], - })(tx); - }; - }, - }; + /** + * Intent-based transaction builders. Each method returns a synchronous closure + * that registers a `$Intent` placeholder in the transaction. The actual PTB commands + * are resolved lazily at `tx.build()` time via the shared PAS resolver plugin. + */ + get tx() { + return { + /** + * Creates a transfer funds intent. At build time, it auto-resolves the issuer's + * approval template commands by reading the Rule and Templates objects on-chain. + * If the recipient vault does not exist, it will be created and shared automatically. + * + * @param options - Transfer options + * @param options.from - The sender's address (owner of the source vault) + * @param options.to - The receiver's address (owner of the destination vault) + * @param options.amount - The amount to transfer + * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") + * @returns A sync closure `(tx: Transaction) => TransactionResult` + */ + transferFunds: transferFundsIntent(this.#packageConfig), + + /** + * Creates an unlock funds intent. At build time, it resolves the issuer's + * approval template commands. This will fail if the issuer has not configured + * unlock approvals for the asset type. + * + * @param options - Unlock options + * @param options.from - The sender's address (owner of the source vault) + * @param options.amount - The amount to unlock + * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") + * @returns A sync closure `(tx: Transaction) => TransactionResult` + */ + unlockFunds: unlockFundsIntent(this.#packageConfig), + + /** + * Creates an unlock funds intent for unrestricted (non-managed) assets. + * Use this when no Rule exists for the asset type (e.g., SUI). + * + * @param options - Unlock options + * @param options.from - The sender's address (owner of the source vault) + * @param options.amount - The amount to unlock + * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") + * @returns A sync closure `(tx: Transaction) => TransactionResult` + */ + unlockUnrestrictedFunds: unlockUnrestrictedFundsIntent(this.#packageConfig), + + /** + * Returns a vault object for the given address. At build time, if the vault + * already exists on-chain it resolves to an object reference; otherwise it + * creates the vault and shares it. + * + * @param owner - The owner address + * @returns A sync closure `(tx: Transaction) => TransactionResult` (the vault) + */ + vaultForAddress: vaultForAddressIntent(this.#packageConfig), + }; + } } diff --git a/sdk/pas/src/constants.ts b/sdk/pas/src/constants.ts index 9841feb..ffeb65e 100644 --- a/sdk/pas/src/constants.ts +++ b/sdk/pas/src/constants.ts @@ -15,6 +15,6 @@ export const MAINNET_PAS_PACKAGE_CONFIG: PASPackageConfig = { // TODO: Remove devnet when going live with the client. export const DEVNET_PAS_PACKAGE_CONFIG: PASPackageConfig = { - packageId: '0x8edb029482f90e4160db01841f8b51bf1602df8f83728c5af596522b30836595', - namespaceId: '0xc79061241d77af9907bd0c7c4163f864c1a5deaaadb3526502df5fb9963e1423', + packageId: '0xcb8c93eab81b9a4f0cb48382962cdac0f16767a23ae81e5f7c4c44690afd4f2a', + namespaceId: '0xf7cb5378eefb861af87eaa9c621e29d7f061a6f3919d241502dc1549b7718a1c', }; diff --git a/sdk/pas/src/contracts/pas/clawback_funds.ts b/sdk/pas/src/contracts/pas/clawback_funds.ts new file mode 100644 index 0000000..9fbdca0 --- /dev/null +++ b/sdk/pas/src/contracts/pas/clawback_funds.ts @@ -0,0 +1,115 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import * as balance from './deps/sui/balance.js'; + +const $moduleName = '@mysten/pas::clawback_funds'; +export const ClawbackFunds = new MoveStruct({ + name: `${$moduleName}::ClawbackFunds`, + fields: { + /** `owner` is the wallet OR object address, NOT the vault address */ + owner: bcs.Address, + /** The ID of the vault the funds are coming from */ + vault_id: bcs.Address, + /** The balance that is being clawed back. */ + balance: balance.Balance, + }, +}); +export interface OwnerArguments { + request: RawTransactionArgument; +} +export interface OwnerOptions { + package?: string; + arguments: OwnerArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function owner(options: OwnerOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'clawback_funds', + function: 'owner', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface VaultIdArguments { + request: RawTransactionArgument; +} +export interface VaultIdOptions { + package?: string; + arguments: VaultIdArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function vaultId(options: VaultIdOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'clawback_funds', + function: 'vault_id', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface AmountArguments { + request: RawTransactionArgument; +} +export interface AmountOptions { + package?: string; + arguments: AmountArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function amount(options: AmountOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'clawback_funds', + function: 'amount', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface ResolveArguments { + request: RawTransactionArgument; + rule: RawTransactionArgument; +} +export interface ResolveOptions { + package?: string; + arguments: + | ResolveArguments + | [request: RawTransactionArgument, rule: RawTransactionArgument]; + typeArguments: [string]; +} +/** + * Resolve a clawback funds request by: + * + * 1. Verify rule is valid + * 2. Verify rule has clawback enabled + * 3. Make sure rule has enabled clawback resolution + */ +export function resolve(options: ResolveOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['request', 'rule']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'clawback_funds', + function: 'resolve', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} diff --git a/sdk/pas/src/contracts/pas/deps/sui/vec_map.ts b/sdk/pas/src/contracts/pas/deps/sui/vec_map.ts new file mode 100644 index 0000000..aac8343 --- /dev/null +++ b/sdk/pas/src/contracts/pas/deps/sui/vec_map.ts @@ -0,0 +1,35 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { bcs, type BcsType } from '@mysten/sui/bcs'; + +import { MoveStruct } from '../../../utils/index.js'; + +const $moduleName = '0x2::vec_map'; +/** An entry in the map */ +export function Entry, V extends BcsType>(...typeParameters: [K, V]) { + return new MoveStruct({ + name: `${$moduleName}::Entry<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, + fields: { + key: typeParameters[0], + value: typeParameters[1], + }, + }); +} +/** + * A map data structure backed by a vector. The map is guaranteed not to contain + * duplicate keys, but entries are _not_ sorted by key--entries are included in + * insertion order. All operations are O(N) in the size of the map--the intention + * of this data structure is only to provide the convenience of programming against + * a map API. Large maps should use handwritten parent/child relationships instead. + * Maps that need sorted iteration rather than insertion order iteration should + * also be handwritten. + */ +export function VecMap, V extends BcsType>(...typeParameters: [K, V]) { + return new MoveStruct({ + name: `${$moduleName}::VecMap<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, + fields: { + contents: bcs.vector(Entry(typeParameters[0], typeParameters[1])), + }, + }); +} diff --git a/sdk/pas/src/contracts/pas/deps/sui/vec_set.ts b/sdk/pas/src/contracts/pas/deps/sui/vec_set.ts new file mode 100644 index 0000000..3ccd71d --- /dev/null +++ b/sdk/pas/src/contracts/pas/deps/sui/vec_set.ts @@ -0,0 +1,24 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { bcs, type BcsType } from '@mysten/sui/bcs'; + +import { MoveStruct } from '../../../utils/index.js'; + +const $moduleName = '0x2::vec_set'; +/** + * A set data structure backed by a vector. The set is guaranteed not to contain + * duplicate keys. All operations are O(N) in the size of the set + * + * - the intention of this data structure is only to provide the convenience of + * programming against a set API. Sets that need sorted iteration rather than + * insertion order iteration should be handwritten. + */ +export function VecSet>(...typeParameters: [K]) { + return new MoveStruct({ + name: `${$moduleName}::VecSet<${typeParameters[0].name as K['name']}>`, + fields: { + contents: bcs.vector(typeParameters[0]), + }, + }); +} diff --git a/sdk/pas/src/contracts/pas/keys.ts b/sdk/pas/src/contracts/pas/keys.ts index 37d2b2a..16b8441 100644 --- a/sdk/pas/src/contracts/pas/keys.ts +++ b/sdk/pas/src/contracts/pas/keys.ts @@ -2,9 +2,85 @@ * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * **************************************************************/ import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; -import { MoveTuple } from '../utils/index.js'; +import { MoveTuple, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; const $moduleName = '@mysten/pas::keys'; export const RuleKey = new MoveTuple({ name: `${$moduleName}::RuleKey`, fields: [bcs.bool()] }); export const VaultKey = new MoveTuple({ name: `${$moduleName}::VaultKey`, fields: [bcs.Address] }); +export const TemplateKey = new MoveTuple({ + name: `${$moduleName}::TemplateKey`, + fields: [bcs.bool()], +}); +export interface TransferFundsActionOptions { + package?: string; + arguments?: []; +} +export function transferFundsAction(options: TransferFundsActionOptions = {}) { + const packageAddress = options.package ?? '@mysten/pas'; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'keys', + function: 'transfer_funds_action', + }); +} +export interface UnlockFundsActionOptions { + package?: string; + arguments?: []; +} +export function unlockFundsAction(options: UnlockFundsActionOptions = {}) { + const packageAddress = options.package ?? '@mysten/pas'; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'keys', + function: 'unlock_funds_action', + }); +} +export interface ClawbackFundsActionOptions { + package?: string; + arguments?: []; +} +export function clawbackFundsAction(options: ClawbackFundsActionOptions = {}) { + const packageAddress = options.package ?? '@mysten/pas'; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'keys', + function: 'clawback_funds_action', + }); +} +export interface ActionsOptions { + package?: string; + arguments?: []; +} +export function actions(options: ActionsOptions = {}) { + const packageAddress = options.package ?? '@mysten/pas'; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'keys', + function: 'actions', + }); +} +export interface IsValidActionArguments { + action: RawTransactionArgument; +} +export interface IsValidActionOptions { + package?: string; + arguments: IsValidActionArguments | [action: RawTransactionArgument]; +} +export function isValidAction(options: IsValidActionOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = ['0x1::string::String'] satisfies (string | null)[]; + const parameterNames = ['action']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'keys', + function: 'is_valid_action', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/sdk/pas/src/contracts/pas/namespace.ts b/sdk/pas/src/contracts/pas/namespace.ts index a8f3b72..d8b7381 100644 --- a/sdk/pas/src/contracts/pas/namespace.ts +++ b/sdk/pas/src/contracts/pas/namespace.ts @@ -15,14 +15,110 @@ import { bcs } from '@mysten/sui/bcs'; import { type Transaction } from '@mysten/sui/transactions'; import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import * as versioning from './versioning.js'; const $moduleName = '@mysten/pas::namespace'; export const Namespace = new MoveStruct({ name: `${$moduleName}::Namespace`, fields: { id: bcs.Address, + /** + * The UpgradeCap of the package, used as the "ownership" capability, mainly to + * block versions of the package in case of emergency. + */ + upgrade_cap_id: bcs.option(bcs.Address), + /** Enables "blocking" versions of the package */ + versioning: versioning.Versioning, }, }); +export interface SetupArguments { + namespace: RawTransactionArgument; + cap: RawTransactionArgument; +} +export interface SetupOptions { + package?: string; + arguments: + | SetupArguments + | [namespace: RawTransactionArgument, cap: RawTransactionArgument]; +} +/** + * Setup the namespace (links the `UpgradeCap`) once after publishing. This makes + * the UpgradeCap the "admin" capability (which can set the blocked versions of a + * package). + */ +export function setup(options: SetupOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['namespace', 'cap']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'namespace', + function: 'setup', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface BlockVersionArguments { + namespace: RawTransactionArgument; + cap: RawTransactionArgument; + version: RawTransactionArgument; +} +export interface BlockVersionOptions { + package?: string; + arguments: + | BlockVersionArguments + | [ + namespace: RawTransactionArgument, + cap: RawTransactionArgument, + version: RawTransactionArgument, + ]; +} +/** + * Allows the package admin to block a version of the package. + * + * This is only used in case of emergency (e.g. security consideration), or if + * there is a breaking change + */ +export function blockVersion(options: BlockVersionOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null, 'u64'] satisfies (string | null)[]; + const parameterNames = ['namespace', 'cap', 'version']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'namespace', + function: 'block_version', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface UnblockVersionArguments { + namespace: RawTransactionArgument; + cap: RawTransactionArgument; + version: RawTransactionArgument; +} +export interface UnblockVersionOptions { + package?: string; + arguments: + | UnblockVersionArguments + | [ + namespace: RawTransactionArgument, + cap: RawTransactionArgument, + version: RawTransactionArgument, + ]; +} +/** Allows the package admin to unblock a version of the package. */ +export function unblockVersion(options: UnblockVersionOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null, 'u64'] satisfies (string | null)[]; + const parameterNames = ['namespace', 'cap', 'version']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'namespace', + function: 'unblock_version', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} export interface RuleExistsArguments { namespace: RawTransactionArgument; } diff --git a/sdk/pas/src/contracts/pas/request.ts b/sdk/pas/src/contracts/pas/request.ts new file mode 100644 index 0000000..4daf9e7 --- /dev/null +++ b/sdk/pas/src/contracts/pas/request.ts @@ -0,0 +1,92 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ +import { type BcsType } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import * as type_name from './deps/std/type_name.js'; +import * as vec_set from './deps/sui/vec_set.js'; + +const $moduleName = '@mysten/pas::request'; +/** + * A base request type. Examples: `Request>` + * `Request>` + */ +export function Request>(...typeParameters: [K]) { + return new MoveStruct({ + name: `${$moduleName}::Request<${typeParameters[0].name as K['name']}>`, + fields: { + /** The collected approvals for this request */ + approvals: vec_set.VecSet(type_name.TypeName), + data: typeParameters[0], + }, + }); +} +export interface ApproveArguments> { + request: RawTransactionArgument; + Approval: RawTransactionArgument; +} +export interface ApproveOptions> { + package?: string; + arguments: + | ApproveArguments + | [request: RawTransactionArgument, Approval: RawTransactionArgument]; + typeArguments: [string, string]; +} +/** Adds an approval to a request. Can be called to resolve rules */ +export function approve>(options: ApproveOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, `${options.typeArguments[1]}`] satisfies (string | null)[]; + const parameterNames = ['request', 'Approval']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'request', + function: 'approve', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface DataArguments { + request: RawTransactionArgument; +} +export interface DataOptions { + package?: string; + arguments: DataArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function data(options: DataOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'request', + function: 'data', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface ApprovalsArguments { + request: RawTransactionArgument; +} +export interface ApprovalsOptions { + package?: string; + arguments: ApprovalsArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function approvals(options: ApprovalsOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'request', + function: 'approvals', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} diff --git a/sdk/pas/src/contracts/pas/rule.ts b/sdk/pas/src/contracts/pas/rule.ts index f6d4f04..e762587 100644 --- a/sdk/pas/src/contracts/pas/rule.ts +++ b/sdk/pas/src/contracts/pas/rule.ts @@ -1,7 +1,7 @@ /************************************************************** * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * **************************************************************/ -import { bcs, type BcsType } from '@mysten/sui/bcs'; +import { bcs } from '@mysten/sui/bcs'; import { type Transaction } from '@mysten/sui/transactions'; import { @@ -11,6 +11,9 @@ import { type RawTransactionArgument, } from '../utils/index.js'; import * as type_name from './deps/std/type_name.js'; +import * as vec_map from './deps/sui/vec_map.js'; +import * as vec_set from './deps/sui/vec_set.js'; +import * as versioning from './versioning.js'; const $moduleName = '@mysten/pas::rule'; export const Rule = new MoveStruct({ @@ -18,46 +21,53 @@ export const Rule = new MoveStruct({ fields: { id: bcs.Address, /** - * The typename used to prove that the "smart contract" agrees with an action for a - * given `T`. Initially, this only means it approves "transfers", "clawbacks" and - * "mints (managed scenario)". In the future, there might be NFT version of these - * rules. + * The required approvals per request type. The key must be one of the request + * types (e.g. `transfer_funds`, `unlock_funds` or `clawback_funds`). + * + * The value is a vector of approvals that need to be gather to resolve the + * request. */ - auth_witness: type_name.TypeName, + required_approvals: vec_map.VecMap(bcs.string(), vec_set.VecSet(type_name.TypeName)), + /** + * Block versions to break backwards compatibility -- only used in case of + * emergency. + */ + versioning: versioning.Versioning, + }, +}); +export const RuleCap = new MoveStruct({ + name: `${$moduleName}::RuleCap`, + fields: { + id: bcs.Address, }, }); -export const ResolutionInfo = new MoveTuple({ - name: `${$moduleName}::ResolutionInfo`, +export const RuleCapKey = new MoveTuple({ + name: `${$moduleName}::RuleCapKey`, fields: [bcs.bool()], }); export const FundsClawbackState = new MoveTuple({ name: `${$moduleName}::FundsClawbackState`, fields: [bcs.bool()], }); -export interface NewArguments> { +export interface NewArguments { namespace: RawTransactionArgument; _: RawTransactionArgument; - Stamp: RawTransactionArgument; } -export interface NewOptions> { +export interface NewOptions { package?: string; arguments: - | NewArguments - | [ - namespace: RawTransactionArgument, - _: RawTransactionArgument, - Stamp: RawTransactionArgument, - ]; - typeArguments: [string, string]; + | NewArguments + | [namespace: RawTransactionArgument, _: RawTransactionArgument]; + typeArguments: [string]; } /** * Create a new `Rule` for `T`. We use `Permit` as the proof of ownership for * `T`. */ -export function _new>(options: NewOptions) { +export function _new(options: NewOptions) { const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null, null, `${options.typeArguments[1]}`] satisfies (string | null)[]; - const parameterNames = ['namespace', '_', 'Stamp']; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['namespace', '_']; return (tx: Transaction) => tx.moveCall({ package: packageAddress, @@ -122,111 +132,89 @@ export function enableFundsManagement(options: EnableFundsManagementOptions) { typeArguments: options.typeArguments, }); } -export interface ResolveUnlockFundsArguments> { +export interface RequiredApprovalsArguments { rule: RawTransactionArgument; - request: RawTransactionArgument; - Stamp: RawTransactionArgument; + actionType: RawTransactionArgument; } -export interface ResolveUnlockFundsOptions> { +export interface RequiredApprovalsOptions { package?: string; arguments: - | ResolveUnlockFundsArguments - | [ - rule: RawTransactionArgument, - request: RawTransactionArgument, - Stamp: RawTransactionArgument, - ]; - typeArguments: [string, string]; + | RequiredApprovalsArguments + | [rule: RawTransactionArgument, actionType: RawTransactionArgument]; + typeArguments: [string]; } -/** - * Resolve an unlock funds request by verifying the authorization witness and - * finalizing the unlock. - */ -export function resolveUnlockFunds>(options: ResolveUnlockFundsOptions) { +/** Get the set of required approvals for a given action. */ +export function requiredApprovals(options: RequiredApprovalsOptions) { const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null, null, `${options.typeArguments[1]}`] satisfies (string | null)[]; - const parameterNames = ['rule', 'request', 'Stamp']; + const argumentsTypes = [null, '0x1::string::String'] satisfies (string | null)[]; + const parameterNames = ['rule', 'actionType']; return (tx: Transaction) => tx.moveCall({ package: packageAddress, module: 'rule', - function: 'resolve_unlock_funds', + function: 'required_approvals', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, }); } -export interface ResolveTransferFundsArguments> { +export interface SetRequiredApprovalArguments { rule: RawTransactionArgument; - request: RawTransactionArgument; - Stamp: RawTransactionArgument; + cap: RawTransactionArgument; + action: RawTransactionArgument; } -export interface ResolveTransferFundsOptions> { +export interface SetRequiredApprovalOptions { package?: string; arguments: - | ResolveTransferFundsArguments + | SetRequiredApprovalArguments | [ rule: RawTransactionArgument, - request: RawTransactionArgument, - Stamp: RawTransactionArgument, + cap: RawTransactionArgument, + action: RawTransactionArgument, ]; typeArguments: [string, string]; } -/** - * Resolve a transfer request by verifying the authorization witness and finalizing - * the transfer. Aborts with `EInvalidProof` if the witness does not match the - * rule's authorization witness. - */ -export function resolveTransferFunds>( - options: ResolveTransferFundsOptions, -) { +export function setRequiredApproval(options: SetRequiredApprovalOptions) { const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null, null, `${options.typeArguments[1]}`] satisfies (string | null)[]; - const parameterNames = ['rule', 'request', 'Stamp']; + const argumentsTypes = [null, null, '0x1::string::String'] satisfies (string | null)[]; + const parameterNames = ['rule', 'cap', 'action']; return (tx: Transaction) => tx.moveCall({ package: packageAddress, module: 'rule', - function: 'resolve_transfer_funds', + function: 'set_required_approval', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, }); } -export interface ClawbackFundsArguments> { +export interface RemoveActionApprovalArguments { rule: RawTransactionArgument; - from: RawTransactionArgument; - amount: RawTransactionArgument; - Stamp: RawTransactionArgument; + _: RawTransactionArgument; + action: RawTransactionArgument; } -export interface ClawbackFundsOptions> { +export interface RemoveActionApprovalOptions { package?: string; arguments: - | ClawbackFundsArguments + | RemoveActionApprovalArguments | [ rule: RawTransactionArgument, - from: RawTransactionArgument, - amount: RawTransactionArgument, - Stamp: RawTransactionArgument, + _: RawTransactionArgument, + action: RawTransactionArgument, ]; - typeArguments: [string, string]; + typeArguments: [string]; } /** - * Clawbacks `amount` of balance from a Vault, returning `Balance` by value. - * - * WARNING: This does not guarantee that the funds will not go out of the - * controlled system. Use with caution. + * Remove the action approval for a given action (this will make all requests not + * resolve). */ -export function clawbackFunds>(options: ClawbackFundsOptions) { +export function removeActionApproval(options: RemoveActionApprovalOptions) { const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null, null, 'u64', `${options.typeArguments[1]}`] satisfies ( - | string - | null - )[]; - const parameterNames = ['rule', 'from', 'amount', 'Stamp']; + const argumentsTypes = [null, null, '0x1::string::String'] satisfies (string | null)[]; + const parameterNames = ['rule', '_', 'action']; return (tx: Transaction) => tx.moveCall({ package: packageAddress, module: 'rule', - function: 'clawback_funds', + function: 'remove_action_approval', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, }); @@ -256,48 +244,43 @@ export function isFundClawbackAllowed(options: IsFundClawbackAllowedOptions) { typeArguments: options.typeArguments, }); } -export interface SetActionCommandArguments> { +export interface SyncVersioningArguments { rule: RawTransactionArgument; - command: RawTransactionArgument; - Stamp: RawTransactionArgument; + namespace: RawTransactionArgument; } -export interface SetActionCommandOptions> { +export interface SyncVersioningOptions { package?: string; arguments: - | SetActionCommandArguments - | [ - rule: RawTransactionArgument, - command: RawTransactionArgument, - Stamp: RawTransactionArgument, - ]; - typeArguments: [string, string, string]; + | SyncVersioningArguments + | [rule: RawTransactionArgument, namespace: RawTransactionArgument]; + typeArguments: [string]; } /** - * Set the move command for a specific action type. NOTE: If the action type - * already exists, it will be replaced. + * Allows syncing the versioning of a rule to the namespace's versioning. This is + * permission-less and can be done */ -export function setActionCommand>(options: SetActionCommandOptions) { +export function syncVersioning(options: SyncVersioningOptions) { const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null, null, `${options.typeArguments[1]}`] satisfies (string | null)[]; - const parameterNames = ['rule', 'command', 'Stamp']; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['rule', 'namespace']; return (tx: Transaction) => tx.moveCall({ package: packageAddress, module: 'rule', - function: 'set_action_command', + function: 'sync_versioning', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, }); } -export interface AuthWitnessArguments { +export interface AssertIsFundManagementEnabledArguments { rule: RawTransactionArgument; } -export interface AuthWitnessOptions { +export interface AssertIsFundManagementEnabledOptions { package?: string; - arguments: AuthWitnessArguments | [rule: RawTransactionArgument]; + arguments: AssertIsFundManagementEnabledArguments | [rule: RawTransactionArgument]; typeArguments: [string]; } -export function authWitness(options: AuthWitnessOptions) { +export function assertIsFundManagementEnabled(options: AssertIsFundManagementEnabledOptions) { const packageAddress = options.package ?? '@mysten/pas'; const argumentsTypes = [null] satisfies (string | null)[]; const parameterNames = ['rule']; @@ -305,7 +288,7 @@ export function authWitness(options: AuthWitnessOptions) { tx.moveCall({ package: packageAddress, module: 'rule', - function: 'auth_witness', + function: 'assert_is_fund_management_enabled', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, }); diff --git a/sdk/pas/src/contracts/pas/templates.ts b/sdk/pas/src/contracts/pas/templates.ts new file mode 100644 index 0000000..776da1f --- /dev/null +++ b/sdk/pas/src/contracts/pas/templates.ts @@ -0,0 +1,95 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Template stores all the Command templates for PAS. + * + * This is the lookup point for PTB resolution on the client-side! There's no + * versioning enforcement here, as this is purely an off-chain used endpoint. + */ + +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; + +const $moduleName = '@mysten/pas::templates'; +export const Templates = new MoveStruct({ + name: `${$moduleName}::Templates`, + fields: { + id: bcs.Address, + }, +}); +export interface SetupArguments { + namespace: RawTransactionArgument; +} +export interface SetupOptions { + package?: string; + arguments: SetupArguments | [namespace: RawTransactionArgument]; +} +export function setup(options: SetupOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['namespace']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'templates', + function: 'setup', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface SetTemplateCommandArguments { + templates: RawTransactionArgument; + _: RawTransactionArgument; + command: RawTransactionArgument; +} +export interface SetTemplateCommandOptions { + package?: string; + arguments: + | SetTemplateCommandArguments + | [ + templates: RawTransactionArgument, + _: RawTransactionArgument, + command: RawTransactionArgument, + ]; + typeArguments: [string]; +} +export function setTemplateCommand(options: SetTemplateCommandOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null, null] satisfies (string | null)[]; + const parameterNames = ['templates', '_', 'command']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'templates', + function: 'set_template_command', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface UnsetTemplateCommandArguments { + templates: RawTransactionArgument; + _: RawTransactionArgument; +} +export interface UnsetTemplateCommandOptions { + package?: string; + arguments: + | UnsetTemplateCommandArguments + | [templates: RawTransactionArgument, _: RawTransactionArgument]; + typeArguments: [string]; +} +export function unsetTemplateCommand(options: UnsetTemplateCommandOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['templates', '_']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'templates', + function: 'unset_template_command', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} diff --git a/sdk/pas/src/contracts/pas/transfer_funds_request.ts b/sdk/pas/src/contracts/pas/transfer_funds.ts similarity index 78% rename from sdk/pas/src/contracts/pas/transfer_funds_request.ts rename to sdk/pas/src/contracts/pas/transfer_funds.ts index 2f8c128..f79d295 100644 --- a/sdk/pas/src/contracts/pas/transfer_funds_request.ts +++ b/sdk/pas/src/contracts/pas/transfer_funds.ts @@ -7,9 +7,9 @@ import { type Transaction } from '@mysten/sui/transactions'; import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; import * as balance from './deps/sui/balance.js'; -const $moduleName = '@mysten/pas::transfer_funds_request'; -export const TransferFundsRequest = new MoveStruct({ - name: `${$moduleName}::TransferFundsRequest`, +const $moduleName = '@mysten/pas::transfer_funds'; +export const TransferFunds = new MoveStruct({ + name: `${$moduleName}::TransferFunds`, fields: { /** `sender` is the wallet OR object address, NOT the vault address */ sender: bcs.Address, @@ -40,7 +40,7 @@ export function sender(options: SenderOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'transfer_funds_request', + module: 'transfer_funds', function: 'sender', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, @@ -61,7 +61,7 @@ export function recipient(options: RecipientOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'transfer_funds_request', + module: 'transfer_funds', function: 'recipient', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, @@ -82,7 +82,7 @@ export function senderVaultId(options: SenderVaultIdOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'transfer_funds_request', + module: 'transfer_funds', function: 'sender_vault_id', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, @@ -103,7 +103,7 @@ export function recipientVaultId(options: RecipientVaultIdOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'transfer_funds_request', + module: 'transfer_funds', function: 'recipient_vault_id', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, @@ -124,9 +124,37 @@ export function amount(options: AmountOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'transfer_funds_request', + module: 'transfer_funds', function: 'amount', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, }); } +export interface ResolveArguments { + request: RawTransactionArgument; + rule: RawTransactionArgument; +} +export interface ResolveOptions { + package?: string; + arguments: + | ResolveArguments + | [request: RawTransactionArgument, rule: RawTransactionArgument]; + typeArguments: [string]; +} +/** + * resolve a transfer request, if funds management is enabled & there are enough + * approvals. + */ +export function resolve(options: ResolveOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['request', 'rule']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'transfer_funds', + function: 'resolve', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} diff --git a/sdk/pas/src/contracts/pas/unlock_funds_request.ts b/sdk/pas/src/contracts/pas/unlock_funds.ts similarity index 77% rename from sdk/pas/src/contracts/pas/unlock_funds_request.ts rename to sdk/pas/src/contracts/pas/unlock_funds.ts index 81beb35..520c3cf 100644 --- a/sdk/pas/src/contracts/pas/unlock_funds_request.ts +++ b/sdk/pas/src/contracts/pas/unlock_funds.ts @@ -7,9 +7,9 @@ import { type Transaction } from '@mysten/sui/transactions'; import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; import * as balance from './deps/sui/balance.js'; -const $moduleName = '@mysten/pas::unlock_funds_request'; -export const UnlockFundsRequest = new MoveStruct({ - name: `${$moduleName}::UnlockFundsRequest`, +const $moduleName = '@mysten/pas::unlock_funds'; +export const UnlockFunds = new MoveStruct({ + name: `${$moduleName}::UnlockFunds`, fields: { /** `from` is the wallet OR object address, NOT the vault address */ owner: bcs.Address, @@ -36,7 +36,7 @@ export function owner(options: OwnerOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'unlock_funds_request', + module: 'unlock_funds', function: 'owner', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, @@ -57,7 +57,7 @@ export function vaultId(options: VaultIdOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'unlock_funds_request', + module: 'unlock_funds', function: 'vault_id', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, @@ -78,7 +78,7 @@ export function amount(options: AmountOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'unlock_funds_request', + module: 'unlock_funds', function: 'amount', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, @@ -109,9 +109,37 @@ export function resolveUnrestricted(options: ResolveUnrestrictedOptions) { return (tx: Transaction) => tx.moveCall({ package: packageAddress, - module: 'unlock_funds_request', + module: 'unlock_funds', function: 'resolve_unrestricted', arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), typeArguments: options.typeArguments, }); } +export interface ResolveArguments { + request: RawTransactionArgument; + rule: RawTransactionArgument; +} +export interface ResolveOptions { + package?: string; + arguments: + | ResolveArguments + | [request: RawTransactionArgument, rule: RawTransactionArgument]; + typeArguments: [string]; +} +/** + * Resolve an unlock funds request as long as funds management is enabled and there + * are enough valid approvals. + */ +export function resolve(options: ResolveOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['request', 'rule']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'unlock_funds', + function: 'resolve', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} diff --git a/sdk/pas/src/contracts/pas/vault.ts b/sdk/pas/src/contracts/pas/vault.ts index b76c018..203c10d 100644 --- a/sdk/pas/src/contracts/pas/vault.ts +++ b/sdk/pas/src/contracts/pas/vault.ts @@ -13,6 +13,7 @@ import { normalizeMoveArguments, type RawTransactionArgument, } from '../utils/index.js'; +import * as versioning from './versioning.js'; const $moduleName = '@mysten/pas::vault'; export const Vault = new MoveStruct({ @@ -27,6 +28,11 @@ export const Vault = new MoveStruct({ * that need to derive the IDs. */ namespace_id: bcs.Address, + /** + * Block versions to break backwards compatibility -- only used in case of + * emergency. + */ + versioning: versioning.Versioning, }, }); export const Auth = new MoveTuple({ name: `${$moduleName}::Auth`, fields: [bcs.Address] }); @@ -165,6 +171,36 @@ export function transferFunds(options: TransferFundsOptions) { typeArguments: options.typeArguments, }); } +export interface ClawbackFundsArguments { + from: RawTransactionArgument; + amount: RawTransactionArgument; +} +export interface ClawbackFundsOptions { + package?: string; + arguments: + | ClawbackFundsArguments + | [from: RawTransactionArgument, amount: RawTransactionArgument]; + typeArguments: [string]; +} +/** + * Initiate a clawback request for an amount of funds. This takes no `Auth`, as + * it's an admin action. + * + * This can only ever finalize if clawback is enabled in the rule. + */ +export function clawbackFunds(options: ClawbackFundsOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, 'u64'] satisfies (string | null)[]; + const parameterNames = ['from', 'amount']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'vault', + function: 'clawback_funds', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} export interface UnsafeTransferFundsArguments { from: RawTransactionArgument; auth: RawTransactionArgument; @@ -280,3 +316,26 @@ export function depositFunds(options: DepositFundsOptions) { typeArguments: options.typeArguments, }); } +export interface SyncVersioningArguments { + vault: RawTransactionArgument; + namespace: RawTransactionArgument; +} +export interface SyncVersioningOptions { + package?: string; + arguments: + | SyncVersioningArguments + | [vault: RawTransactionArgument, namespace: RawTransactionArgument]; +} +/** Permission-less operation to bring versioning up-to-date with the namespace. */ +export function syncVersioning(options: SyncVersioningOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['vault', 'namespace']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'vault', + function: 'sync_versioning', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/sdk/pas/src/contracts/pas/versioning.ts b/sdk/pas/src/contracts/pas/versioning.ts new file mode 100644 index 0000000..2a848e5 --- /dev/null +++ b/sdk/pas/src/contracts/pas/versioning.ts @@ -0,0 +1,71 @@ +/************************************************************** + * THIS FILE IS GENERATED AND SHOULD NOT BE MANUALLY MODIFIED * + **************************************************************/ + +/** + * Versioning module. + * + * This module is responsible for managing the versioning of the package. + * + * It allows for blocking specific versions of the package in case of emergency, or + * to slowly deprecate an earlier feature. + */ + +import { bcs } from '@mysten/sui/bcs'; +import { type Transaction } from '@mysten/sui/transactions'; + +import { MoveStruct, normalizeMoveArguments, type RawTransactionArgument } from '../utils/index.js'; +import * as vec_set from './deps/sui/vec_set.js'; + +const $moduleName = '@mysten/pas::versioning'; +export const Versioning = new MoveStruct({ + name: `${$moduleName}::Versioning`, + fields: { + blocked_versions: vec_set.VecSet(bcs.u64()), + }, +}); +export interface IsValidVersionArguments { + versioning: RawTransactionArgument; + version: RawTransactionArgument; +} +export interface IsValidVersionOptions { + package?: string; + arguments: + | IsValidVersionArguments + | [ + versioning: RawTransactionArgument, + version: RawTransactionArgument, + ]; +} +/** Verify that a version is not part of the blocked version list. */ +export function isValidVersion(options: IsValidVersionOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, 'u64'] satisfies (string | null)[]; + const parameterNames = ['versioning', 'version']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'versioning', + function: 'is_valid_version', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} +export interface AssertIsValidVersionArguments { + versioning: RawTransactionArgument; +} +export interface AssertIsValidVersionOptions { + package?: string; + arguments: AssertIsValidVersionArguments | [versioning: RawTransactionArgument]; +} +export function assertIsValidVersion(options: AssertIsValidVersionOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['versioning']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'versioning', + function: 'assert_is_valid_version', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + }); +} diff --git a/sdk/pas/src/derivation.ts b/sdk/pas/src/derivation.ts index 2745f49..80aeec9 100644 --- a/sdk/pas/src/derivation.ts +++ b/sdk/pas/src/derivation.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { bcs } from '@mysten/sui/bcs'; -import { deriveObjectID, normalizeSuiAddress } from '@mysten/sui/utils'; +import { deriveDynamicFieldID, deriveObjectID, normalizeSuiAddress } from '@mysten/sui/utils'; import type { PASPackageConfig } from './types.js'; @@ -54,3 +54,39 @@ export function deriveRuleAddress(assetType: string, packageConfig: PASPackageCo const typeTag = `${packageId}::keys::RuleKey<${assetType}>`; return deriveObjectID(namespaceId, typeTag, ruleKeyBcs); } + +/** + * Derives the templates object address for a given package configuration. + * + * Templates are derived using the namespace UID and a TemplateKey(). + * The key structure in Move is: `TemplateKey()` + * + * @param packageConfig - PAS package configuration + * @returns The derived templates object ID + */ +export function deriveTemplateRegistryAddress(packageConfig: PASPackageConfig): string { + const { packageId, namespaceId } = packageConfig; + + // The type tag is the TemplateKey type from the PAS package + const typeTag = `${packageId}::keys::TemplateKey`; + + return deriveObjectID(namespaceId, typeTag, new Uint8Array([0])); +} + +/** + * Derives the dynamic field address for a template command on the Templates object. + * + * Templates store Commands as dynamic fields keyed by `TypeName` (the approval type's + * `type_name::with_defining_ids` value). The DF key type is `std::type_name::TypeName` + * which is a struct with a single `name: String` field. + * + * @param templatesId - The Templates object ID + * @param approvalTypeName - The fully qualified approval type name (e.g., "0x123::demo_usd::TransferApproval") + * @returns The derived dynamic field object ID + */ +export function deriveTemplateAddress(templatesId: string, approvalTypeName: string): string { + // TypeName is a struct { name: String }, serialized as BCS string + const key = bcs.string().serialize(approvalTypeName).toBytes(); + + return deriveDynamicFieldID(templatesId, '0x1::type_name::TypeName', key); +} diff --git a/sdk/pas/src/error.ts b/sdk/pas/src/error.ts index 57d52fc..9abdf65 100644 --- a/sdk/pas/src/error.ts +++ b/sdk/pas/src/error.ts @@ -11,13 +11,6 @@ export class PASClientError extends Error { } } -export class VaultNotFoundError extends PASClientError { - constructor(address: string) { - super(`Vault not found for address ${address}.`); - this.name = 'VaultNotFoundError'; - } -} - export class RuleNotFoundError extends PASClientError { constructor(assetType: string, message?: string) { super(message ?? `Rule not found for asset type ${assetType}.`); diff --git a/sdk/pas/src/intents.ts b/sdk/pas/src/intents.ts new file mode 100644 index 0000000..bfc6ff8 --- /dev/null +++ b/sdk/pas/src/intents.ts @@ -0,0 +1,810 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { bcs } from '@mysten/sui/bcs'; +import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import { Inputs, Transaction, TransactionCommands } from '@mysten/sui/transactions'; +import type { + Argument, + CallArg, + Command, + TransactionDataBuilder, + TransactionPlugin, + TransactionResult, +} from '@mysten/sui/transactions'; +import { normalizeStructTag } from '@mysten/sui/utils'; + +import { + deriveRuleAddress, + deriveTemplateAddress, + deriveTemplateRegistryAddress, + deriveVaultAddress, +} from './derivation.js'; +import { PASClientError, RuleNotFoundError } from './error.js'; +import { + buildMoveCallCommandFromTemplate, + getCommandFromTemplate, + getRequiredApprovals, + PASActionType, +} from './resolution.js'; +import type { PASPackageConfig } from './types.js'; + +const PAS_INTENT_NAME = 'PAS'; + +// --------------------------------------------------------------------------- +// Intent data types +// --------------------------------------------------------------------------- + +type TransferFundsIntentData = { + action: 'transferFunds'; + from: string; + to: string; + amount: string; + assetType: string; + cfg: PASPackageConfig; +}; + +type UnlockFundsIntentData = { + action: 'unlockFunds'; + from: string; + amount: string; + assetType: string; + cfg: PASPackageConfig; +}; + +type UnlockUnrestrictedFundsIntentData = { + action: 'unlockUnrestrictedFunds'; + from: string; + amount: string; + assetType: string; + cfg: PASPackageConfig; +}; + +type VaultForAddressIntentData = { + action: 'vaultForAddress'; + owner: string; + cfg: PASPackageConfig; +}; + +type PASIntentData = + | TransferFundsIntentData + | UnlockFundsIntentData + | UnlockUnrestrictedFundsIntentData + | VaultForAddressIntentData; + +/** + * Creates a memoized PAS intent closure. On first call it registers the + * shared resolver and adds the $Intent command; subsequent calls return + * the cached TransactionResult. + */ +function createPASIntent(data: PASIntentData): (tx: Transaction) => TransactionResult { + let result: TransactionResult | null = null; + return (tx: Transaction) => { + if (result) return result; + tx.addIntentResolver(PAS_INTENT_NAME, resolvePASIntents); + result = tx.add( + TransactionCommands.Intent({ + name: PAS_INTENT_NAME, + inputs: {}, + data: data as unknown as Record, + }), + ); + return result; + }; +} + +export function transferFundsIntent( + packageConfig: PASPackageConfig, +): (options: { + from: string; + to: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { + return ({ from, to, amount, assetType }) => + createPASIntent({ + action: 'transferFunds', + from, + to, + amount: String(amount), + assetType, + cfg: packageConfig, + }); +} + +export function unlockFundsIntent( + packageConfig: PASPackageConfig, +): (options: { + from: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { + return ({ from, amount, assetType }) => + createPASIntent({ + action: 'unlockFunds', + from, + amount: String(amount), + assetType, + cfg: packageConfig, + }); +} + +export function unlockUnrestrictedFundsIntent( + packageConfig: PASPackageConfig, +): (options: { + from: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { + return ({ from, amount, assetType }) => + createPASIntent({ + action: 'unlockUnrestrictedFunds', + from, + amount: String(amount), + assetType, + cfg: packageConfig, + }); +} + +export function vaultForAddressIntent( + packageConfig: PASPackageConfig, +): (owner: string) => (tx: Transaction) => TransactionResult { + return (owner: string) => + createPASIntent({ action: 'vaultForAddress', owner, cfg: packageConfig }); +} + +// --------------------------------------------------------------------------- +// Resolver -- holds mutable state shared across all intent builders +// --------------------------------------------------------------------------- +// +// ## How intent resolution works +// +// Each PAS intent occupies a single $Intent slot in the transaction's command +// list. At build time, the resolver replaces each $Intent with a sequence of +// concrete MoveCall commands via `replaceCommand`. +// +// The tricky part is **indexing**. Commands within a PTB reference each +// other's outputs by absolute command index (e.g. `{ Result: 5 }` means +// "the output of command #5"). When we build the replacement commands for +// an intent, we need to know what absolute index each new command will land +// at in the final PTB. That's what `baseIdx` is for: +// +// baseIdx = the position of the $Intent slot being replaced +// +// So if baseIdx is 3 and we push 2 vault-creation commands before the +// new_auth call, new_auth lands at absolute index 5 (= 3 + 2). +// +// The SDK's `replaceCommand` handles index shifting automatically: after +// splicing N commands in place of 1, it adjusts all Result/NestedResult +// references in subsequent commands by (N - 1). So we iterate the live +// command list directly -- no manual offset tracking needed. +// +// Each builder returns a `BuildResult` containing: +// - `commands`: the replacement commands (local array, 0-indexed) +// - `resultOffset`: which command in that array produces the intent's +// output value (so external references to the intent can be remapped) +// + +type SuiObject = SuiClientTypes.Object<{ content: true }>; + +type VaultState = { kind: 'existing' } | { kind: 'created'; resultIndex: number }; + +/** Return value from each per-action builder. */ +interface BuildResult { + commands: Command[]; + /** Offset within `commands` of the command whose Result is the intent's output. */ + resultOffset: number; +} + +class Resolver { + /** Pre-fetched on-chain objects (vaults, rules). null = does not exist. */ + readonly objects: Map; + /** Pre-fetched template dynamic field objects. */ + readonly templates: Map; + /** Pre-parsed template lookup: ruleId:actionType -> approval type names. */ + readonly templateApprovals: Map; + /** Vault existence / creation tracking. */ + readonly vaults: Map; + + readonly #tx: TransactionDataBuilder; + readonly #inputCache = new Map(); + readonly #templateCommandsCache = new Map[]>(); + readonly #config: PASPackageConfig; + + constructor({ + transactionData, + objects, + templates, + templateApprovals, + vaults, + config, + }: { + transactionData: TransactionDataBuilder; + objects: Map; + templates: Map; + templateApprovals: Map; + vaults: Map; + config: PASPackageConfig; + }) { + this.#tx = transactionData; + this.objects = objects; + this.templates = templates; + this.templateApprovals = templateApprovals; + this.vaults = vaults; + this.#config = config; + } + + // -- Input helpers (deduplicated) ---------------------------------------- + + addObjectInput(objectId: string): Argument { + let arg = this.#inputCache.get(objectId); + if (!arg) { + arg = this.#tx.addInput('object', { + $kind: 'UnresolvedObject', + UnresolvedObject: { objectId }, + }); + this.#inputCache.set(objectId, arg); + } + return arg; + } + + addPureInput(key: string, value: ReturnType): Argument { + let arg = this.#inputCache.get(key); + if (!arg) { + arg = this.#tx.addInput('pure', value); + this.#inputCache.set(key, arg); + } + return arg; + } + + addTemplateInput(type: 'object' | 'pure', arg: CallArg): Argument { + if (type === 'object' && arg.$kind === 'UnresolvedObject') { + return this.addObjectInput(arg.UnresolvedObject.objectId); + } + return this.#tx.addInput(type, arg); + } + + // -- Object lookup ------------------------------------------------------- + + getObjectOrThrow(objectId: string, errorFactory: () => Error): SuiObject { + const obj = this.objects.get(objectId); + if (!obj) throw errorFactory(); + return obj; + } + + // -- Vault resolution ---------------------------------------------------- + + /** + * Returns an Argument referencing the vault for `vaultId`. + * + * - Existing on-chain vault: returns an object Input. + * - Already created earlier in this PTB: returns the stored Result ref. + * - Does not exist yet: **pushes** a `vault::create` MoveCall into the + * caller's `commands` array (mutating it) and records the creation so + * subsequent calls for the same vault reuse the same Result. The vault + * will be shared at the end of the PTB via `shareNewVaults()`. + * + * @param commands - The caller's local command array (may be mutated). + * @param baseIdx - Absolute PTB index where `commands[0]` will land. + */ + resolveVaultArg(vaultId: string, owner: string, baseIdx: number): [Argument, Command[]] { + const state = this.vaults.get(vaultId); + const commands: Command[] = []; + + if (state?.kind === 'existing') return [this.addObjectInput(vaultId), commands]; + + if (state?.kind === 'created') + return [{ $kind: 'Result', Result: state.resultIndex }, commands]; + + const absoluteIndex = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'create', + arguments: [ + this.addObjectInput(this.#config.namespaceId), + this.addPureInput(`address:${owner}`, Inputs.Pure(bcs.Address.serialize(owner))), + ], + }), + ); + + this.vaults.set(vaultId, { kind: 'created', resultIndex: absoluteIndex }); + return [{ $kind: 'Result', Result: absoluteIndex }, commands]; + } + + // -- Template resolution (synchronous, all data pre-fetched) ------------- + + resolveTemplateCommands(ruleObjectId: string, actionType: PASActionType) { + const cacheKey = `${ruleObjectId}:${actionType}`; + const cached = this.#templateCommandsCache.get(cacheKey); + if (cached) return cached; + + const approvalTypeNames = this.templateApprovals.get(cacheKey); + if (!approvalTypeNames) { + throw new PASClientError( + `No required approvals found for action "${actionType}". The issuer has not configured this action.`, + ); + } + + const templatesId = deriveTemplateRegistryAddress(this.#config); + const commands = approvalTypeNames.map((tn) => { + const templateId = deriveTemplateAddress(templatesId, tn); + const template = this.templates.get(templateId); + if (!template) { + throw new PASClientError( + `Template not found for approval type "${tn}". The issuer has not set up the template command.`, + ); + } + return getCommandFromTemplate(template); + }); + + this.#templateCommandsCache.set(cacheKey, commands); + return commands; + } + + /** + * Replaces a standard action intent (transfer/unlock) with its built + * commands. The resolve call at `actualIdx + resultOffset` produces the + * intent's output value. + */ + replaceIntent(actualIdx: number, commands: Command[], resultOffset: number) { + this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx + resultOffset }); + } + + /** + * Replaces a vaultForAddress intent when the vault already exists. + * The intent is removed (0 replacement commands) and external references + * are remapped to the existing vault's Input argument. + * + * Note: SDK's replaceCommand signature doesn't accept Input args as + * resultIndex, but the runtime handles it correctly via ArgumentSchema.parse(). + */ + replaceIntentWithExistingVault(actualIdx: number, vaultArg: Argument) { + this.#tx.replaceCommand(actualIdx, [], vaultArg as any); + } + + /** + * Replaces a vaultForAddress intent when the vault needs to be created. + * The intent is replaced with the vault::create command(s), and external + * references are remapped to the first command's Result (the new vault). + */ + replaceIntentWithCreatedVault(actualIdx: number, commands: Command[]) { + this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx }); + } + + // -- Per-action builders -------------------------------------------------- + // + // Each builder constructs a local `commands` array representing the + // sequence of MoveCall commands that replace the intent. Commands + // reference each other using absolute indices (baseIdx + local offset). + // + // The general pattern for a transfer is: + // [vault::create (0..N)] -- only if vaults don't exist yet + // vault::new_auth -- create ownership proof + // vault::transfer_funds -- initiate the request + // [approval commands] -- issuer-defined template commands + // transfer_funds::resolve -- finalize and produce the output + // + // `resultOffset` points at the last command (resolve), whose Result + // becomes the intent's output value. + + buildTransferFunds(data: TransferFundsIntentData, baseIdx: number): BuildResult { + const { from, to, assetType, amount } = data; + const fromVaultId = deriveVaultAddress(from, this.#config); + const toVaultId = deriveVaultAddress(to, this.#config); + + const ruleId = deriveRuleAddress(assetType, this.#config); + const ruleObject = this.getObjectOrThrow(ruleId, () => new RuleNotFoundError(assetType)); + const templateCmds = this.resolveTemplateCommands( + ruleObject.objectId, + PASActionType.TransferFunds, + ); + + const [toVaultArg, commands] = this.resolveVaultArg(toVaultId, to, baseIdx); + const [fromVaultArg, fromVaultCommands] = this.resolveVaultArg( + fromVaultId, + from, + baseIdx + commands.length, + ); + commands.push(...fromVaultCommands); + + const ruleArg = this.addObjectInput(ruleId); + + // vault::new_auth + const authIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'new_auth', + }), + ); + + // vault::transfer_funds + const requestIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'transfer_funds', + arguments: [ + fromVaultArg, + { $kind: 'Result', Result: authIdx }, + toVaultArg, + this.addTemplateInput('pure', Inputs.Pure(bcs.u64().serialize(BigInt(amount)))), + ], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + const requestArg: Argument = { $kind: 'Result', Result: requestIdx }; + + // Issuer-defined approval commands from templates + for (const templateCmd of templateCmds) { + commands.push( + buildMoveCallCommandFromTemplate(templateCmd, { + addInput: (type, arg) => this.addTemplateInput(type, arg), + senderVault: fromVaultArg, + receiverVault: toVaultArg, + rule: ruleArg, + request: requestArg, + systemType: assetType, + }), + ); + } + + // transfer_funds::resolve + const resultOffset = commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'transfer_funds', + function: 'resolve', + arguments: [requestArg, ruleArg], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + + return { commands, resultOffset }; + } + + /** + * Builds commands for both restricted and unrestricted unlock flows. + * Restricted: requires a Rule, runs issuer approval templates, then resolve. + * Unrestricted: no Rule needed, calls resolve_unrestricted directly. + */ + buildUnlockFunds( + data: UnlockFundsIntentData | UnlockUnrestrictedFundsIntentData, + baseIdx: number, + ): BuildResult { + const { from, assetType, amount } = data; + const fromVaultId = deriveVaultAddress(from, this.#config); + const ruleId = deriveRuleAddress(assetType, this.#config); + + const isRestricted = data.action === 'unlockFunds'; + + if (isRestricted) { + this.getObjectOrThrow( + ruleId, + () => + new PASClientError( + `Rule does not exist for asset type ${assetType}. ` + + `That means that the issuer has not yet enabled funds management for this asset. ` + + `If this is a non-managed asset, you can use the unrestricted unlock flow by calling unlockUnrestrictedFunds() instead.`, + ), + ); + } else { + if (this.objects.get(ruleId) !== null) { + throw new PASClientError( + `A rule exists for asset type ${assetType}. That means that the issuer has enabled funds management for this asset and you can no longer use the unrestricted unlock flow.`, + ); + } + } + + const [fromVaultArg, commands] = this.resolveVaultArg(fromVaultId, from, baseIdx); + const ruleArg = isRestricted ? this.addObjectInput(ruleId) : undefined; + + // vault::new_auth + const authIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'new_auth', + }), + ); + + // vault::unlock_funds + const requestIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'unlock_funds', + arguments: [ + fromVaultArg, + { $kind: 'Result', Result: authIdx }, + this.addTemplateInput('pure', Inputs.Pure(bcs.u64().serialize(BigInt(amount)))), + ], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + const requestArg: Argument = { $kind: 'Result', Result: requestIdx }; + + if (isRestricted) { + // Issuer-defined approval commands from templates + const templateCmds = this.resolveTemplateCommands(ruleId, PASActionType.UnlockFunds); + for (const templateCmd of templateCmds) { + commands.push( + buildMoveCallCommandFromTemplate(templateCmd, { + addInput: (type, arg) => this.addTemplateInput(type, arg), + senderVault: fromVaultArg, + rule: ruleArg, + request: requestArg, + systemType: assetType, + }), + ); + } + + // unlock_funds::resolve + const resultOffset = commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'unlock_funds', + function: 'resolve', + arguments: [requestArg, ruleArg!], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + return { commands, resultOffset }; + } + + // unlock_funds::resolve_unrestricted + const resultOffset = commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'unlock_funds', + function: 'resolve_unrestricted', + arguments: [requestArg, this.addObjectInput(this.#config.namespaceId)], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + return { commands, resultOffset }; + } + + // -- Finalization --------------------------------------------------------- + + /** + * Appends `vault::share` commands for every vault that was created during + * resolution. Called once at the end, after all intents have been resolved, + * so that each vault is shared exactly once regardless of how many intents + * referenced it. + */ + shareNewVaults() { + for (const state of this.vaults.values()) { + if (state.kind !== 'created') continue; + this.#tx.commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'share', + arguments: [{ $kind: 'Result', Result: state.resultIndex }], + }), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Data collection + fetching (pre-resolution) +// --------------------------------------------------------------------------- + +type VaultOwner = { owner: string }; + +interface IntentDataCollection { + objectIds: Set; + vaultRequests: Map; + intentDataList: PASIntentData[]; + cfg: PASPackageConfig; +} + +/** Scans commands for PAS intents and collects the object IDs we need to fetch. */ +function collectIntentData(commands: readonly Command[]): IntentDataCollection | null { + const objectIds = new Set(); + const vaultRequests = new Map(); + const intentDataList: PASIntentData[] = []; + let cfg: PASPackageConfig | null = null; + + for (const command of commands) { + if (command.$kind !== '$Intent' || command.$Intent.name !== PAS_INTENT_NAME) continue; + const data = command.$Intent.data as unknown as PASIntentData; + + if (!cfg) cfg = data.cfg; + intentDataList.push(data); + + switch (data.action) { + case 'transferFunds': { + const fromId = deriveVaultAddress(data.from, cfg); + const toId = deriveVaultAddress(data.to, cfg); + objectIds.add(fromId); + objectIds.add(toId); + objectIds.add(deriveRuleAddress(data.assetType, cfg)); + vaultRequests.set(fromId, { owner: data.from }); + vaultRequests.set(toId, { owner: data.to }); + break; + } + case 'unlockFunds': + case 'unlockUnrestrictedFunds': { + const fromId = deriveVaultAddress(data.from, cfg); + objectIds.add(fromId); + objectIds.add(deriveRuleAddress(data.assetType, cfg)); + vaultRequests.set(fromId, { owner: data.from }); + break; + } + case 'vaultForAddress': { + const id = deriveVaultAddress(data.owner, cfg); + objectIds.add(id); + vaultRequests.set(id, { owner: data.owner }); + break; + } + } + } + + if (!cfg) + throw new PASClientError('No package configuration found in intents. This is an internal bug.'); + + return intentDataList.length > 0 ? { objectIds, vaultRequests, intentDataList, cfg } : null; +} + +async function initializeContext( + transactionData: TransactionDataBuilder, + client: ClientWithCoreApi, + objectIds: Set, + vaultRequests: Map, + intentDataList: PASIntentData[], + config: PASPackageConfig, +): Promise { + // 1. Batch-fetch all vaults + rules + const allIds = [...objectIds]; + const { objects: fetched } = await client.core.getObjects({ + objectIds: allIds, + include: { content: true }, + }); + + const objects = new Map(); + + for (const id of allIds) { + const obj = fetched.filter((o) => 'content' in o).find((o) => o.objectId === id); + objects.set(id, obj ?? null); + } + + // 2. Build initial vault map (existing vs needs-creation) + const vaults = new Map(); + for (const [vaultId] of vaultRequests) { + if (objects.get(vaultId) !== null) { + vaults.set(vaultId, { kind: 'existing' }); + } + } + + // 3. Collect template DF IDs by parsing rules + const templateApprovals = new Map(); + const templateIds: string[] = []; + const seen = new Set(); + + for (const data of intentDataList) { + let actionType: PASActionType | null = null; + let assetType: string | null = null; + + if (data.action === 'transferFunds') { + actionType = PASActionType.TransferFunds; + assetType = data.assetType; + } else if (data.action === 'unlockFunds') { + actionType = PASActionType.UnlockFunds; + assetType = data.assetType; + } + + if (!actionType || !assetType) continue; + + const ruleId = deriveRuleAddress(assetType, config); + const key = `${ruleId}:${actionType}`; + if (seen.has(key)) continue; + seen.add(key); + + const ruleObject = objects.get(ruleId); + if (!ruleObject) continue; + + const approvalTypeNames = getRequiredApprovals(ruleObject, actionType); + if (!approvalTypeNames?.length) continue; + + const templatesId = deriveTemplateRegistryAddress(config); + templateApprovals.set(key, approvalTypeNames); + templateIds.push(...approvalTypeNames.map((tn) => deriveTemplateAddress(templatesId, tn))); + } + + // 4. Batch-fetch all template data + const templates = new Map(); + if (templateIds.length > 0) { + const { objects: templateObjects } = await client.core.getObjects({ + objectIds: templateIds, + include: { content: true }, + }); + + for (const obj of templateObjects.filter((o) => 'content' in o)) { + templates.set(obj.objectId, obj); + } + } + + return new Resolver({ + transactionData, + objects, + templates, + templateApprovals, + vaults, + config, + }); +} + +const resolvePASIntents: TransactionPlugin = async (transactionData, buildOptions, next) => { + const client = buildOptions.client; + if (!client) + throw new PASClientError( + 'A SuiClient must be provided to build transactions with PAS intents.', + ); + + const requirements = collectIntentData(transactionData.commands); + if (!requirements) return next(); + + const { objectIds, vaultRequests, intentDataList, cfg } = requirements; + + const ctx = await initializeContext( + transactionData, + client, + objectIds, + vaultRequests, + intentDataList, + cfg, + ); + + // Iterate the live command list. replaceCommand mutates the array in place + // and shifts all subsequent indices automatically, so we don't need to + // track index offsets ourselves -- the iterator sees correct positions. + for (const [index, command] of transactionData.commands.entries()) { + if (command.$kind !== '$Intent' || command.$Intent.name !== PAS_INTENT_NAME) continue; + + const data = command.$Intent.data as unknown as PASIntentData; + + // -- vaultForAddress is handled separately (may produce 0 commands) -- + if (data.action === 'vaultForAddress') { + const vaultId = deriveVaultAddress(data.owner, cfg); + const [vaultArg, commands] = ctx.resolveVaultArg(vaultId, data.owner, index); + + if (commands.length === 0) { + ctx.replaceIntentWithExistingVault(index, vaultArg); + } else { + ctx.replaceIntentWithCreatedVault(index, commands); + } + continue; + } + + // -- Standard action intents -- + let result: BuildResult; + switch (data.action) { + case 'transferFunds': + result = ctx.buildTransferFunds(data, index); + break; + case 'unlockFunds': + case 'unlockUnrestrictedFunds': + result = ctx.buildUnlockFunds(data, index); + break; + default: + continue; + } + + ctx.replaceIntent(index, result.commands, result.resultOffset); + } + + ctx.shareNewVaults(); + return next(); +}; diff --git a/sdk/pas/src/resolution.ts b/sdk/pas/src/resolution.ts index 33ffdb4..eb9cb1d 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -1,16 +1,16 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { bcs } from '@mysten/sui/bcs'; import { SuiClientTypes } from '@mysten/sui/client'; -import { type Transaction, type TransactionObjectArgument } from '@mysten/sui/transactions'; +import { Inputs, TransactionCommands } from '@mysten/sui/transactions'; +import type { Argument, CallArg, Command as SdkCommand } from '@mysten/sui/transactions'; import { normalizeStructTag } from '@mysten/sui/utils'; -import { Field, VecMap } from './bcs.js'; -import { ResolutionInfo } from './contracts/pas/rule.js'; +import { Field } from './bcs.js'; +import { TypeName } from './contracts/pas/deps/std/type_name.js'; +import { Rule } from './contracts/pas/rule.js'; import { Command, MoveCall } from './contracts/ptb/ptb.js'; import { PASClientError } from './error.js'; -import type { PASPackageConfig } from './types.js'; const OBJECT_BY_ID_EXT = 'object_by_id'; const OBJECT_BY_TYPE_EXT = 'object_by_type'; @@ -21,69 +21,56 @@ const RECEIVING_BY_ID_EXT = 'receiving_by_id'; */ export enum PASActionType { /** Transfer funds between vaults */ - TransferFunds = 'TransferFunds', + TransferFunds = 'transfer_funds', /** Unlock funds from a vault */ - UnlockFunds = 'UnlockFunds', + UnlockFunds = 'unlock_funds', + /** Clawback funds from a vault */ + ClawbackFunds = 'clawback_funds', } /** - * Builds the full typename for a PAS action. + * Parses the Rule object to extract the required approval type names for a given action. * - * @param actionType - The action type (TransferFunds or UnlockFunds) - * @param assetType - The asset type (e.g., "0x2::sui::SUI") - * @param packageConfig - PAS package configuration - * @returns The full typename (e.g., "0x123::transfer_funds_request::TransferFundsRequest<0x2::sui::SUI>") + * The Rule's `required_approvals` is a `VecMap>` where: + * - Key is the action name (e.g., "transfer_funds") + * - Value is a set of approval TypeNames that must be satisfied + * + * @param ruleObject - The Rule object fetched with content + * @returns The list of approval TypeName strings for the given action, or undefined if not found */ -export function buildActionTypeName( +export function getRequiredApprovals( + ruleObject: SuiClientTypes.Object<{ content: true }>, actionType: PASActionType, - assetType: string, - packageConfig: PASPackageConfig, -): string { - const { packageId } = packageConfig; - - switch (actionType) { - case PASActionType.TransferFunds: - return `${packageId}::transfer_funds_request::TransferFundsRequest<${assetType}>`; - case PASActionType.UnlockFunds: - return `${packageId}::unlock_funds_request::UnlockFundsRequest<${assetType}>`; - default: - throw new PASClientError(`Unknown action type: ${actionType}`); - } +): string[] | undefined { + const rule = Rule.parse(ruleObject.content); + + const entry = rule.required_approvals.contents.find((e) => e.key === actionType); + + if (!entry) return undefined; + + return entry.value.contents.map((tn) => tn.name); } /** - * Resolves a Command from a Rule's resolution_info map. + * Parses a Command from a Template dynamic field object. * - * The resolution_info is a VecMap where: - * - TypeName is the action type (e.g., "0x123::transfer_funds_request::TransferFundsRequest<0x2::sui::SUI>") - * - Command is the move call instruction to execute for that action + * Each Template DF is a `Field` where: + * - TypeName is the approval type (e.g., the `with_defining_ids` of `TransferApproval`) + * - Command is the move call instruction to execute for that approval * - * @param rule - The parsed Rule object - * @param actionType - The full typename of the action (e.g., "0x123::transfer_funds_request::TransferFundsRequest<0x2::sui::SUI>") - * @returns The Command object for this action type, or undefined if not found + * @param templateDF - The Template DF object fetched with content + * @returns The parsed Command, or undefined if parsing fails */ -export function getCommandForAction( - object: SuiClientTypes.Object<{ content: true }>, - actionType: string, -): ReturnType | undefined { - // Parse the Dynamic Field. - const df = Field(ResolutionInfo, VecMap(bcs.String, Command)).parse(object.content); - // The resolution_info is a VecMap - // VecMap has a 'contents' field which is an array of { key: TypeName, value: Command } - const resolutionMap = df.value; - - // The resolution map stored in the DF is `VecMap` - const command = resolutionMap.contents.find( - (entry) => - normalizeStructTag(entry.key).toString() === normalizeStructTag(actionType).toString(), - ); - - return command?.value ? parseCommand(command.value) : undefined; +export function getCommandFromTemplate( + template: SuiClientTypes.Object<{ content: true }>, +): ReturnType { + const df = Field(TypeName, Command).parse(template.content); + return parseCommand(df.value); } // TODO: Discuss why this is interpreted as `(number | number[])[])` instead of `[number, number[]]` // and if there's a way to solve that. -export function parseCommand([key, cmd]: ReturnType) { +function parseCommand([key, cmd]: ReturnType) { // Support only `Command` for now. if (key !== 0) throw new Error(`Unknown command type: ${key}`); @@ -91,48 +78,50 @@ export function parseCommand([key, cmd]: ReturnType) { return MoveCall.parse(new Uint8Array(cmd as number[])); } +// --------------------------------------------------------------------------- +// Command builder (for use with TransactionDataBuilder / replaceCommand) +// --------------------------------------------------------------------------- + /** - * Context provided when building a PTB from a command + * Arguments for building a MoveCall Command from a template. + * Used by the intent resolver which works directly with TransactionDataBuilder. */ -export interface CommandBuildContext { - /** The transaction builder */ - tx: Transaction; - /** The sender vault (for transfers/unlocks) */ - senderVault?: TransactionObjectArgument; - /** The receiver vault (for transfers) */ - receiverVault?: TransactionObjectArgument; - /** The rule object */ - rule?: TransactionObjectArgument; - /** The transfer/unlock request */ - request?: TransactionObjectArgument; +interface RawCommandBuildArgs { + /** Adds an input to the parent transaction and returns the Argument ref. */ + addInput: (type: 'object' | 'pure', arg: CallArg) => Argument; + /** The sender vault argument (already resolved) */ + senderVault?: Argument; + /** The receiver vault argument (already resolved) */ + receiverVault?: Argument; + /** The rule argument (already resolved) */ + rule?: Argument; + /** The request argument (already resolved) */ + request?: Argument; /** The system type T (e.g., "0x2::sui::SUI") */ systemType?: string; - /** Additional custom arguments */ - customArgs?: Map; } /** - * Adds the `tx.moveCall()` as it is resolved from `Command`. + * Builds a `Command` (TransactionCommands.MoveCall) from a parsed template command, + * suitable for use with `transactionData.replaceCommand()`. * - * This function translates the Command structure into actual moveCall operations - * in the transaction, resolving placeholders like "sender_vault", "receiver_vault", etc. + * Resolves template argument placeholders (pas:request, pas:rule, etc.) into + * concrete Argument references, and converts object/pure inputs via the provided + * `addInput` callback. * - * @param command - The parsed Command object - * @param context - The build context with required objects - * @returns The result of the moveCall + * @param command - The parsed MoveCall from a template object + * @param args - The resolved arguments and addInput helper + * @returns A Command object ready for `replaceCommand` */ -export function addMoveCallFromCommand( +export function buildMoveCallCommandFromTemplate( command: ReturnType, - context: CommandBuildContext, -) { - const { tx } = context; - - // Resolve arguments - const resolvedArgs: TransactionObjectArgument[] = []; + args: RawCommandBuildArgs, +): SdkCommand { + const resolvedArgs: Argument[] = []; for (const arg of command.arguments) { if (arg.Ext) throw new PASClientError(`There are no supported ext arguments in this client.`); - else if (arg.GasCoin) resolvedArgs.push(tx.gas); + else if (arg.GasCoin) resolvedArgs.push({ $kind: 'GasCoin', GasCoin: true }); else if (arg.NestedResult) resolvedArgs.push({ $kind: 'NestedResult', @@ -140,34 +129,44 @@ export function addMoveCallFromCommand( }); else if (arg.Result) resolvedArgs.push({ $kind: 'Result', Result: arg.Result }); else if (arg.Input) { - if (arg.Input.Pure) resolvedArgs.push(tx.pure(new Uint8Array(arg.Input.Pure))); + if (arg.Input.Pure) + resolvedArgs.push(args.addInput('pure', Inputs.Pure(new Uint8Array(arg.Input.Pure)))); else if (arg.Input.Object) { switch (arg.Input.Object.$kind) { case 'ImmOrOwnedObject': resolvedArgs.push( - tx.objectRef({ - objectId: arg.Input.Object.ImmOrOwnedObject.object_id, - version: arg.Input.Object.ImmOrOwnedObject.sequence_number, - digest: arg.Input.Object.ImmOrOwnedObject.digest, - }), + args.addInput( + 'object', + Inputs.ObjectRef({ + objectId: arg.Input.Object.ImmOrOwnedObject.object_id, + version: arg.Input.Object.ImmOrOwnedObject.sequence_number, + digest: arg.Input.Object.ImmOrOwnedObject.digest, + }), + ), ); break; case 'SharedObject': resolvedArgs.push( - tx.sharedObjectRef({ - objectId: arg.Input.Object.SharedObject.object_id, - initialSharedVersion: arg.Input.Object.SharedObject.initial_shared_version, - mutable: arg.Input.Object.SharedObject.is_mutable, - }), + args.addInput( + 'object', + Inputs.SharedObjectRef({ + objectId: arg.Input.Object.SharedObject.object_id, + initialSharedVersion: arg.Input.Object.SharedObject.initial_shared_version, + mutable: arg.Input.Object.SharedObject.is_mutable, + }), + ), ); break; case 'Receiving': resolvedArgs.push( - tx.receivingRef({ - objectId: arg.Input.Object.Receiving.object_id, - version: arg.Input.Object.Receiving.sequence_number, - digest: arg.Input.Object.Receiving.digest, - }), + args.addInput( + 'object', + Inputs.ReceivingRef({ + objectId: arg.Input.Object.Receiving.object_id, + version: arg.Input.Object.Receiving.sequence_number, + digest: arg.Input.Object.Receiving.digest, + }), + ), ); break; case 'Ext': @@ -176,7 +175,12 @@ export function addMoveCallFromCommand( switch (kind) { case OBJECT_BY_ID_EXT: case RECEIVING_BY_ID_EXT: - resolvedArgs.push(tx.object(value)); + resolvedArgs.push( + args.addInput('object', { + $kind: 'UnresolvedObject', + UnresolvedObject: { objectId: value }, + } as CallArg), + ); break; case OBJECT_BY_TYPE_EXT: throw new PASClientError( @@ -192,48 +196,46 @@ export function addMoveCallFromCommand( ); } } else if (arg.Input.Ext) { - resolvedArgs.push(resolvePasRequest(context, arg.Input.Ext)); + resolvedArgs.push(resolveRawPasRequest(args, arg.Input.Ext)); } else { throw new PASClientError(`Unsupported input kind: ${arg.Input.$kind}`); } } } - // Resolve type arguments const typeArgs: string[] = []; for (const typeArg of command.type_arguments) typeArgs.push(normalizeStructTag(typeArg).toString()); - // Build the moveCall if (!command.module_name || !command.function) throw new PASClientError( 'Module name or function name is missing from the on-chain rule. This means that the issuer has not set up the rule correctly.', ); - return tx.moveCall({ - target: `${command.package_id}::${command.module_name}::${command.function}`, + return TransactionCommands.MoveCall({ + package: command.package_id, + module: command.module_name, + function: command.function, arguments: resolvedArgs, typeArguments: typeArgs.length > 0 ? typeArgs : [], }); } -/// Handle the special resolvers for PAS. -/// This includes the `rul`, the `request`, the `sender_vault` as well as the `receiver_vault`. -function resolvePasRequest(context: CommandBuildContext, value: string) { +function resolveRawPasRequest(args: RawCommandBuildArgs, value: string): Argument { switch (value) { case 'pas:request': - if (!context.request) throw new PASClientError(`Request is not set in the context.`); - return context.request; + if (!args.request) throw new PASClientError(`Request is not set in the context.`); + return args.request; case 'pas:rule': - if (!context.rule) throw new PASClientError(`Rule is not set in the context.`); - return context.rule; + if (!args.rule) throw new PASClientError(`Rule is not set in the context.`); + return args.rule; case 'pas:sender_vault': - if (!context.senderVault) throw new PASClientError(`Sender vault is not set in the context.`); - return context.senderVault; + if (!args.senderVault) throw new PASClientError(`Sender vault is not set in the context.`); + return args.senderVault; case 'pas:receiver_vault': - if (!context.receiverVault) + if (!args.receiverVault) throw new PASClientError(`Receiver vault is not set in the context.`); - return context.receiverVault; + return args.receiverVault; default: throw new PASClientError(`Unknown pas request: ${value}`); } diff --git a/sdk/pas/test/e2e/data/demo_usd/Move.lock b/sdk/pas/test/e2e/data/demo_usd/Move.lock new file mode 100644 index 0000000..381b097 --- /dev/null +++ b/sdk/pas/test/e2e/data/demo_usd/Move.lock @@ -0,0 +1,35 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.demo_usd] +source = { root = true } +use_environment = "testnet" +manifest_digest = "F3F3BE825FCACCADB2ECE4ADCDD2DA4CD2C8D0DDAC32D4F23CBCE3F2760282C5" +deps = { pas = "pas", ptb = "ptb", std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.pas] +source = { local = "../pas" } +use_environment = "testnet" +manifest_digest = "38AA62656ABE7551C444DA427ADBAA7751CB67250663D39FCDE36E938138EA7D" +deps = { ptb = "ptb", std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.ptb] +source = { local = "../ptb" } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move b/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move index dd42d00..20f4266 100644 --- a/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move +++ b/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move @@ -9,8 +9,10 @@ module demo_usd::demo_usd; use pas::namespace::Namespace; -use pas::rule::{Self, Rule}; -use pas::transfer_funds_request::TransferFundsRequest; +use pas::request::Request; +use pas::rule::{Self, Rule, RuleCap}; +use pas::templates::Templates; +use pas::transfer_funds::TransferFunds; use ptb::ptb; use std::type_name; use sui::balance::Balance; @@ -23,6 +25,9 @@ const EInvalidAmount: vector = b"Any amount over 10K is not allowed in this #[error(code = 1)] const ECannotSelfTransfer: vector = b"Transfers cannot be made to the same address as the sender."; +#[error(code = 2)] +const ENotAllowedRecipient: vector = + b"Transfers to the address 0x2 are not allowed in this demo."; /// One-time witness for the demo_usd package public struct DEMO_USD has drop {} @@ -32,11 +37,17 @@ public struct Faucet has key { id: UID, cap: TreasuryCap, metadata: MetadataCap, + rule_cap: Option>, } /// Stamp used in PAS for authorizing any admin action. public struct ActionStamp() has drop; +public struct TransferApproval() has drop; +public struct TransferApprovalV2() has drop; + +public struct UnlockApproval() has drop; + public fun faucet_mint_balance(faucet: &mut Faucet, amount: u64): Balance { faucet.cap.mint_balance(amount) } @@ -59,39 +70,61 @@ fun init(otw: DEMO_USD, ctx: &mut TxContext) { id: object::new(ctx), cap, metadata, + rule_cap: option::none(), }); } -entry fun setup(namespace: &mut Namespace, faucet: &mut Faucet) { - let mut rule = rule::new(namespace, internal::permit(), ActionStamp()); +entry fun setup(namespace: &mut Namespace, templates: &mut Templates, faucet: &mut Faucet) { + let (mut rule, cap) = rule::new(namespace, internal::permit()); + // Enable funds management (with clawbacks!) rule.enable_funds_management(&mut faucet.cap, true); + rule.set_required_approval<_, TransferApproval>(&cap, "transfer_funds"); + + faucet.rule_cap.fill(cap); + let type_name = type_name::with_defining_ids(); let cmd = ptb::move_call( type_name.address_string().to_string(), "demo_usd", - "resolve_transfer", - vector[ - ptb::ext_input("pas:request"), - ptb::ext_input("pas:rule"), - ptb::object_by_id(@0x6.to_id()), - ], + "approve_transfer", + vector[ptb::ext_input("pas:request"), ptb::object_by_id(@0x6.to_id())], vector[(*type_name.as_string()).to_string()], ); - rule.set_action_command<_, _, TransferFundsRequest>(cmd, ActionStamp()); + templates.set_template_command(internal::permit(), cmd); rule.share(); } +/// starts using v2 approve transfer to test upgradeability. +public fun use_v2(rule: &mut Rule, templates: &mut Templates, faucet: &mut Faucet) { + let cmd = ptb::move_call( + type_name::with_defining_ids().address_string().to_string(), + "demo_usd", + "approve_transfer_v2", + vector[ptb::ext_input("pas:request"), ptb::object_by_id(object::id(faucet))], + vector[], + ); + + templates.set_template_command(internal::permit(), cmd); + + rule.set_required_approval<_, TransferApprovalV2>(faucet.rule_cap.borrow(), "transfer_funds"); +} + /// Resolver function for transfer requests - simply approves all transfers -public fun resolve_transfer(request: TransferFundsRequest, rule: &Rule, _clock: &Clock) { +public fun approve_transfer(request: &mut Request>, _clock: &Clock) { // We only allow transfers with value less than 10K. // NOTE: This is only for testing, this is not really enforceable like this as you could batch multiple in a PTB. - assert!(request.amount() < 10_000 * 1_000_000, EInvalidAmount); - assert!(request.sender() != request.recipient(), ECannotSelfTransfer); + assert!(request.data().amount() < 10_000 * 1_000_000, EInvalidAmount); + assert!(request.data().sender() != request.data().recipient(), ECannotSelfTransfer); + + request.approve(TransferApproval()); +} - // Resolve the transfer! - rule.resolve_transfer_funds(request, ActionStamp()) +/// V2 function allows all transfers, besides transferring to 0x2. +public fun approve_transfer_v2(request: &mut Request>, _faucet: &Faucet) { + assert!(request.data().recipient() != @0x2, ENotAllowedRecipient); + request.approve(TransferApprovalV2()); } diff --git a/sdk/pas/test/e2e/demoUsd.ts b/sdk/pas/test/e2e/demoUsd.ts index b21148e..3416ba8 100644 --- a/sdk/pas/test/e2e/demoUsd.ts +++ b/sdk/pas/test/e2e/demoUsd.ts @@ -1,13 +1,15 @@ import { Transaction } from '@mysten/sui/transactions'; -import { type PublishedPackage, type TestToolbox } from './setup.ts'; +import { execSuiTools, type PublishedPackage, type TestToolbox } from './setup.ts'; export class DemoUsdTestHelpers { toolbox: TestToolbox; #publicationData: PublishedPackage; + #cacheKey: string; - constructor(toolbox: TestToolbox) { + constructor(toolbox: TestToolbox, cacheKey?: string) { this.toolbox = toolbox; + this.#cacheKey = cacheKey ?? 'demo_usd'; } get pub() { @@ -23,16 +25,26 @@ export class DemoUsdTestHelpers { return this.#publicationData; } - const result = await this.toolbox.publishPackage('demo_usd'); + // When using a custom cache key, copy the source to a unique container + // directory so test-publish treats it as a separate package instance. + let packagePath = 'demo_usd'; + if (this.#cacheKey !== 'demo_usd') { + await execSuiTools(['cp', '-r', '/test-data/demo_usd', `/test-data/${this.#cacheKey}`]); + packagePath = this.#cacheKey; + } + + const result = await this.toolbox.publishPackage(packagePath, this.#cacheKey); this.#publicationData = result; const faucetId = result.createdObjects.find((o) => o.type.endsWith('demo_usd::Faucet'))!.id; + const templateRegistryId = this.toolbox.client.pas.deriveTemplateRegistryAddress(); const transaction = new Transaction(); transaction.moveCall({ target: `${result.originalId}::demo_usd::setup`, arguments: [ transaction.object(this.toolbox.client.pas.getPackageConfig().namespaceId), + transaction.object(templateRegistryId), transaction.object(faucetId), ], }); @@ -63,6 +75,19 @@ export class DemoUsdTestHelpers { await this.toolbox.executeTransaction(transaction); } + async upgradeToV2() { + const ruleId = this.toolbox.client.pas.deriveRuleAddress(this.demoUsdAssetType); + const templateRegistryId = this.toolbox.client.pas.deriveTemplateRegistryAddress(); + const faucetId = this.pub.createdObjects.find((o) => o.type.endsWith('demo_usd::Faucet'))!.id; + + const tx = new Transaction(); + tx.moveCall({ + target: `${this.pub.originalId}::demo_usd::use_v2`, + arguments: [tx.object(ruleId), tx.object(templateRegistryId), tx.object(faucetId)], + }); + await this.toolbox.executeTransaction(tx); + } + get demoUsdAssetType() { return `${this.pub.originalId}::demo_usd::DEMO_USD`; } diff --git a/sdk/pas/test/e2e/e2e.isolated.test.ts b/sdk/pas/test/e2e/e2e.isolated.test.ts index e7249b7..811f5f2 100644 --- a/sdk/pas/test/e2e/e2e.isolated.test.ts +++ b/sdk/pas/test/e2e/e2e.isolated.test.ts @@ -1,199 +1,494 @@ import { Transaction } from '@mysten/sui/transactions'; import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { DemoUsdTestHelpers } from './demoUsd.ts'; -import { setupToolbox, TestToolbox } from './setup.ts'; +import { setupToolbox, simulateFailingTransaction, type TestToolbox } from './setup.ts'; + +async function expectBalances( + toolbox: TestToolbox, + expected: { vault: string; asset: string; amount: number }[], +) { + const balances = await Promise.all( + expected.map(({ vault, asset }) => toolbox.getBalance(vault, asset)), + ); + for (const [idx, { amount }] of expected.entries()) { + expect(Number(balances[idx].balance.balance)).toBe(amount * 1_000_000); + } +} + +describe.concurrent( + 'e2e tests with isolated PAS Package (each test runs in its own PAS package)', + () => { + it('unlocks non-managed funds (e.g. SUI), but only through the unrestricted unlock flow', async () => { + const toolbox = await setupToolbox(); + const vaultId = toolbox.client.pas.deriveVaultAddress(toolbox.address()); + + const suiTypeName = normalizeStructTag('0x2::sui::SUI').toString(); + + const { balance } = await toolbox.getBalance(vaultId, suiTypeName); + expect(Number(balance.balance)).toBe(0); + + // Transfer 1 SUI to the vault. + const fundTransferTx = new Transaction(); + const sui = fundTransferTx.splitCoins(fundTransferTx.gas, [ + fundTransferTx.pure.u64(1_000_000_000), + ]); + + const into_balance = fundTransferTx.moveCall({ + target: '0x2::coin::into_balance', + arguments: [sui], + typeArguments: [suiTypeName], + }); + fundTransferTx.moveCall({ + target: '0x2::balance::send_funds', + arguments: [into_balance, fundTransferTx.pure.address(vaultId)], + typeArguments: [suiTypeName], + }); + await toolbox.executeTransaction(fundTransferTx); + + // Create the vault for the address. + await toolbox.createVaultForAddress(toolbox.address()); + + const { balance: vaultBalanceAfterTransfer } = await toolbox.getBalance(vaultId, suiTypeName); + expect(Number(vaultBalanceAfterTransfer.balance)).toBe(1_000_000_000); + + // try to do an unlock but it should fail because `rule` for Sui does not exist. + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.unlockFunds({ + from: toolbox.address(), + amount: 1_000_000_000, + assetType: suiTypeName, + }), + ); + // Should fail because SUI is not a managed asset + await expect(toolbox.executeTransaction(tx)).rejects.toThrowError( + 'Rule does not exist for asset type ', + ); + + // Now let's unlock funds properly. + const unlockTx = new Transaction(); + const withdrawal = unlockTx.add( + toolbox.client.pas.tx.unlockUnrestrictedFunds({ + from: toolbox.address(), + amount: 1_000_000_000, + assetType: suiTypeName, + }), + ); + unlockTx.moveCall({ + target: '0x2::balance::send_funds', + arguments: [withdrawal, unlockTx.pure.address(toolbox.address())], + typeArguments: [suiTypeName], + }); + + await toolbox.executeTransaction(unlockTx); + + const { balance: vaultBalanceAfterUnlock } = await toolbox.getBalance(vaultId, suiTypeName); + expect(Number(vaultBalanceAfterUnlock.balance)).toBe(0); + }); -describe('e2e tests with isolated PAS Package (each test runs in its own PAS package)', () => { - let toolbox: TestToolbox; + it('Should be able to transfer between vaults, going through the rule of the issuer;', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); - // Each execution should use its own runner to avoid shared state of PAS package. - beforeEach(async () => { - toolbox = await setupToolbox(); - }); + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); - it('unlocks non-managed funds (e.g. SUI), but only through the unrestricted unlock flow', async () => { - const vaultId = toolbox.client.pas.deriveVaultAddress(toolbox.address()); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + const toVaultId = toolbox.client.pas.deriveVaultAddress(to); - const suiTypeName = normalizeStructTag('0x2::sui::SUI').toString(); + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); - const { balance } = await toolbox.getBalance(vaultId, suiTypeName); - expect(Number(balance.balance)).toBe(0); + await demoUsd.mintFromFaucetInto(100, fromVaultId); - // Transfer 1 SUI to the vault. - const fundTransferTx = new Transaction(); - const sui = fundTransferTx.splitCoins(fundTransferTx.gas, [ - fundTransferTx.pure.u64(1_000_000_000), - ]); + const [{ balance: fromBalanceBefore }, { balance: toBalanceBefore }] = await Promise.all([ + toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), + ]); - const into_balance = fundTransferTx.moveCall({ - target: '0x2::coin::into_balance', - arguments: [sui], - typeArguments: [suiTypeName], - }); - fundTransferTx.moveCall({ - target: '0x2::balance::send_funds', - arguments: [into_balance, fundTransferTx.pure.address(vaultId)], - typeArguments: [suiTypeName], - }); - await toolbox.executeTransaction(fundTransferTx); - - // Create the vault for the address. - await toolbox.createVaultForAddress(toolbox.address()); - - const { balance: vaultBalanceAfterTransfer } = await toolbox.getBalance(vaultId, suiTypeName); - expect(Number(vaultBalanceAfterTransfer.balance)).toBe(1_000_000_000); - - // try to do an unlock but it should fail because `rule` for Sui does not exist. - const tx = new Transaction(); - tx.add( - toolbox.client.pas.tx.unlockFunds({ - from: toolbox.address(), - amount: 1_000_000_000, - assetType: suiTypeName, - }), - ); - // Should fail because SUI is not a managed asset - await expect(toolbox.executeTransaction(tx)).rejects.toThrowError( - 'Rule does not exist for asset type ', - ); - - // Now let's unlock funds properly. - const unlockTx = new Transaction(); - const withdrawal = unlockTx.add( - toolbox.client.pas.tx.unlockUnrestrictedFunds({ - from: toolbox.address(), - amount: 1_000_000_000, - assetType: suiTypeName, - }), - ); - unlockTx.moveCall({ - target: '0x2::balance::send_funds', - arguments: [withdrawal, unlockTx.pure.address(toolbox.address())], - typeArguments: [suiTypeName], - }); + expect(Number(fromBalanceBefore.balance)).toBe(100 * 1_000_000); + expect(Number(toBalanceBefore.balance)).toBe(0); - await toolbox.executeTransaction(unlockTx); + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 100 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); - const { balance: vaultBalanceAfterUnlock } = await toolbox.getBalance(vaultId, suiTypeName); - expect(Number(vaultBalanceAfterUnlock.balance)).toBe(0); - }); + await toolbox.executeTransaction(tx); - it('Should be able to transfer between vaults, going through the rule of the issuer;', async () => { - const demoUsd = new DemoUsdTestHelpers(toolbox); - await demoUsd.createRule(); + const [{ balance: fromBalanceAfter }, { balance: toBalanceAfter }] = await Promise.all([ + toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), + ]); - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); + expect(Number(fromBalanceAfter.balance)).toBe(0); + expect(Number(toBalanceAfter.balance)).toBe(100 * 1_000_000); + }); - const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); - const toVaultId = toolbox.client.pas.deriveVaultAddress(to); + it('Should be able to create the recipient vault if it does not exist ahead of time', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); - await toolbox.createVaultForAddress(from); - await toolbox.createVaultForAddress(to); + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); - await demoUsd.mintFromFaucetInto(100, fromVaultId); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + const toVaultId = toolbox.client.pas.deriveVaultAddress(to); - const [{ balance: fromBalanceBefore }, { balance: toBalanceBefore }] = await Promise.all([ - toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), - toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), - ]); + await demoUsd.mintFromFaucetInto(100, fromVaultId); + await toolbox.createVaultForAddress(from); - expect(Number(fromBalanceBefore.balance)).toBe(100 * 1_000_000); - expect(Number(toBalanceBefore.balance)).toBe(0); + await expect( + toolbox.client.core.getObject({ + objectId: toVaultId, + }), + ).rejects.toThrowError('not found'); - const tx = new Transaction(); - tx.add( - toolbox.client.pas.tx.transferFunds({ - from, - to, - amount: 100 * 1_000_000, - assetType: demoUsd.demoUsdAssetType, - }), - ); + const transaction = new Transaction(); + transaction.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); - await toolbox.executeTransaction(tx); + await toolbox.executeTransaction(transaction); - const [{ balance: fromBalanceAfter }, { balance: toBalanceAfter }] = await Promise.all([ - toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), - toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), - ]); + // Object should now exist after the first transfer. + const responseAfter = await toolbox.client.core.getObject({ + objectId: toVaultId, + }); - expect(Number(fromBalanceAfter.balance)).toBe(0); - expect(Number(toBalanceAfter.balance)).toBe(100 * 1_000_000); - }); + expect(responseAfter.object).toBeDefined(); + }); - it('Should be able to create the recipient vault if it does not exist ahead of time', async () => { - const demoUsd = new DemoUsdTestHelpers(toolbox); - await demoUsd.createRule(); + it('Should deduplicate vault creation when multiple intents reference the same non-existent vaults', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + // Sender is the test keypair (required for Auth), receiver is fresh. + const sender = toolbox.address(); + const receiver = normalizeSuiAddress('0xB2'); + + const senderVaultId = toolbox.client.pas.deriveVaultAddress(sender); + const receiverVaultId = toolbox.client.pas.deriveVaultAddress(receiver); + + // Verify neither vault exists. + await expect(toolbox.client.core.getObject({ objectId: senderVaultId })).rejects.toThrowError( + 'not found', + ); + await expect( + toolbox.client.core.getObject({ objectId: receiverVaultId }), + ).rejects.toThrowError('not found'); + + // Mint funds directly into the sender vault's address (balance::send_funds + // works even before the vault object exists). + await demoUsd.mintFromFaucetInto(200, senderVaultId); + + // Build a single PTB that: + // 1. Implicitly creates the sender vault (via vaultForAddress) + // 2. Has an intermediate non-PAS moveCall (a no-op) + // 3. Transfers 50 DEMO_USD from sender -> receiver (receiver vault created implicitly) + // 4. Has another intermediate non-PAS moveCall + // 5. Transfers another 50 DEMO_USD from sender -> receiver (same vaults, no re-creation) + const tx = new Transaction(); + + // (1) vaultForAddress for sender -- forces implicit creation + tx.add(toolbox.client.pas.tx.vaultForAddress(sender)); + + // (2) Intermediate command: a harmless moveCall (merge empty split back into gas) + const split1 = tx.splitCoins(tx.gas, [tx.pure.u64(0)]); + tx.mergeCoins(tx.gas, [split1]); + + // (3) First transfer: sender -> receiver (receiver vault does not exist) + tx.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 50 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + // (4) Another intermediate command + const split2 = tx.splitCoins(tx.gas, [tx.pure.u64(0)]); + tx.mergeCoins(tx.gas, [split2]); + + // (5) Second transfer: sender -> receiver (both vaults already created in this PTB) + tx.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 50 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + await toolbox.executeTransaction(tx); + + // Verify both vaults now exist. + const [senderObj, receiverObj] = await Promise.all([ + toolbox.client.core.getObject({ objectId: senderVaultId }), + toolbox.client.core.getObject({ objectId: receiverVaultId }), + ]); + expect(senderObj.object).toBeDefined(); + expect(receiverObj.object).toBeDefined(); + + // Verify balances: sender started with 200, transferred 50+50 = 100. + const [{ balance: senderBalance }, { balance: receiverBalance }] = await Promise.all([ + toolbox.getBalance(senderVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(receiverVaultId, demoUsd.demoUsdAssetType), + ]); + + expect(Number(senderBalance.balance)).toBe(100 * 1_000_000); + expect(Number(receiverBalance.balance)).toBe(100 * 1_000_000); + }); - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); + it('v1 approval rejects transfers over 10K', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x3'); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + await demoUsd.mintFromFaucetInto(15_000, fromVaultId); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 15_000 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await simulateFailingTransaction(toolbox, tx); + expect(resp.FailedTransaction).toBeDefined(); + expect(resp.FailedTransaction!.effects.status.error!.message).toContain( + 'Any amount over 10K is not allowed in this demo.', + ); + }); - const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); - const toVaultId = toolbox.client.pas.deriveVaultAddress(to); + it('self-transfer is rejected (same vault cannot be borrowed mutably twice)', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const addr = toolbox.address(); + const vaultId = toolbox.client.pas.deriveVaultAddress(addr); + + await toolbox.createVaultForAddress(addr); + await demoUsd.mintFromFaucetInto(10, vaultId); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from: addr, + to: addr, + amount: 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await simulateFailingTransaction(toolbox, tx); + expect(resp.FailedTransaction).toBeDefined(); + // Same vault passed as both &mut sender and &mut receiver -- Move rejects + // this before the approval function even runs. + expect(resp.FailedTransaction!.effects.status.error!.message).toContain( + 'InvalidReferenceArgument', + ); + }); - await demoUsd.mintFromFaucetInto(100, fromVaultId); - await toolbox.createVaultForAddress(from); + it('Should fail to transfer between vaults, if there are not enough funds in the source vault', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + + const transaction = new Transaction(); + transaction.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 100 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await toolbox.client.signAndExecuteTransaction({ + signer: toolbox.keypair, + transaction, + include: { + effects: true, + }, + }); + + expect(resp.FailedTransaction).toBeDefined(); + expect(resp.FailedTransaction!.effects.status.error!.message).toEqual( + 'InsufficientFundsForWithdraw', + ); + }); - await expect( - toolbox.client.core.getObject({ - objectId: toVaultId, - }), - ).rejects.toThrowError('not found'); - - const transaction = new Transaction(); - transaction.add( - toolbox.client.pas.tx.transferFunds({ - from, - to, - amount: 1_000_000, - assetType: demoUsd.demoUsdAssetType, - }), - ); - - await toolbox.executeTransaction(transaction); - - // Object should now exist after the first transfer. - const responseAfter = await toolbox.client.core.getObject({ - objectId: toVaultId, + it('use_v2 upgrades approval logic and the resolver picks up the new template', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x3'); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + await demoUsd.mintFromFaucetInto(15_000, fromVaultId); + + await demoUsd.upgradeToV2(); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 15_000 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + await toolbox.executeTransaction(tx); + + const { balance } = await toolbox.getBalance( + toolbox.client.pas.deriveVaultAddress(to), + demoUsd.demoUsdAssetType, + ); + expect(Number(balance.balance)).toBe(15_000 * 1_000_000); }); - expect(responseAfter.object).toBeDefined(); - }); - - it('Should fail to transfer between vaults, if there are not enough funds in the source vault', async () => { - const demoUsd = new DemoUsdTestHelpers(toolbox); - await demoUsd.createRule(); - - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); - - await toolbox.createVaultForAddress(from); - await toolbox.createVaultForAddress(to); - - const transaction = new Transaction(); - transaction.add( - toolbox.client.pas.tx.transferFunds({ - from, - to, - amount: 100 * 1_000_000, - assetType: demoUsd.demoUsdAssetType, - }), - ); - - const resp = await toolbox.client.signAndExecuteTransaction({ - signer: toolbox.keypair, - transaction, - include: { - effects: true, - }, + it('transfers two different asset types (v1 and v2 approval) in a single PTB', async () => { + const toolbox = await setupToolbox(); + const asset1 = new DemoUsdTestHelpers(toolbox, 'demo_usd_1'); + const asset2 = new DemoUsdTestHelpers(toolbox, 'demo_usd_2'); + await asset1.createRule(); + await asset2.createRule(); + + // Upgrade asset2 to v2 so the two assets use completely different approval code paths. + await asset2.upgradeToV2(); + + const sender = toolbox.address(); + const receiver = normalizeSuiAddress('0xB3'); + const senderVaultId = toolbox.client.pas.deriveVaultAddress(sender); + const receiverVaultId = toolbox.client.pas.deriveVaultAddress(receiver); + + await asset1.mintFromFaucetInto(500, senderVaultId); + await asset2.mintFromFaucetInto(800, senderVaultId); + + // --- First PTB: transfers both asset types, implicitly creates receiver vault --- + const tx1 = new Transaction(); + tx1.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 120 * 1_000_000, + assetType: asset1.demoUsdAssetType, + }), + ); + tx1.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 350 * 1_000_000, + assetType: asset2.demoUsdAssetType, + }), + ); + await toolbox.executeTransaction(tx1); + + const receiverObj = await toolbox.client.core.getObject({ objectId: receiverVaultId }); + expect(receiverObj.object).toBeDefined(); + await expectBalances(toolbox, [ + { vault: senderVaultId, asset: asset1.demoUsdAssetType, amount: 380 }, + { vault: senderVaultId, asset: asset2.demoUsdAssetType, amount: 450 }, + { vault: receiverVaultId, asset: asset1.demoUsdAssetType, amount: 120 }, + { vault: receiverVaultId, asset: asset2.demoUsdAssetType, amount: 350 }, + ]); + + // --- Second PTB: both vaults already exist, different amounts --- + const tx2 = new Transaction(); + tx2.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 80 * 1_000_000, + assetType: asset1.demoUsdAssetType, + }), + ); + tx2.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 150 * 1_000_000, + assetType: asset2.demoUsdAssetType, + }), + ); + await toolbox.executeTransaction(tx2); + + await expectBalances(toolbox, [ + { vault: senderVaultId, asset: asset1.demoUsdAssetType, amount: 300 }, + { vault: senderVaultId, asset: asset2.demoUsdAssetType, amount: 300 }, + { vault: receiverVaultId, asset: asset1.demoUsdAssetType, amount: 200 }, + { vault: receiverVaultId, asset: asset2.demoUsdAssetType, amount: 500 }, + ]); }); - expect(resp.FailedTransaction).toBeDefined(); - expect(resp.FailedTransaction!.effects.status.error!.message).toEqual( - 'InsufficientFundsForWithdraw', - ); - }); -}); + it('v2 approval rejects transfers to 0x2', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + await demoUsd.mintFromFaucetInto(10, fromVaultId); + + await demoUsd.upgradeToV2(); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await simulateFailingTransaction(toolbox, tx); + expect(resp.FailedTransaction).toBeDefined(); + expect(resp.FailedTransaction!.effects.status.error!.message).toContain( + 'Transfers to the address 0x2 are not allowed in this demo.', + ); + }); + }, +); diff --git a/sdk/pas/test/e2e/e2e.shared.test.ts b/sdk/pas/test/e2e/e2e.shared.test.ts index 970b482..b595659 100644 --- a/sdk/pas/test/e2e/e2e.shared.test.ts +++ b/sdk/pas/test/e2e/e2e.shared.test.ts @@ -43,7 +43,7 @@ describe('e2e tests with shared PAS package (all tests run in the same PAS packa effects: true, }, }), - ).rejects.toThrowError('No command found for UnlockFunds action in Rule for '); + ).rejects.toThrowError('No required approvals found for action'); }); it('derivations work as expected for vaults', async () => { diff --git a/sdk/pas/test/e2e/setup.ts b/sdk/pas/test/e2e/setup.ts index add2605..01e3697 100644 --- a/sdk/pas/test/e2e/setup.ts +++ b/sdk/pas/test/e2e/setup.ts @@ -56,9 +56,13 @@ export class TestToolbox { } /// Publishes a package at a given path. - /// IF the package is already published, we return its data. - /// It only does sequential writes to avoid equivocation (we use a mutex) - async publishPackage(packagePath: string) { + /// IF the package is already published under the same key, we return its data. + /// It only does sequential writes to avoid equivocation (we use a mutex). + /// An optional `cacheKey` allows publishing the same package path multiple + /// times under different keys (e.g. two independent demo_usd instances). + async publishPackage(packagePath: string, cacheKey?: string) { + const key = cacheKey ?? packagePath; + // Ensure only one publish happens at a time using the mutex const currentLock = this.publishLock; let releaseLock: () => void; @@ -69,9 +73,9 @@ export class TestToolbox { await currentLock; // If the package has already been published, return the published data. - if (this.publishedPackages[packagePath]) { + if (this.publishedPackages[key]) { releaseLock!(); - return this.publishedPackages[packagePath]; + return this.publishedPackages[key]; } try { @@ -81,14 +85,14 @@ export class TestToolbox { baseClient: this.client, }); - this.publishedPackages[packagePath] = { + this.publishedPackages[key] = { digest: publicationData.digest, createdObjects: publicationData.createdObjects, originalId: publicationData.packageId, publishedAt: publicationData.packageId, }; - return this.publishedPackages[packagePath]; + return this.publishedPackages[key]; } finally { // Release the lock so the next publish can proceed releaseLock!(); @@ -106,7 +110,7 @@ export class TestToolbox { // Creates a vault for a given address. async createVaultForAddress(address: string) { const tx = new Transaction(); - tx.add(this.client.pas.call.createAndShareVault(address)); + tx.add(this.client.pas.tx.vaultForAddress(address)); return this.executeTransaction(tx); } @@ -153,9 +157,6 @@ export async function setupToolbox() { // Get some gas for any publishes. await execSuiTools(['sui', 'client', '--client.config', configPath, 'faucet']); - // wait for the faucet to be ready (give it 2s, should probably be like 100ms) - await new Promise((resolve) => setTimeout(resolve, 2000)); - // Track the published packages. const publishedPackages: Record = {}; @@ -187,18 +188,42 @@ export async function setupToolbox() { publishedAt: pasPublishData.packageId, }; + const pasPackageId = pasPublishData.packageId; + const namespaceId = pasPublishData.createdObjects.find((obj) => + obj.type.endsWith('namespace::Namespace'), + )?.id!; + const upgradeCapId = pasPublishData.createdObjects.find((obj) => + obj.type.endsWith('UpgradeCap'), + )?.id!; + // Extend the client with pas so we can use it across our testing. const client = baseClient.$extend( pas({ packageConfig: { - packageId: pasPublishData.packageId, - namespaceId: pasPublishData.createdObjects.find((obj) => - obj.type.endsWith('namespace::Namespace'), - )?.id!, + packageId: pasPackageId, + namespaceId, }, }), ); + // Link the UpgradeCap to the Namespace (required before any derived object operations). + // This must be done via CLI since the UpgradeCap is owned by the CLI address, not the test keypair. + + await execSuiTools([ + 'sui', + 'client', + '--client.config', + configPath, + 'ptb', + '--move-call', + `${pasPackageId}::namespace::setup`, + `@${namespaceId} @${upgradeCapId}`, + '--move-call', + `${pasPackageId}::templates::setup`, + `@${namespaceId}`, + '--json', + ]); + return new TestToolbox(keypair, client, configPath, pubFilePath, publishedPackages); } @@ -313,6 +338,24 @@ export async function executeTransaction(toolbox: TestToolbox, tx: Transaction) return resp; } +/** + * Simulate a transaction that is expected to fail, returning the structured + * error with smart-error messages. Uses `simulateTransaction` (not dry-run + * budget estimation) so Move aborts surface as `FailedTransaction` responses + * rather than thrown RPC errors. + */ +export async function simulateFailingTransaction(toolbox: TestToolbox, tx: Transaction) { + tx.setSenderIfNotSet(toolbox.address()); + await tx.prepareForSerialization({ client: toolbox.client }); + + const resp = await toolbox.client.core.simulateTransaction({ + transaction: tx, + include: { effects: true }, + }); + + return resp; +} + export async function simulateTransaction(toolbox: TestToolbox, tx: Transaction) { tx.setSender(toolbox.address()); await tx.prepareForSerialization({ client: toolbox.client }); diff --git a/sdk/pnpm-lock.yaml b/sdk/pnpm-lock.yaml index 7980b9e..ac0b9a6 100644 --- a/sdk/pnpm-lock.yaml +++ b/sdk/pnpm-lock.yaml @@ -9,20 +9,20 @@ importers: .: devDependencies: '@mysten/bcs': - specifier: ^2.0.1 - version: 2.0.1 + specifier: ^2.0.2 + version: 2.0.2 '@mysten/codegen': specifier: ^0.6.0 version: 0.6.0 '@mysten/sui': - specifier: ^2.0.1 - version: 2.0.1(typescript@5.9.3) + specifier: ^2.4.0 + version: 2.4.0(typescript@5.9.3) example-app: devDependencies: '@mysten/sui': - specifier: ^2.0.1 - version: 2.0.1(typescript@5.9.3) + specifier: ^2.4.0 + version: 2.4.0(typescript@5.9.3) '@types/node': specifier: ^25.0.8 version: 25.0.9 @@ -39,14 +39,14 @@ importers: specifier: ^4.7.0 version: 4.7.0(prettier@3.8.0) '@mysten/bcs': - specifier: ^2.0.1 - version: 2.0.1 + specifier: ^2.0.2 + version: 2.0.2 '@mysten/codegen': specifier: ^0.6.0 version: 0.6.0 '@mysten/sui': - specifier: ^2.0.1 - version: 2.0.1(typescript@5.9.3) + specifier: ^2.4.0 + version: 2.4.0(typescript@5.9.3) '@testcontainers/postgresql': specifier: ^11.11.0 version: 11.11.0 @@ -439,19 +439,19 @@ packages: resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} - '@mysten/bcs@2.0.1': - resolution: {integrity: sha512-/q1uC5Vw/RjXzR4fE9QiIwmTvRx9BstduxkR1j1Z5EbE7ZJynLkFKcJJlXRcnnEytIPcsyiR+TkQixzSbz3msQ==} + '@mysten/bcs@2.0.2': + resolution: {integrity: sha512-c/nVRPJEV1fRZdKXhysVsy/yCPdiFt7jn6A4/7W2LH1ZPSVPzRkxtLY362D0zaLuBnyT5Y9d9nFLm3ixI8Goug==} '@mysten/codegen@0.6.0': resolution: {integrity: sha512-35QCW9E6JRx17XHhh3GjQbPyVzvr7UsGPmnQxc9QDXZmgY4+P6AaksDke+pTi/hBi/L2nH6M09umJBBv3Q6XIw==} hasBin: true - '@mysten/sui@2.0.1': - resolution: {integrity: sha512-uFaD9sPwi9Wy+KlSc2uMhjo+yWEay4SOc6RP9cj16+CpzbzRijXtRFagQytQJ0KXM6qCTlwWtXpkmkFGuMA98w==} + '@mysten/sui@2.4.0': + resolution: {integrity: sha512-2EG5+lTypWMgU3lWyKlcjopQ++Ae9BkoROOBVcJ6mYwM5o1IcKnoc7rFqr94KQClYmBFV2aFz5+aKm5okatoBw==} engines: {node: '>=22'} - '@mysten/utils@0.3.0': - resolution: {integrity: sha512-paVyFTP+1yHXDO8uorU6YuT1EAIh/GMKXeHbxtGFPJbLzwc5jk1qfUMrks/S7MAwJHMOHcSOx+e2Mwx8ejaIew==} + '@mysten/utils@0.3.1': + resolution: {integrity: sha512-36KhxG284uhDdSnlkyNaS6fzKTX9FpP2WQWOwUKIRsqQFFIm2ooCf2TP1IuqrtMpkairwpiWkAS0eg7cpemVzg==} '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -2233,15 +2233,15 @@ snapshots: strict-event-emitter: 0.5.1 optional: true - '@mysten/bcs@2.0.1': + '@mysten/bcs@2.0.2': dependencies: - '@mysten/utils': 0.3.0 + '@mysten/utils': 0.3.1 '@scure/base': 2.0.0 '@mysten/codegen@0.6.0': dependencies: - '@mysten/bcs': 2.0.1 - '@mysten/sui': 2.0.1(typescript@5.9.3) + '@mysten/bcs': 2.0.2 + '@mysten/sui': 2.4.0(typescript@5.9.3) '@stricli/auto-complete': 1.2.5 '@stricli/core': 1.2.5 '@types/node': 25.0.9 @@ -2254,11 +2254,11 @@ snapshots: - '@gql.tada/svelte-support' - '@gql.tada/vue-support' - '@mysten/sui@2.0.1(typescript@5.9.3)': + '@mysten/sui@2.4.0(typescript@5.9.3)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - '@mysten/bcs': 2.0.1 - '@mysten/utils': 0.3.0 + '@mysten/bcs': 2.0.2 + '@mysten/utils': 0.3.1 '@noble/curves': 2.0.1 '@noble/hashes': 2.0.1 '@protobuf-ts/grpcweb-transport': 2.11.1 @@ -2276,7 +2276,7 @@ snapshots: - '@gql.tada/vue-support' - typescript - '@mysten/utils@0.3.0': + '@mysten/utils@0.3.1': dependencies: '@scure/base': 2.0.0