From 0d0034a552ae1b4cf3dd58866fc9a7ff9446b1ad Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 12:30:09 +0200 Subject: [PATCH 1/3] codegen rerun --- sdk/pas/src/contracts/pas/clawback_funds.ts | 115 +++++++++++ sdk/pas/src/contracts/pas/deps/sui/vec_map.ts | 35 ++++ sdk/pas/src/contracts/pas/deps/sui/vec_set.ts | 24 +++ sdk/pas/src/contracts/pas/keys.ts | 78 +++++++- sdk/pas/src/contracts/pas/namespace.ts | 96 +++++++++ sdk/pas/src/contracts/pas/request.ts | 92 +++++++++ sdk/pas/src/contracts/pas/rule.ts | 185 ++++++++---------- sdk/pas/src/contracts/pas/templates.ts | 95 +++++++++ sdk/pas/src/contracts/pas/transfer_funds.ts | 160 +++++++++++++++ sdk/pas/src/contracts/pas/unlock_funds.ts | 145 ++++++++++++++ sdk/pas/src/contracts/pas/vault.ts | 59 ++++++ sdk/pas/src/contracts/pas/versioning.ts | 71 +++++++ 12 files changed, 1053 insertions(+), 102 deletions(-) create mode 100644 sdk/pas/src/contracts/pas/clawback_funds.ts create mode 100644 sdk/pas/src/contracts/pas/deps/sui/vec_map.ts create mode 100644 sdk/pas/src/contracts/pas/deps/sui/vec_set.ts create mode 100644 sdk/pas/src/contracts/pas/request.ts create mode 100644 sdk/pas/src/contracts/pas/templates.ts create mode 100644 sdk/pas/src/contracts/pas/transfer_funds.ts create mode 100644 sdk/pas/src/contracts/pas/unlock_funds.ts create mode 100644 sdk/pas/src/contracts/pas/versioning.ts 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.ts b/sdk/pas/src/contracts/pas/transfer_funds.ts new file mode 100644 index 0000000..f79d295 --- /dev/null +++ b/sdk/pas/src/contracts/pas/transfer_funds.ts @@ -0,0 +1,160 @@ +/************************************************************** + * 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::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, + /** `recipient` is the wallet OR object address, NOT the vault address */ + recipient: bcs.Address, + /** The ID of the vault the funds are coming from */ + sender_vault_id: bcs.Address, + /** The ID of the vault the funds are going to */ + recipient_vault_id: bcs.Address, + /** The amount being transferred (original) */ + amount: bcs.u64(), + /** The actual balance being transferred */ + balance: balance.Balance, + }, +}); +export interface SenderArguments { + request: RawTransactionArgument; +} +export interface SenderOptions { + package?: string; + arguments: SenderArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function sender(options: SenderOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'transfer_funds', + function: 'sender', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface RecipientArguments { + request: RawTransactionArgument; +} +export interface RecipientOptions { + package?: string; + arguments: RecipientArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function recipient(options: RecipientOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'transfer_funds', + function: 'recipient', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface SenderVaultIdArguments { + request: RawTransactionArgument; +} +export interface SenderVaultIdOptions { + package?: string; + arguments: SenderVaultIdArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function senderVaultId(options: SenderVaultIdOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'transfer_funds', + function: 'sender_vault_id', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface RecipientVaultIdArguments { + request: RawTransactionArgument; +} +export interface RecipientVaultIdOptions { + package?: string; + arguments: RecipientVaultIdArguments | [request: RawTransactionArgument]; + typeArguments: [string]; +} +export function recipientVaultId(options: RecipientVaultIdOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null] satisfies (string | null)[]; + const parameterNames = ['request']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + module: 'transfer_funds', + function: 'recipient_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: '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.ts b/sdk/pas/src/contracts/pas/unlock_funds.ts new file mode 100644 index 0000000..520c3cf --- /dev/null +++ b/sdk/pas/src/contracts/pas/unlock_funds.ts @@ -0,0 +1,145 @@ +/************************************************************** + * 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::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, + /** The ID of the vault the funds are coming from */ + vault_id: bcs.Address, + /** The amount being transferred (initial amount) */ + amount: bcs.u64(), + /** The actual balance being transferred */ + 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: 'unlock_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: 'unlock_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: 'unlock_funds', + function: 'amount', + arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), + typeArguments: options.typeArguments, + }); +} +export interface ResolveUnrestrictedArguments { + request: RawTransactionArgument; + namespace: RawTransactionArgument; +} +export interface ResolveUnrestrictedOptions { + package?: string; + arguments: + | ResolveUnrestrictedArguments + | [request: RawTransactionArgument, namespace: RawTransactionArgument]; + typeArguments: [string]; +} +/** + * 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. + * + * 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. + */ +export function resolveUnrestricted(options: ResolveUnrestrictedOptions) { + const packageAddress = options.package ?? '@mysten/pas'; + const argumentsTypes = [null, null] satisfies (string | null)[]; + const parameterNames = ['request', 'namespace']; + return (tx: Transaction) => + tx.moveCall({ + package: packageAddress, + 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), + }); +} From 0e8201b4b0b09607bb3e8ae67c4335062b88cc94 Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 13:10:29 +0200 Subject: [PATCH 2/3] fix SDK to use new flows (v1) --- sdk/pas/src/client.ts | 225 +++++++++++------- .../contracts/pas/transfer_funds_request.ts | 132 ---------- .../src/contracts/pas/unlock_funds_request.ts | 117 --------- sdk/pas/src/derivation.ts | 38 ++- sdk/pas/src/resolution.ts | 84 +++---- sdk/pas/test/e2e/demoUsd.ts | 2 + sdk/pas/test/e2e/e2e.shared.test.ts | 2 +- sdk/pas/test/e2e/setup.ts | 52 +++- 8 files changed, 257 insertions(+), 395 deletions(-) delete mode 100644 sdk/pas/src/contracts/pas/transfer_funds_request.ts delete mode 100644 sdk/pas/src/contracts/pas/unlock_funds_request.ts diff --git a/sdk/pas/src/client.ts b/sdk/pas/src/client.ts index 3be25e8..643d9d4 100644 --- a/sdk/pas/src/client.ts +++ b/sdk/pas/src/client.ts @@ -1,24 +1,31 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import type { ClientWithCoreApi } from '@mysten/sui/client'; +import type { ClientWithCoreApi, SuiClientTypes } 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 { resolve as resolveTransferFunds } from './contracts/pas/transfer_funds.js'; +import { + resolve as resolveUnlockFunds, + resolveUnrestricted, +} from './contracts/pas/unlock_funds.js'; import * as Vault from './contracts/pas/vault.js'; -import { deriveRuleAddress, deriveVaultAddress } from './derivation.js'; +import { + deriveRuleAddress, + deriveTemplateDFAddress, + deriveTemplatesObjectAddress, + deriveVaultAddress, +} from './derivation.js'; import { PASClientError, RuleNotFoundError, VaultNotFoundError } from './error.js'; import { addMoveCallFromCommand, - buildActionTypeName, - getCommandForAction, + getCommandFromTemplateDF, + getRequiredApprovals, PASActionType, } from './resolution.js'; import type { PASClientConfig, PASOptions, PASPackageConfig } from './types.js'; @@ -124,17 +131,22 @@ 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 + */ + deriveTemplatesAddress(): string { + return deriveTemplatesObjectAddress(this.#packageConfig); + } + + /** + * 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 */ - deriveRuleResolutionInfoAddress(assetType: string): string { - const ruleAddress = this.deriveRuleAddress(assetType); - return deriveDynamicFieldID( - ruleAddress, - `${this.#packageConfig.packageId}::rule::ResolutionInfo`, - ResolutionInfo.serialize([false]).toBytes(), - ); + deriveTemplateAddress(approvalTypeName: string): string { + return deriveTemplateDFAddress(this.deriveTemplatesAddress(), approvalTypeName); } call = { @@ -156,12 +168,55 @@ export class PASClient { }, }; + /** + * Fetches the Rule object for a given asset type and extracts the required approval + * type names for the specified action. Then fetches the template DFs for each approval. + * + * @returns The list of parsed commands from the template DFs + */ + async #resolveTemplateCommands( + ruleObject: SuiClientTypes.Object<{ content: true }>, + actionType: PASActionType, + ) { + const approvalTypeNames = getRequiredApprovals(ruleObject, actionType); + + if (!approvalTypeNames || approvalTypeNames.length === 0) { + throw new PASClientError( + `No required approvals found for action "${actionType}". The issuer has not configured this action.`, + ); + } + + // Derive template DF addresses for each approval type + const templateDFIds = approvalTypeNames.map((typeName) => this.deriveTemplateAddress(typeName)); + + // Fetch all template DFs + const { objects: templateDFs } = await this.#suiClient.core.getObjects({ + objectIds: templateDFIds, + include: { content: true }, + }); + + // Parse commands from each template DF + const commands = []; + for (let i = 0; i < approvalTypeNames.length; i++) { + const templateDF = templateDFs[i]; + if (!templateDF || templateDF instanceof Error || !templateDF.content) { + throw new PASClientError( + `Template not found for approval type "${approvalTypeNames[i]}". The issuer has not set up the template command.`, + ); + } + commands.push(getCommandFromTemplateDF(templateDF)); + } + + return commands; + } + /** * Methods that create transactions without executing them */ tx = { /** - * Creates a transfer funds transaction. It auto-resolves the creator's transfer function. + * Creates a transfer funds transaction. It auto-resolves the creator's transfer function + * by reading the Rule's required approvals and fetching the corresponding template commands. * * @param options - Transfer options * @param options.from - The sender's address (owner of the source vault) @@ -183,39 +238,39 @@ export class PASClient { 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 + // 2. Fetch vaults and rule in a single batch call const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [resolutionInfoId, fromVaultId, toVaultId], + objectIds: [fromVaultId, toVaultId, ruleId], 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, ); + const ruleResult = objects.find( + (obj) => !(obj instanceof Error) && obj.objectId === ruleId, + ); if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) { throw new VaultNotFoundError(from); } - // 4. Validate and parse rule - if ( - !resolutionInfoResult || - resolutionInfoResult instanceof Error || - !resolutionInfoResult.content - ) { + if (!ruleResult || ruleResult instanceof Error || !ruleResult.content) { throw new RuleNotFoundError(assetType); } - // 6. Check if recipient vault exists + // 4. Resolve template commands for the transfer action + const templateCommands = await this.#resolveTemplateCommands( + ruleResult as SuiClientTypes.Object<{ content: true }>, + PASActionType.TransferFunds, + ); + + // 5. Check if recipient vault exists const toVaultExists = toVaultResult && !(toVaultResult instanceof Error) && toVaultResult.content !== null; @@ -244,30 +299,24 @@ export class PASClient { 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}`, - ); + // 9. Execute each template command (approval) + for (const command of templateCommands) { + addMoveCallFromCommand(command, { + tx, + senderVault: tx.object(fromVaultId), + receiverVault: toVault, + rule: tx.object(ruleId), + request: transferRequest, + systemType: 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, - }); + // 10. Resolve the transfer request (consumes the request after all approvals) + resolveTransferFunds({ + package: this.#packageConfig.packageId, + arguments: [transferRequest, tx.object(ruleId)], + typeArguments: [assetType], + })(tx); // 11. Share the vault if it was just created if (shouldShareVault) { @@ -276,8 +325,6 @@ export class PASClient { arguments: [toVault], })(tx); } - - return result; }; }, @@ -298,31 +345,26 @@ export class PASClient { // 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 + // 2. Fetch vault and rule const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [resolutionInfoId, fromVaultId], + objectIds: [fromVaultId, ruleId], 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 ruleResult = objects.find( + (obj) => !(obj instanceof Error) && obj.objectId === ruleId, + ); - if ( - !resolutionInfoResult || - resolutionInfoResult instanceof Error || - !resolutionInfoResult.content - ) { + if (!ruleResult || ruleResult instanceof Error || !ruleResult.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.`, + `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.`, ); } @@ -330,40 +372,41 @@ export class PASClient { throw new VaultNotFoundError(from); } - // 4. Create auth proof from transaction sender + // 4. Resolve template commands for the unlock action + const templateCommands = await this.#resolveTemplateCommands( + ruleResult as SuiClientTypes.Object<{ content: true }>, + PASActionType.UnlockFunds, + ); + + // 5. Create auth proof from transaction sender const auth = Vault.newAuth({ package: this.#packageConfig.packageId, })(tx); - // 5. Create the unlock request using vault::unlock_funds + // 6. 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. Execute each template command (approval) + for (const command of templateCommands) { + addMoveCallFromCommand(command, { + tx, + senderVault: tx.object(fromVaultId), + rule: tx.object(ruleId), + request: unlockRequest, + systemType: assetType, + }); } - // 7. Build the PTB from the command - return addMoveCallFromCommand(command, { - tx, - senderVault: tx.object(fromVaultId), - rule: tx.object(ruleId), - request: unlockRequest, - systemType: assetType, - }); + // 8. Resolve the unlock request (consumes the request after all approvals) + return resolveUnlockFunds({ + package: this.#packageConfig.packageId, + arguments: [unlockRequest, tx.object(ruleId)], + typeArguments: [assetType], + })(tx); }; }, diff --git a/sdk/pas/src/contracts/pas/transfer_funds_request.ts b/sdk/pas/src/contracts/pas/transfer_funds_request.ts deleted file mode 100644 index 2f8c128..0000000 --- a/sdk/pas/src/contracts/pas/transfer_funds_request.ts +++ /dev/null @@ -1,132 +0,0 @@ -/************************************************************** - * 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::transfer_funds_request'; -export const TransferFundsRequest = new MoveStruct({ - name: `${$moduleName}::TransferFundsRequest`, - fields: { - /** `sender` is the wallet OR object address, NOT the vault address */ - sender: bcs.Address, - /** `recipient` is the wallet OR object address, NOT the vault address */ - recipient: bcs.Address, - /** The ID of the vault the funds are coming from */ - sender_vault_id: bcs.Address, - /** The ID of the vault the funds are going to */ - recipient_vault_id: bcs.Address, - /** The amount being transferred (original) */ - amount: bcs.u64(), - /** The actual balance being transferred */ - balance: balance.Balance, - }, -}); -export interface SenderArguments { - request: RawTransactionArgument; -} -export interface SenderOptions { - package?: string; - arguments: SenderArguments | [request: RawTransactionArgument]; - typeArguments: [string]; -} -export function sender(options: SenderOptions) { - const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null] satisfies (string | null)[]; - const parameterNames = ['request']; - return (tx: Transaction) => - tx.moveCall({ - package: packageAddress, - module: 'transfer_funds_request', - function: 'sender', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), - typeArguments: options.typeArguments, - }); -} -export interface RecipientArguments { - request: RawTransactionArgument; -} -export interface RecipientOptions { - package?: string; - arguments: RecipientArguments | [request: RawTransactionArgument]; - typeArguments: [string]; -} -export function recipient(options: RecipientOptions) { - const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null] satisfies (string | null)[]; - const parameterNames = ['request']; - return (tx: Transaction) => - tx.moveCall({ - package: packageAddress, - module: 'transfer_funds_request', - function: 'recipient', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), - typeArguments: options.typeArguments, - }); -} -export interface SenderVaultIdArguments { - request: RawTransactionArgument; -} -export interface SenderVaultIdOptions { - package?: string; - arguments: SenderVaultIdArguments | [request: RawTransactionArgument]; - typeArguments: [string]; -} -export function senderVaultId(options: SenderVaultIdOptions) { - const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null] satisfies (string | null)[]; - const parameterNames = ['request']; - return (tx: Transaction) => - tx.moveCall({ - package: packageAddress, - module: 'transfer_funds_request', - function: 'sender_vault_id', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), - typeArguments: options.typeArguments, - }); -} -export interface RecipientVaultIdArguments { - request: RawTransactionArgument; -} -export interface RecipientVaultIdOptions { - package?: string; - arguments: RecipientVaultIdArguments | [request: RawTransactionArgument]; - typeArguments: [string]; -} -export function recipientVaultId(options: RecipientVaultIdOptions) { - const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null] satisfies (string | null)[]; - const parameterNames = ['request']; - return (tx: Transaction) => - tx.moveCall({ - package: packageAddress, - module: 'transfer_funds_request', - function: 'recipient_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: 'transfer_funds_request', - function: 'amount', - 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_request.ts deleted file mode 100644 index 81beb35..0000000 --- a/sdk/pas/src/contracts/pas/unlock_funds_request.ts +++ /dev/null @@ -1,117 +0,0 @@ -/************************************************************** - * 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::unlock_funds_request'; -export const UnlockFundsRequest = new MoveStruct({ - name: `${$moduleName}::UnlockFundsRequest`, - fields: { - /** `from` 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 amount being transferred (initial amount) */ - amount: bcs.u64(), - /** The actual balance being transferred */ - 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: 'unlock_funds_request', - 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: 'unlock_funds_request', - 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: 'unlock_funds_request', - function: 'amount', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), - typeArguments: options.typeArguments, - }); -} -export interface ResolveUnrestrictedArguments { - request: RawTransactionArgument; - namespace: RawTransactionArgument; -} -export interface ResolveUnrestrictedOptions { - package?: string; - arguments: - | ResolveUnrestrictedArguments - | [request: RawTransactionArgument, namespace: RawTransactionArgument]; - typeArguments: [string]; -} -/** - * 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. - * - * 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. - */ -export function resolveUnrestricted(options: ResolveUnrestrictedOptions) { - const packageAddress = options.package ?? '@mysten/pas'; - const argumentsTypes = [null, null] satisfies (string | null)[]; - const parameterNames = ['request', 'namespace']; - return (tx: Transaction) => - tx.moveCall({ - package: packageAddress, - module: 'unlock_funds_request', - function: 'resolve_unrestricted', - arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames), - typeArguments: options.typeArguments, - }); -} diff --git a/sdk/pas/src/derivation.ts b/sdk/pas/src/derivation.ts index 2745f49..415121f 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 deriveTemplatesObjectAddress(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 deriveTemplateDFAddress(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/resolution.ts b/sdk/pas/src/resolution.ts index 33ffdb4..037f2ed 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -1,16 +1,15 @@ // 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 { 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,64 +20,51 @@ 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 getCommandFromTemplateDF( + templateDF: SuiClientTypes.Object<{ content: true }>, +): ReturnType { + const df = Field(TypeName, Command).parse(templateDF.content); + return parseCommand(df.value); } // TODO: Discuss why this is interpreted as `(number | number[])[])` instead of `[number, number[]]` diff --git a/sdk/pas/test/e2e/demoUsd.ts b/sdk/pas/test/e2e/demoUsd.ts index b21148e..4504533 100644 --- a/sdk/pas/test/e2e/demoUsd.ts +++ b/sdk/pas/test/e2e/demoUsd.ts @@ -27,12 +27,14 @@ export class DemoUsdTestHelpers { this.#publicationData = result; const faucetId = result.createdObjects.find((o) => o.type.endsWith('demo_usd::Faucet'))!.id; + const templatesId = this.toolbox.client.pas.deriveTemplatesAddress(); const transaction = new Transaction(); transaction.moveCall({ target: `${result.originalId}::demo_usd::setup`, arguments: [ transaction.object(this.toolbox.client.pas.getPackageConfig().namespaceId), + transaction.object(templatesId), transaction.object(faucetId), ], }); 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..743705e 100644 --- a/sdk/pas/test/e2e/setup.ts +++ b/sdk/pas/test/e2e/setup.ts @@ -187,18 +187,62 @@ 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, + 'call', + '--package', + pasPackageId, + '--module', + 'namespace', + '--function', + 'setup', + '--args', + namespaceId, + upgradeCapId, + '--json', + ]); + + // Create the Templates object (required for template-based resolution) + await execSuiTools([ + 'sui', + 'client', + '--client.config', + configPath, + 'call', + '--package', + pasPackageId, + '--module', + 'templates', + '--function', + 'setup', + '--args', + namespaceId, + '--json', + ]); + return new TestToolbox(keypair, client, configPath, pubFilePath, publishedPackages); } From d23fc2bd9b07bde0551dd78445fc460d1876961e Mon Sep 17 00:00:00 2001 From: Manolis Liolios Date: Tue, 17 Feb 2026 17:01:19 +0200 Subject: [PATCH 3/3] [sdk] Productionize (use intents) (#17) * SDK built with intents now * fmt * Cleanups + more complex tests * naming fixes * nits * nits * fix example * Cleanup more --- .../testing/demo_usd/sources/demo_usd.move | 8 +- sdk/example-app/package.json | 2 +- sdk/example-app/src/extension-example.ts | 9 +- sdk/package.json | 8 +- sdk/pas/package.json | 4 +- sdk/pas/src/bcs.ts | 20 - sdk/pas/src/client.ts | 410 ++------- sdk/pas/src/constants.ts | 4 +- sdk/pas/src/derivation.ts | 4 +- sdk/pas/src/error.ts | 7 - sdk/pas/src/intents.ts | 810 ++++++++++++++++++ sdk/pas/src/resolution.ts | 148 ++-- sdk/pas/test/e2e/data/demo_usd/Move.lock | 35 + .../e2e/data/demo_usd/sources/demo_usd.move | 8 +- sdk/pas/test/e2e/demoUsd.ts | 33 +- sdk/pas/test/e2e/e2e.isolated.test.ts | 627 ++++++++++---- sdk/pas/test/e2e/setup.ts | 75 +- sdk/pnpm-lock.yaml | 48 +- 18 files changed, 1563 insertions(+), 697 deletions(-) create mode 100644 sdk/pas/src/intents.ts create mode 100644 sdk/pas/test/e2e/data/demo_usd/Move.lock diff --git a/packages/testing/demo_usd/sources/demo_usd.move b/packages/testing/demo_usd/sources/demo_usd.move index 6178389..20f4266 100644 --- a/packages/testing/demo_usd/sources/demo_usd.move +++ b/packages/testing/demo_usd/sources/demo_usd.move @@ -15,7 +15,6 @@ use pas::templates::Templates; use pas::transfer_funds::TransferFunds; use ptb::ptb; use std::type_name; -use sui::accumulator::AccumulatorRoot; use sui::balance::Balance; use sui::clock::Clock; use sui::coin::TreasuryCap; @@ -105,7 +104,7 @@ public fun use_v2(rule: &mut Rule, templates: &mut Templates, faucet: type_name::with_defining_ids().address_string().to_string(), "demo_usd", "approve_transfer_v2", - vector[ptb::ext_input("pas:request"), ptb::object_by_id(@0xacc.to_id())], + vector[ptb::ext_input("pas:request"), ptb::object_by_id(object::id(faucet))], vector[], ); @@ -125,10 +124,7 @@ public fun approve_transfer(request: &mut Request>, _clock: } /// V2 function allows all transfers, besides transferring to 0x2. -public fun approve_transfer_v2( - request: &mut Request>, - _acc: &AccumulatorRoot, -) { +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 643d9d4..da177af 100644 --- a/sdk/pas/src/client.ts +++ b/sdk/pas/src/client.ts @@ -1,33 +1,26 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; -import { Transaction } from '@mysten/sui/transactions'; +import type { ClientWithCoreApi } from '@mysten/sui/client'; import { DEVNET_PAS_PACKAGE_CONFIG, MAINNET_PAS_PACKAGE_CONFIG, TESTNET_PAS_PACKAGE_CONFIG, } from './constants.js'; -import { resolve as resolveTransferFunds } from './contracts/pas/transfer_funds.js'; -import { - resolve as resolveUnlockFunds, - resolveUnrestricted, -} from './contracts/pas/unlock_funds.js'; -import * as Vault from './contracts/pas/vault.js'; import { deriveRuleAddress, - deriveTemplateDFAddress, - deriveTemplatesObjectAddress, + deriveTemplateAddress, + deriveTemplateRegistryAddress, deriveVaultAddress, } from './derivation.js'; -import { PASClientError, RuleNotFoundError, VaultNotFoundError } from './error.js'; +import { PASClientError } from './error.js'; import { - addMoveCallFromCommand, - getCommandFromTemplateDF, - getRequiredApprovals, - PASActionType, -} from './resolution.js'; + transferFundsIntent, + unlockFundsIntent, + unlockUnrestrictedFundsIntent, + vaultForAddressIntent, +} from './intents.js'; import type { PASClientConfig, PASOptions, PASPackageConfig } from './types.js'; export function pas({ @@ -135,8 +128,8 @@ export class PASClient { * * @returns The derived templates object ID */ - deriveTemplatesAddress(): string { - return deriveTemplatesObjectAddress(this.#packageConfig); + deriveTemplateRegistryAddress(): string { + return deriveTemplateRegistryAddress(this.#packageConfig); } /** @@ -146,335 +139,64 @@ export class PASClient { * @returns The derived dynamic field object ID */ deriveTemplateAddress(approvalTypeName: string): string { - return deriveTemplateDFAddress(this.deriveTemplatesAddress(), approvalTypeName); + return deriveTemplateAddress(this.deriveTemplateRegistryAddress(), approvalTypeName); } - 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); - }; - }, - }; - /** - * Fetches the Rule object for a given asset type and extracts the required approval - * type names for the specified action. Then fetches the template DFs for each approval. - * - * @returns The list of parsed commands from the template DFs + * 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. */ - async #resolveTemplateCommands( - ruleObject: SuiClientTypes.Object<{ content: true }>, - actionType: PASActionType, - ) { - const approvalTypeNames = getRequiredApprovals(ruleObject, actionType); - - if (!approvalTypeNames || approvalTypeNames.length === 0) { - throw new PASClientError( - `No required approvals found for action "${actionType}". The issuer has not configured this action.`, - ); - } - - // Derive template DF addresses for each approval type - const templateDFIds = approvalTypeNames.map((typeName) => this.deriveTemplateAddress(typeName)); - - // Fetch all template DFs - const { objects: templateDFs } = await this.#suiClient.core.getObjects({ - objectIds: templateDFIds, - include: { content: true }, - }); - - // Parse commands from each template DF - const commands = []; - for (let i = 0; i < approvalTypeNames.length; i++) { - const templateDF = templateDFs[i]; - if (!templateDF || templateDF instanceof Error || !templateDF.content) { - throw new PASClientError( - `Template not found for approval type "${approvalTypeNames[i]}". The issuer has not set up the template command.`, - ); - } - commands.push(getCommandFromTemplateDF(templateDF)); - } - - return commands; + 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), + }; } - - /** - * Methods that create transactions without executing them - */ - tx = { - /** - * Creates a transfer funds transaction. It auto-resolves the creator's transfer function - * by reading the Rule's required approvals and fetching the corresponding template commands. - * - * @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); - - // 2. Fetch vaults and rule in a single batch call - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [fromVaultId, toVaultId, ruleId], - include: { content: true }, - }); - - // 3. Find objects by ID - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - const toVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === toVaultId, - ); - const ruleResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === ruleId, - ); - - if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) { - throw new VaultNotFoundError(from); - } - - if (!ruleResult || ruleResult instanceof Error || !ruleResult.content) { - throw new RuleNotFoundError(assetType); - } - - // 4. Resolve template commands for the transfer action - const templateCommands = await this.#resolveTemplateCommands( - ruleResult as SuiClientTypes.Object<{ content: true }>, - PASActionType.TransferFunds, - ); - - // 5. 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. Execute each template command (approval) - for (const command of templateCommands) { - addMoveCallFromCommand(command, { - tx, - senderVault: tx.object(fromVaultId), - receiverVault: toVault, - rule: tx.object(ruleId), - request: transferRequest, - systemType: assetType, - }); - } - - // 10. Resolve the transfer request (consumes the request after all approvals) - resolveTransferFunds({ - package: this.#packageConfig.packageId, - arguments: [transferRequest, tx.object(ruleId)], - typeArguments: [assetType], - })(tx); - - // 11. Share the vault if it was just created - if (shouldShareVault) { - Vault.share({ - package: this.#packageConfig.packageId, - arguments: [toVault], - })(tx); - } - }; - }, - - /** - * 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); - - // 2. Fetch vault and rule - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [fromVaultId, ruleId], - include: { content: true }, - }); - - // 3. Find objects by ID - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - const ruleResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === ruleId, - ); - - if (!ruleResult || ruleResult instanceof Error || !ruleResult.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. Resolve template commands for the unlock action - const templateCommands = await this.#resolveTemplateCommands( - ruleResult as SuiClientTypes.Object<{ content: true }>, - PASActionType.UnlockFunds, - ); - - // 5. Create auth proof from transaction sender - const auth = Vault.newAuth({ - package: this.#packageConfig.packageId, - })(tx); - - // 6. 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); - - // 7. Execute each template command (approval) - for (const command of templateCommands) { - addMoveCallFromCommand(command, { - tx, - senderVault: tx.object(fromVaultId), - rule: tx.object(ruleId), - request: unlockRequest, - systemType: assetType, - }); - } - - // 8. Resolve the unlock request (consumes the request after all approvals) - return resolveUnlockFunds({ - package: this.#packageConfig.packageId, - arguments: [unlockRequest, tx.object(ruleId)], - typeArguments: [assetType], - })(tx); - }; - }, - - /** - * 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); - - return resolveUnrestricted({ - package: this.#packageConfig.packageId, - arguments: [unlockRequest, this.#packageConfig.namespaceId], - typeArguments: [assetType], - })(tx); - }; - }, - }; } 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/derivation.ts b/sdk/pas/src/derivation.ts index 415121f..80aeec9 100644 --- a/sdk/pas/src/derivation.ts +++ b/sdk/pas/src/derivation.ts @@ -64,7 +64,7 @@ export function deriveRuleAddress(assetType: string, packageConfig: PASPackageCo * @param packageConfig - PAS package configuration * @returns The derived templates object ID */ -export function deriveTemplatesObjectAddress(packageConfig: PASPackageConfig): string { +export function deriveTemplateRegistryAddress(packageConfig: PASPackageConfig): string { const { packageId, namespaceId } = packageConfig; // The type tag is the TemplateKey type from the PAS package @@ -84,7 +84,7 @@ export function deriveTemplatesObjectAddress(packageConfig: PASPackageConfig): s * @param approvalTypeName - The fully qualified approval type name (e.g., "0x123::demo_usd::TransferApproval") * @returns The derived dynamic field object ID */ -export function deriveTemplateDFAddress(templatesId: string, approvalTypeName: string): string { +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(); 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 037f2ed..eb9cb1d 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 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 } from './bcs.js'; @@ -60,16 +61,16 @@ export function getRequiredApprovals( * @param templateDF - The Template DF object fetched with content * @returns The parsed Command, or undefined if parsing fails */ -export function getCommandFromTemplateDF( - templateDF: SuiClientTypes.Object<{ content: true }>, +export function getCommandFromTemplate( + template: SuiClientTypes.Object<{ content: true }>, ): ReturnType { - const df = Field(TypeName, Command).parse(templateDF.content); + 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}`); @@ -77,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', @@ -126,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': @@ -162,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( @@ -178,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 6178389..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 @@ -15,7 +15,6 @@ use pas::templates::Templates; use pas::transfer_funds::TransferFunds; use ptb::ptb; use std::type_name; -use sui::accumulator::AccumulatorRoot; use sui::balance::Balance; use sui::clock::Clock; use sui::coin::TreasuryCap; @@ -105,7 +104,7 @@ public fun use_v2(rule: &mut Rule, templates: &mut Templates, faucet: type_name::with_defining_ids().address_string().to_string(), "demo_usd", "approve_transfer_v2", - vector[ptb::ext_input("pas:request"), ptb::object_by_id(@0xacc.to_id())], + vector[ptb::ext_input("pas:request"), ptb::object_by_id(object::id(faucet))], vector[], ); @@ -125,10 +124,7 @@ public fun approve_transfer(request: &mut Request>, _clock: } /// V2 function allows all transfers, besides transferring to 0x2. -public fun approve_transfer_v2( - request: &mut Request>, - _acc: &AccumulatorRoot, -) { +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 4504533..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,18 +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 templatesId = this.toolbox.client.pas.deriveTemplatesAddress(); + 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(templatesId), + transaction.object(templateRegistryId), transaction.object(faucetId), ], }); @@ -65,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/setup.ts b/sdk/pas/test/e2e/setup.ts index 743705e..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 = {}; @@ -207,39 +208,19 @@ export async function setupToolbox() { // 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, - 'call', - '--package', - pasPackageId, - '--module', - 'namespace', - '--function', - 'setup', - '--args', - namespaceId, - upgradeCapId, - '--json', - ]); - // Create the Templates object (required for template-based resolution) await execSuiTools([ 'sui', 'client', '--client.config', configPath, - 'call', - '--package', - pasPackageId, - '--module', - 'templates', - '--function', - 'setup', - '--args', - namespaceId, + 'ptb', + '--move-call', + `${pasPackageId}::namespace::setup`, + `@${namespaceId} @${upgradeCapId}`, + '--move-call', + `${pasPackageId}::templates::setup`, + `@${namespaceId}`, '--json', ]); @@ -357,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