From c3386b55143d46cd2697ade786f6562944051d39 Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 17:45:33 +0200 Subject: [PATCH 1/8] SDK built with intents now --- .../testing/demo_usd/sources/demo_usd.move | 8 +- sdk/example-app/package.json | 2 +- sdk/package.json | 8 +- sdk/pas/package.json | 4 +- sdk/pas/src/client.ts | 419 ++------- sdk/pas/src/intents.ts | 800 ++++++++++++++++++ sdk/pas/src/resolution.ts | 169 ++++ 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 | 15 + sdk/pas/test/e2e/e2e.isolated.test.ts | 225 ++++- sdk/pas/test/e2e/setup.ts | 55 +- sdk/pnpm-lock.yaml | 48 +- 13 files changed, 1377 insertions(+), 419 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/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/client.ts b/sdk/pas/src/client.ts index 643d9d4..59c9922 100644 --- a/sdk/pas/src/client.ts +++ b/sdk/pas/src/client.ts @@ -1,7 +1,7 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import type { ClientWithCoreApi } from '@mysten/sui/client'; import { Transaction } from '@mysten/sui/transactions'; import { @@ -9,11 +9,6 @@ import { 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, @@ -21,13 +16,13 @@ import { deriveTemplatesObjectAddress, 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({ @@ -149,332 +144,84 @@ export class PASClient { return deriveTemplateDFAddress(this.deriveTemplatesAddress(), 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 - */ - 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 call() { + const cfg = this.#packageConfig; + return { + createVault: (owner: string) => { + return (tx: Transaction) => { + return Vault.create({ + package: cfg.packageId, + arguments: [cfg.namespaceId, owner], + })(tx); + }; + }, + createAndShareVault: (owner: string) => { + return (tx: Transaction) => { + return Vault.createAndShare({ + package: cfg.packageId, + arguments: [cfg.namespaceId, owner], + })(tx); + }; + }, + }; } /** - * Methods that create transactions without executing them + * 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. */ - 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); - }; - }, - }; + get tx() { + const cfg = this.#packageConfig; + 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(cfg), + + /** + * 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(cfg), + + /** + * 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(cfg), + + /** + * 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(cfg), + }; + } } diff --git a/sdk/pas/src/intents.ts b/sdk/pas/src/intents.ts new file mode 100644 index 0000000..198bc4e --- /dev/null +++ b/sdk/pas/src/intents.ts @@ -0,0 +1,800 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { bcs } from '@mysten/sui/bcs'; +import type { 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, + deriveTemplateDFAddress, + deriveTemplatesObjectAddress, + deriveVaultAddress, +} from './derivation.js'; +import { PASClientError, RuleNotFoundError, VaultNotFoundError } from './error.js'; +import { + buildMoveCallCommandFromTemplate, + getCommandFromTemplateDF, + getRequiredApprovals, + PASActionType, +} from './resolution.js'; +import type { PASPackageConfig } from './types.js'; + +export const PAS_INTENT_NAME = 'PAS'; + +// --------------------------------------------------------------------------- +// Intent data types +// --------------------------------------------------------------------------- + +interface TransferFundsIntentData { + action: 'transferFunds'; + from: string; + to: string; + amount: string; + assetType: string; + packageConfig: PASPackageConfig; +} + +interface UnlockFundsIntentData { + action: 'unlockFunds'; + from: string; + amount: string; + assetType: string; + packageConfig: PASPackageConfig; +} + +interface UnlockUnrestrictedFundsIntentData { + action: 'unlockUnrestrictedFunds'; + from: string; + amount: string; + assetType: string; + packageConfig: PASPackageConfig; +} + +interface VaultForAddressIntentData { + action: 'vaultForAddress'; + owner: string; + packageConfig: PASPackageConfig; +} + +export type PASIntentData = + | TransferFundsIntentData + | UnlockFundsIntentData + | UnlockUnrestrictedFundsIntentData + | VaultForAddressIntentData; + +// --------------------------------------------------------------------------- +// Intent creator helpers (called from PASClient.tx.*) +// --------------------------------------------------------------------------- + +/** + * 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, + 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, + 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, + packageConfig, + }); +} + +export function vaultForAddressIntent( + packageConfig: PASPackageConfig, +): (owner: string) => (tx: Transaction) => TransactionResult { + return (owner: string) => + createPASIntent({ action: 'vaultForAddress', owner, 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; cfg: PASPackageConfig }; + +/** 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 templateDFs: Map; + /** Pre-parsed template lookup: ruleId:actionType -> approval type names. */ + readonly templateLookup: Map; + /** Vault existence / creation tracking. */ + readonly vaults: Map; + + readonly #txData: TransactionDataBuilder; + readonly #inputCache = new Map(); + readonly #templateCommandsCache = new Map[]>(); + + constructor( + txData: TransactionDataBuilder, + objects: Map, + templateDFs: Map, + templateLookup: Map, + vaults: Map, + ) { + this.#txData = txData; + this.objects = objects; + this.templateDFs = templateDFs; + this.templateLookup = templateLookup; + this.vaults = vaults; + } + + // -- Input helpers (deduplicated) ---------------------------------------- + + addObjectInput(objectId: string): Argument { + let arg = this.#inputCache.get(objectId); + if (!arg) { + arg = this.#txData.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.#txData.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.#txData.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 `appendVaultShares()`. + * + * @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, + cfg: PASPackageConfig, + commands: Command[], + baseIdx: number, + ): Argument { + const state = this.vaults.get(vaultId); + + if (state?.kind === 'existing') { + return this.addObjectInput(vaultId); + } + if (state?.kind === 'created') { + return { $kind: 'Result', Result: state.resultIndex }; + } + + const absoluteIndex = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: cfg.packageId, + module: 'vault', + function: 'create', + arguments: [ + this.addObjectInput(cfg.namespaceId), + this.addPureInput(`address:${owner}`, Inputs.Pure(bcs.Address.serialize(owner))), + ], + }), + ); + + this.vaults.set(vaultId, { kind: 'created', resultIndex: absoluteIndex, cfg }); + return { $kind: 'Result', Result: absoluteIndex }; + } + + // -- Template resolution (synchronous, all data pre-fetched) ------------- + + resolveTemplateCommands(ruleObjectId: string, actionType: PASActionType, cfg: PASPackageConfig) { + const cacheKey = `${ruleObjectId}:${actionType}`; + const cached = this.#templateCommandsCache.get(cacheKey); + if (cached) return cached; + + const approvalTypeNames = this.templateLookup.get(cacheKey); + if (!approvalTypeNames) { + throw new PASClientError( + `No required approvals found for action "${actionType}". The issuer has not configured this action.`, + ); + } + + const templatesId = deriveTemplatesObjectAddress(cfg); + const commands = approvalTypeNames.map((tn) => { + const dfId = deriveTemplateDFAddress(templatesId, tn); + const df = this.templateDFs.get(dfId); + if (!df) { + throw new PASClientError( + `Template not found for approval type "${tn}". The issuer has not set up the template command.`, + ); + } + return getCommandFromTemplateDF(df); + }); + + this.#templateCommandsCache.set(cacheKey, commands); + return commands; + } + + // -- Command replacement -------------------------------------------------- + // + // `replaceCommand(index, commands, resultIndex)` splices the replacement + // commands in and automatically remaps all external argument references: + // - References past `index` are shifted by (commands.length - 1) + // - References to `index` itself are remapped to `resultIndex` + // So we just need to tell it which command produces the intent's output. + + /** + * 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.#txData.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.#txData.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.#txData.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, packageConfig: cfg } = data; + const fromVaultId = deriveVaultAddress(from, cfg); + const toVaultId = deriveVaultAddress(to, cfg); + + if (!this.objects.get(fromVaultId) && !this.vaults.has(fromVaultId)) { + throw new VaultNotFoundError(from); + } + + const ruleId = deriveRuleAddress(assetType, cfg); + const ruleObject = this.getObjectOrThrow(ruleId, () => new RuleNotFoundError(assetType)); + const templateCmds = this.resolveTemplateCommands(ruleObject.objectId, PASActionType.TransferFunds, cfg); + + const commands: Command[] = []; + const toVaultArg = this.resolveVaultArg(toVaultId, to, cfg, commands, baseIdx); + const fromVaultArg = this.resolveVaultArg(fromVaultId, from, cfg, commands, baseIdx); + const ruleArg = this.addObjectInput(ruleId); + + // vault::new_auth + const authIdx = baseIdx + commands.length; + commands.push(TransactionCommands.MoveCall({ package: cfg.packageId, module: 'vault', function: 'new_auth' })); + + // vault::transfer_funds + const requestIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: cfg.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: cfg.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, packageConfig: cfg } = data; + const fromVaultId = deriveVaultAddress(from, cfg); + const ruleId = deriveRuleAddress(assetType, cfg); + + if (!this.objects.get(fromVaultId) && !this.vaults.has(fromVaultId)) { + throw new VaultNotFoundError(from); + } + + 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 commands: Command[] = []; + const fromVaultArg = this.resolveVaultArg(fromVaultId, from, cfg, commands, baseIdx); + const ruleArg = isRestricted ? this.addObjectInput(ruleId) : undefined; + + // vault::new_auth + const authIdx = baseIdx + commands.length; + commands.push(TransactionCommands.MoveCall({ package: cfg.packageId, module: 'vault', function: 'new_auth' })); + + // vault::unlock_funds + const requestIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: cfg.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, cfg); + 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: cfg.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: cfg.packageId, + module: 'unlock_funds', + function: 'resolve_unrestricted', + arguments: [requestArg, this.addObjectInput(cfg.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. + */ + appendVaultShares() { + for (const [, state] of this.vaults) { + if (state.kind !== 'created') continue; + this.#txData.commands.push( + TransactionCommands.MoveCall({ + package: state.cfg.packageId, + module: 'vault', + function: 'share', + arguments: [{ $kind: 'Result', Result: state.resultIndex }], + }), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Data collection + fetching (pre-resolution) +// --------------------------------------------------------------------------- + +interface PreFetchRequirements { + objectIds: Set; + vaultRequests: Map; + intentDataList: PASIntentData[]; +} + +/** Scans commands for PAS intents and collects the object IDs we need to fetch. */ +function collectPreFetchRequirements(commands: readonly Command[]): PreFetchRequirements | null { + const objectIds = new Set(); + const vaultRequests = new Map(); + const intentDataList: PASIntentData[] = []; + + 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; + intentDataList.push(data); + const cfg = data.packageConfig; + + 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, cfg }); + vaultRequests.set(toId, { owner: data.to, cfg }); + 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, cfg }); + break; + } + case 'vaultForAddress': { + const id = deriveVaultAddress(data.owner, cfg); + objectIds.add(id); + vaultRequests.set(id, { owner: data.owner, cfg }); + break; + } + } + } + + return intentDataList.length > 0 ? { objectIds, vaultRequests, intentDataList } : null; +} + +interface FetchedState { + objects: Map; + templateDFs: Map; + templateLookup: Map; + vaults: Map; +} + +async function fetchOnChainState( + client: NonNullable[1]['client']>, + objectIds: Set, + vaultRequests: Map, + intentDataList: PASIntentData[], +): 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 (let i = 0; i < allIds.length; i++) { + const obj = fetched[i]; + objects.set(allIds[i], obj && !(obj instanceof Error) && obj.content ? obj as SuiObject : 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 templateLookup = new Map(); + const templateDFIds: 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, data.packageConfig); + 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 = deriveTemplatesObjectAddress(data.packageConfig); + templateLookup.set(key, approvalTypeNames); + templateDFIds.push(...approvalTypeNames.map((tn) => deriveTemplateDFAddress(templatesId, tn))); + } + + // 4. Batch-fetch all template DFs + const templateDFs = new Map(); + if (templateDFIds.length > 0) { + const { objects: dfObjects } = await client.core.getObjects({ + objectIds: templateDFIds, + include: { content: true }, + }); + for (let i = 0; i < templateDFIds.length; i++) { + const obj = dfObjects[i]; + if (obj && !(obj instanceof Error) && obj.content) { + templateDFs.set(templateDFIds[i], obj as SuiObject); + } + } + } + + return { objects, templateDFs, templateLookup, vaults }; +} + +// --------------------------------------------------------------------------- +// Shared resolver (TransactionPlugin) +// --------------------------------------------------------------------------- + +export 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 = collectPreFetchRequirements(transactionData.commands); + if (!requirements) return next(); + + const { objectIds, vaultRequests, intentDataList } = requirements; + const state = await fetchOnChainState(client, objectIds, vaultRequests, intentDataList); + const ctx = new Resolver( + transactionData, + state.objects, + state.templateDFs, + state.templateLookup, + state.vaults, + ); + + // 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, data.packageConfig); + const commands: Command[] = []; + const vaultArg = ctx.resolveVaultArg(vaultId, data.owner, data.packageConfig, commands, 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.appendVaultShares(); + return next(); +}; diff --git a/sdk/pas/src/resolution.ts b/sdk/pas/src/resolution.ts index 037f2ed..ce2936c 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { SuiClientTypes } from '@mysten/sui/client'; +import { Inputs, TransactionCommands } from '@mysten/sui/transactions'; +import type { + Argument, + CallArg, + Command as SdkCommand, +} from '@mysten/sui/transactions'; import { type Transaction, type TransactionObjectArgument } from '@mysten/sui/transactions'; import { normalizeStructTag } from '@mysten/sui/utils'; @@ -224,3 +230,166 @@ function resolvePasRequest(context: CommandBuildContext, value: string) { throw new PASClientError(`Unknown pas request: ${value}`); } } + +// --------------------------------------------------------------------------- +// Raw Command builder (for use with TransactionDataBuilder / replaceCommand) +// --------------------------------------------------------------------------- + +/** + * Arguments for building a raw MoveCall Command from a template, without + * requiring a Transaction object. Used by the intent resolver which works + * directly with TransactionDataBuilder. + */ +export 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; +} + +/** + * Builds a raw `Command` (TransactionCommands.MoveCall) from a parsed template + * command. This is the low-level equivalent of `addMoveCallFromCommand` that + * works without a `Transaction` object, suitable for use with + * `transactionData.replaceCommand()`. + * + * @param command - The parsed MoveCall from a template DF + * @param args - The resolved arguments and addInput helper + * @returns A raw Command object ready for `replaceCommand` + */ +export function buildMoveCallCommandFromTemplate( + command: ReturnType, + 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({ $kind: 'GasCoin', GasCoin: true }); + else if (arg.NestedResult) + resolvedArgs.push({ + $kind: 'NestedResult', + NestedResult: [arg.NestedResult[0], arg.NestedResult[1]], + }); + else if (arg.Result) resolvedArgs.push({ $kind: 'Result', Result: arg.Result }); + else if (arg.Input) { + 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( + 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( + 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( + 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': + const [kind, value] = arg.Input.Object.Ext.split(':'); + + switch (kind) { + case OBJECT_BY_ID_EXT: + case RECEIVING_BY_ID_EXT: + resolvedArgs.push( + args.addInput('object', { + $kind: 'UnresolvedObject', + UnresolvedObject: { objectId: value }, + } as CallArg), + ); + break; + case OBJECT_BY_TYPE_EXT: + throw new PASClientError( + `There are no supported object by type arguments in this client.`, + ); + default: + throw new PASClientError(`Unknown external object argument: ${kind}`); + } + break; + default: + throw new PASClientError( + `Not supported object argument: ${JSON.stringify(arg.Input.Object)}`, + ); + } + } else if (arg.Input.Ext) { + resolvedArgs.push(resolveRawPasRequest(args, arg.Input.Ext)); + } else { + throw new PASClientError(`Unsupported input kind: ${arg.Input.$kind}`); + } + } + } + + const typeArgs: string[] = []; + for (const typeArg of command.type_arguments) + typeArgs.push(normalizeStructTag(typeArg).toString()); + + 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 TransactionCommands.MoveCall({ + package: command.package_id, + module: command.module_name, + function: command.function, + arguments: resolvedArgs, + typeArguments: typeArgs.length > 0 ? typeArgs : [], + }); +} + +function resolveRawPasRequest(args: RawCommandBuildArgs, value: string): Argument { + switch (value) { + case 'pas:request': + if (!args.request) throw new PASClientError(`Request is not set in the context.`); + return args.request; + case 'pas:rule': + if (!args.rule) throw new PASClientError(`Rule is not set in the context.`); + return args.rule; + case 'pas:sender_vault': + if (!args.senderVault) throw new PASClientError(`Sender vault is not set in the context.`); + return args.senderVault; + case 'pas:receiver_vault': + if (!args.receiverVault) + throw new PASClientError(`Receiver vault is not set in the context.`); + 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..1997ae2 100644 --- a/sdk/pas/test/e2e/demoUsd.ts +++ b/sdk/pas/test/e2e/demoUsd.ts @@ -65,6 +65,21 @@ export class DemoUsdTestHelpers { await this.toolbox.executeTransaction(transaction); } + async upgradeToV2() { + const ruleId = this.toolbox.client.pas.deriveRuleAddress(this.demoUsdAssetType); + const templatesId = this.toolbox.client.pas.deriveTemplatesAddress(); + 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(templatesId), 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..87c415d 100644 --- a/sdk/pas/test/e2e/e2e.isolated.test.ts +++ b/sdk/pas/test/e2e/e2e.isolated.test.ts @@ -1,19 +1,13 @@ 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'; - -describe('e2e tests with isolated PAS Package (each test runs in its own PAS package)', () => { - let toolbox: TestToolbox; - - // Each execution should use its own runner to avoid shared state of PAS package. - beforeEach(async () => { - toolbox = await setupToolbox(); - }); +import { setupToolbox, simulateFailingTransaction } from './setup.ts'; +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(); @@ -81,6 +75,7 @@ describe('e2e tests with isolated PAS Package (each test runs in its own PAS pac }); 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(); @@ -125,6 +120,7 @@ describe('e2e tests with isolated PAS Package (each test runs in its own PAS pac }); 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(); @@ -163,7 +159,151 @@ describe('e2e tests with isolated PAS Package (each test runs in its own PAS pac expect(responseAfter.object).toBeDefined(); }); + 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); + }); + + 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.', + ); + }); + + 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', + ); + }); + 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(); @@ -196,4 +336,69 @@ describe('e2e tests with isolated PAS Package (each test runs in its own PAS pac 'InsufficientFundsForWithdraw', ); }); + + 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); + }); + + 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..fbd51bd 100644 --- a/sdk/pas/test/e2e/setup.ts +++ b/sdk/pas/test/e2e/setup.ts @@ -153,9 +153,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 +204,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 +334,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 From 7d2406e48361d3cbb374b06cfeb58ff08ab6fec0 Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 17:47:29 +0200 Subject: [PATCH 2/8] fmt --- sdk/pas/src/intents.ts | 84 ++- sdk/pas/src/resolution.ts | 15 +- sdk/pas/test/e2e/demoUsd.ts | 4 +- sdk/pas/test/e2e/e2e.isolated.test.ts | 749 +++++++++++++------------- 4 files changed, 443 insertions(+), 409 deletions(-) diff --git a/sdk/pas/src/intents.ts b/sdk/pas/src/intents.ts index 198bc4e..74ebbde 100644 --- a/sdk/pas/src/intents.ts +++ b/sdk/pas/src/intents.ts @@ -99,8 +99,12 @@ function createPASIntent(data: PASIntentData): (tx: Transaction) => TransactionR export function transferFundsIntent( packageConfig: PASPackageConfig, -): (options: { from: string; to: string; amount: number | bigint; assetType: string }) => - (tx: Transaction) => TransactionResult { +): (options: { + from: string; + to: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { return ({ from, to, amount, assetType }) => createPASIntent({ action: 'transferFunds', @@ -114,8 +118,11 @@ export function transferFundsIntent( export function unlockFundsIntent( packageConfig: PASPackageConfig, -): (options: { from: string; amount: number | bigint; assetType: string }) => - (tx: Transaction) => TransactionResult { +): (options: { + from: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { return ({ from, amount, assetType }) => createPASIntent({ action: 'unlockFunds', @@ -128,8 +135,11 @@ export function unlockFundsIntent( export function unlockUnrestrictedFundsIntent( packageConfig: PASPackageConfig, -): (options: { from: string; amount: number | bigint; assetType: string }) => - (tx: Transaction) => TransactionResult { +): (options: { + from: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { return ({ from, amount, assetType }) => createPASIntent({ action: 'unlockUnrestrictedFunds', @@ -143,8 +153,7 @@ export function unlockUnrestrictedFundsIntent( export function vaultForAddressIntent( packageConfig: PASPackageConfig, ): (owner: string) => (tx: Transaction) => TransactionResult { - return (owner: string) => - createPASIntent({ action: 'vaultForAddress', owner, packageConfig }); + return (owner: string) => createPASIntent({ action: 'vaultForAddress', owner, packageConfig }); } // --------------------------------------------------------------------------- @@ -204,7 +213,10 @@ class Resolver { readonly #txData: TransactionDataBuilder; readonly #inputCache = new Map(); - readonly #templateCommandsCache = new Map[]>(); + readonly #templateCommandsCache = new Map< + string, + ReturnType[] + >(); constructor( txData: TransactionDataBuilder, @@ -350,10 +362,7 @@ class Resolver { * intent's output value. */ replaceIntent(actualIdx: number, commands: Command[], resultOffset: number) { - this.#txData.replaceCommand( - actualIdx, commands, - { Result: actualIdx + resultOffset }, - ); + this.#txData.replaceCommand(actualIdx, commands, { Result: actualIdx + resultOffset }); } /** @@ -374,10 +383,7 @@ class Resolver { * references are remapped to the first command's Result (the new vault). */ replaceIntentWithCreatedVault(actualIdx: number, commands: Command[]) { - this.#txData.replaceCommand( - actualIdx, commands, - { Result: actualIdx }, - ); + this.#txData.replaceCommand(actualIdx, commands, { Result: actualIdx }); } // -- Per-action builders -------------------------------------------------- @@ -407,7 +413,11 @@ class Resolver { const ruleId = deriveRuleAddress(assetType, cfg); const ruleObject = this.getObjectOrThrow(ruleId, () => new RuleNotFoundError(assetType)); - const templateCmds = this.resolveTemplateCommands(ruleObject.objectId, PASActionType.TransferFunds, cfg); + const templateCmds = this.resolveTemplateCommands( + ruleObject.objectId, + PASActionType.TransferFunds, + cfg, + ); const commands: Command[] = []; const toVaultArg = this.resolveVaultArg(toVaultId, to, cfg, commands, baseIdx); @@ -416,7 +426,13 @@ class Resolver { // vault::new_auth const authIdx = baseIdx + commands.length; - commands.push(TransactionCommands.MoveCall({ package: cfg.packageId, module: 'vault', function: 'new_auth' })); + commands.push( + TransactionCommands.MoveCall({ + package: cfg.packageId, + module: 'vault', + function: 'new_auth', + }), + ); // vault::transfer_funds const requestIdx = baseIdx + commands.length; @@ -470,7 +486,10 @@ class Resolver { * 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 { + buildUnlockFunds( + data: UnlockFundsIntentData | UnlockUnrestrictedFundsIntentData, + baseIdx: number, + ): BuildResult { const { from, assetType, amount, packageConfig: cfg } = data; const fromVaultId = deriveVaultAddress(from, cfg); const ruleId = deriveRuleAddress(assetType, cfg); @@ -487,8 +506,8 @@ class Resolver { () => 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.`, + `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 { @@ -505,7 +524,13 @@ class Resolver { // vault::new_auth const authIdx = baseIdx + commands.length; - commands.push(TransactionCommands.MoveCall({ package: cfg.packageId, module: 'vault', function: 'new_auth' })); + commands.push( + TransactionCommands.MoveCall({ + package: cfg.packageId, + module: 'vault', + function: 'new_auth', + }), + ); // vault::unlock_funds const requestIdx = baseIdx + commands.length; @@ -667,7 +692,10 @@ async function fetchOnChainState( const objects = new Map(); for (let i = 0; i < allIds.length; i++) { const obj = fetched[i]; - objects.set(allIds[i], obj && !(obj instanceof Error) && obj.content ? obj as SuiObject : null); + objects.set( + allIds[i], + obj && !(obj instanceof Error) && obj.content ? (obj as SuiObject) : null, + ); } // 2. Build initial vault map (existing vs needs-creation) @@ -768,7 +796,13 @@ export const resolvePASIntents: TransactionPlugin = async (transactionData, buil if (data.action === 'vaultForAddress') { const vaultId = deriveVaultAddress(data.owner, data.packageConfig); const commands: Command[] = []; - const vaultArg = ctx.resolveVaultArg(vaultId, data.owner, data.packageConfig, commands, index); + const vaultArg = ctx.resolveVaultArg( + vaultId, + data.owner, + data.packageConfig, + commands, + index, + ); if (commands.length === 0) { ctx.replaceIntentWithExistingVault(index, vaultArg); diff --git a/sdk/pas/src/resolution.ts b/sdk/pas/src/resolution.ts index ce2936c..0714eee 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { SuiClientTypes } from '@mysten/sui/client'; -import { Inputs, TransactionCommands } from '@mysten/sui/transactions'; -import type { - Argument, - CallArg, - Command as SdkCommand, +import { + Inputs, + TransactionCommands, + type Transaction, + type TransactionObjectArgument, } from '@mysten/sui/transactions'; -import { type Transaction, type TransactionObjectArgument } 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'; @@ -303,8 +303,7 @@ export function buildMoveCallCommandFromTemplate( 'object', Inputs.SharedObjectRef({ objectId: arg.Input.Object.SharedObject.object_id, - initialSharedVersion: - arg.Input.Object.SharedObject.initial_shared_version, + initialSharedVersion: arg.Input.Object.SharedObject.initial_shared_version, mutable: arg.Input.Object.SharedObject.is_mutable, }), ), diff --git a/sdk/pas/test/e2e/demoUsd.ts b/sdk/pas/test/e2e/demoUsd.ts index 1997ae2..9a168bc 100644 --- a/sdk/pas/test/e2e/demoUsd.ts +++ b/sdk/pas/test/e2e/demoUsd.ts @@ -68,9 +68,7 @@ export class DemoUsdTestHelpers { async upgradeToV2() { const ruleId = this.toolbox.client.pas.deriveRuleAddress(this.demoUsdAssetType); const templatesId = this.toolbox.client.pas.deriveTemplatesAddress(); - const faucetId = this.pub.createdObjects.find((o) => - o.type.endsWith('demo_usd::Faucet'), - )!.id; + const faucetId = this.pub.createdObjects.find((o) => o.type.endsWith('demo_usd::Faucet'))!.id; const tx = new Transaction(); tx.moveCall({ diff --git a/sdk/pas/test/e2e/e2e.isolated.test.ts b/sdk/pas/test/e2e/e2e.isolated.test.ts index 87c415d..daa5331 100644 --- a/sdk/pas/test/e2e/e2e.isolated.test.ts +++ b/sdk/pas/test/e2e/e2e.isolated.test.ts @@ -5,400 +5,403 @@ import { describe, expect, it } from 'vitest'; import { DemoUsdTestHelpers } from './demoUsd.ts'; import { setupToolbox, simulateFailingTransaction } from './setup.ts'; -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], +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); }); - await toolbox.executeTransaction(unlockTx); + 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(); - const { balance: vaultBalanceAfterUnlock } = await toolbox.getBalance(vaultId, suiTypeName); - expect(Number(vaultBalanceAfterUnlock.balance)).toBe(0); - }); + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); - 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(); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + const toVaultId = toolbox.client.pas.deriveVaultAddress(to); - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); - const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); - const toVaultId = toolbox.client.pas.deriveVaultAddress(to); + await demoUsd.mintFromFaucetInto(100, fromVaultId); - await toolbox.createVaultForAddress(from); - await toolbox.createVaultForAddress(to); + const [{ balance: fromBalanceBefore }, { balance: toBalanceBefore }] = await Promise.all([ + toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), + ]); - await demoUsd.mintFromFaucetInto(100, fromVaultId); + expect(Number(fromBalanceBefore.balance)).toBe(100 * 1_000_000); + expect(Number(toBalanceBefore.balance)).toBe(0); - const [{ balance: fromBalanceBefore }, { balance: toBalanceBefore }] = await Promise.all([ - toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), - toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), - ]); + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 100 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); - expect(Number(fromBalanceBefore.balance)).toBe(100 * 1_000_000); - expect(Number(toBalanceBefore.balance)).toBe(0); + await toolbox.executeTransaction(tx); - const tx = new Transaction(); - tx.add( - toolbox.client.pas.tx.transferFunds({ - from, - to, - amount: 100 * 1_000_000, - assetType: demoUsd.demoUsdAssetType, - }), - ); + const [{ balance: fromBalanceAfter }, { balance: toBalanceAfter }] = await Promise.all([ + toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), + ]); + + expect(Number(fromBalanceAfter.balance)).toBe(0); + expect(Number(toBalanceAfter.balance)).toBe(100 * 1_000_000); + }); - await toolbox.executeTransaction(tx); + 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(); - 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 demoUsd.mintFromFaucetInto(100, fromVaultId); + await toolbox.createVaultForAddress(from); - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); + await expect( + toolbox.client.core.getObject({ + objectId: toVaultId, + }), + ).rejects.toThrowError('not found'); - const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); - const toVaultId = toolbox.client.pas.deriveVaultAddress(to); + const transaction = new Transaction(); + transaction.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); - await demoUsd.mintFromFaucetInto(100, fromVaultId); - await toolbox.createVaultForAddress(from); + await toolbox.executeTransaction(transaction); - await expect( - toolbox.client.core.getObject({ + // Object should now exist after the first transfer. + const responseAfter = await 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, + }); + + expect(responseAfter.object).toBeDefined(); + }); + + 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); }); - expect(responseAfter.object).toBeDefined(); - }); - - 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); - }); - - 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.', - ); - }); - - 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', - ); - }); - - 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, - }, + 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.', + ); }); - expect(resp.FailedTransaction).toBeDefined(); - expect(resp.FailedTransaction!.effects.status.error!.message).toEqual( - 'InsufficientFundsForWithdraw', - ); - }); - - 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); - }); - - 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.', - ); - }); -}); + 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', + ); + }); + + 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', + ); + }); + + 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); + }); + + 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.', + ); + }); + }, +); From da4e6a7de0bf692e499563d536c5efebd97018db Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 20:03:51 +0200 Subject: [PATCH 3/8] Cleanups + more complex tests --- sdk/pas/src/bcs.ts | 20 --- sdk/pas/src/error.ts | 7 - sdk/pas/src/intents.ts | 16 +-- sdk/pas/src/resolution.ts | 178 ++------------------------ sdk/pas/test/e2e/demoUsd.ts | 16 ++- sdk/pas/test/e2e/e2e.isolated.test.ts | 89 ++++++++++++- sdk/pas/test/e2e/setup.ts | 18 ++- 7 files changed, 129 insertions(+), 215 deletions(-) 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/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 index 74ebbde..9708e26 100644 --- a/sdk/pas/src/intents.ts +++ b/sdk/pas/src/intents.ts @@ -20,7 +20,7 @@ import { deriveTemplatesObjectAddress, deriveVaultAddress, } from './derivation.js'; -import { PASClientError, RuleNotFoundError, VaultNotFoundError } from './error.js'; +import { PASClientError, RuleNotFoundError } from './error.js'; import { buildMoveCallCommandFromTemplate, getCommandFromTemplateDF, @@ -29,7 +29,7 @@ import { } from './resolution.js'; import type { PASPackageConfig } from './types.js'; -export const PAS_INTENT_NAME = 'PAS'; +const PAS_INTENT_NAME = 'PAS'; // --------------------------------------------------------------------------- // Intent data types @@ -66,7 +66,7 @@ interface VaultForAddressIntentData { packageConfig: PASPackageConfig; } -export type PASIntentData = +type PASIntentData = | TransferFundsIntentData | UnlockFundsIntentData | UnlockUnrestrictedFundsIntentData @@ -407,10 +407,6 @@ class Resolver { const fromVaultId = deriveVaultAddress(from, cfg); const toVaultId = deriveVaultAddress(to, cfg); - if (!this.objects.get(fromVaultId) && !this.vaults.has(fromVaultId)) { - throw new VaultNotFoundError(from); - } - const ruleId = deriveRuleAddress(assetType, cfg); const ruleObject = this.getObjectOrThrow(ruleId, () => new RuleNotFoundError(assetType)); const templateCmds = this.resolveTemplateCommands( @@ -494,10 +490,6 @@ class Resolver { const fromVaultId = deriveVaultAddress(from, cfg); const ruleId = deriveRuleAddress(assetType, cfg); - if (!this.objects.get(fromVaultId) && !this.vaults.has(fromVaultId)) { - throw new VaultNotFoundError(from); - } - const isRestricted = data.action === 'unlockFunds'; if (isRestricted) { @@ -763,7 +755,7 @@ async function fetchOnChainState( // Shared resolver (TransactionPlugin) // --------------------------------------------------------------------------- -export const resolvePASIntents: TransactionPlugin = async (transactionData, buildOptions, next) => { +const resolvePASIntents: TransactionPlugin = async (transactionData, buildOptions, next) => { const client = buildOptions.client; if (!client) { throw new PASClientError( diff --git a/sdk/pas/src/resolution.ts b/sdk/pas/src/resolution.ts index 0714eee..14a2ad3 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -2,12 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { SuiClientTypes } from '@mysten/sui/client'; -import { - Inputs, - TransactionCommands, - 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'; @@ -75,7 +70,7 @@ export function getCommandFromTemplateDF( // 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}`); @@ -83,164 +78,15 @@ export function parseCommand([key, cmd]: ReturnType) { return MoveCall.parse(new Uint8Array(cmd as number[])); } -/** - * Context provided when building a PTB from a command - */ -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; - /** 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`. - * - * This function translates the Command structure into actual moveCall operations - * in the transaction, resolving placeholders like "sender_vault", "receiver_vault", etc. - * - * @param command - The parsed Command object - * @param context - The build context with required objects - * @returns The result of the moveCall - */ -export function addMoveCallFromCommand( - command: ReturnType, - context: CommandBuildContext, -) { - const { tx } = context; - - // Resolve arguments - const resolvedArgs: TransactionObjectArgument[] = []; - - 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.NestedResult) - resolvedArgs.push({ - $kind: 'NestedResult', - NestedResult: [arg.NestedResult[0], arg.NestedResult[1]], - }); - 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))); - 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, - }), - ); - 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, - }), - ); - 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, - }), - ); - break; - case 'Ext': - const [kind, value] = arg.Input.Object.Ext.split(':'); - - switch (kind) { - case OBJECT_BY_ID_EXT: - case RECEIVING_BY_ID_EXT: - resolvedArgs.push(tx.object(value)); - break; - case OBJECT_BY_TYPE_EXT: - throw new PASClientError( - `There are no supported object by type arguments in this client.`, - ); - default: - throw new PASClientError(`Unknown external object argument: ${kind}`); - } - break; - default: - throw new PASClientError( - `Not supported object argument: ${JSON.stringify(arg.Input.Object)}`, - ); - } - } else if (arg.Input.Ext) { - resolvedArgs.push(resolvePasRequest(context, 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}`, - 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) { - switch (value) { - case 'pas:request': - if (!context.request) throw new PASClientError(`Request is not set in the context.`); - return context.request; - case 'pas:rule': - if (!context.rule) throw new PASClientError(`Rule is not set in the context.`); - return context.rule; - case 'pas:sender_vault': - if (!context.senderVault) throw new PASClientError(`Sender vault is not set in the context.`); - return context.senderVault; - case 'pas:receiver_vault': - if (!context.receiverVault) - throw new PASClientError(`Receiver vault is not set in the context.`); - return context.receiverVault; - default: - throw new PASClientError(`Unknown pas request: ${value}`); - } -} - // --------------------------------------------------------------------------- -// Raw Command builder (for use with TransactionDataBuilder / replaceCommand) +// Command builder (for use with TransactionDataBuilder / replaceCommand) // --------------------------------------------------------------------------- /** - * Arguments for building a raw MoveCall Command from a template, without - * requiring a Transaction object. Used by the intent resolver which works - * directly with TransactionDataBuilder. + * Arguments for building a MoveCall Command from a template. + * Used by the intent resolver which works directly with TransactionDataBuilder. */ -export interface RawCommandBuildArgs { +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) */ @@ -256,14 +102,16 @@ export interface RawCommandBuildArgs { } /** - * Builds a raw `Command` (TransactionCommands.MoveCall) from a parsed template - * command. This is the low-level equivalent of `addMoveCallFromCommand` that - * works without a `Transaction` object, suitable for use with - * `transactionData.replaceCommand()`. + * Builds a `Command` (TransactionCommands.MoveCall) from a parsed template command, + * suitable for use with `transactionData.replaceCommand()`. + * + * 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 MoveCall from a template DF * @param args - The resolved arguments and addInput helper - * @returns A raw Command object ready for `replaceCommand` + * @returns A Command object ready for `replaceCommand` */ export function buildMoveCallCommandFromTemplate( command: ReturnType, diff --git a/sdk/pas/test/e2e/demoUsd.ts b/sdk/pas/test/e2e/demoUsd.ts index 9a168bc..6ac7647 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,7 +25,15 @@ 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; diff --git a/sdk/pas/test/e2e/e2e.isolated.test.ts b/sdk/pas/test/e2e/e2e.isolated.test.ts index daa5331..f4d8fc7 100644 --- a/sdk/pas/test/e2e/e2e.isolated.test.ts +++ b/sdk/pas/test/e2e/e2e.isolated.test.ts @@ -3,7 +3,19 @@ import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'; import { describe, expect, it } from 'vitest'; import { DemoUsdTestHelpers } from './demoUsd.ts'; -import { setupToolbox, simulateFailingTransaction } 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 (let i = 0; i < expected.length; i++) { + expect(Number(balances[i].balance.balance)).toBe(expected[i].amount * 1_000_000); + } +} describe.concurrent( 'e2e tests with isolated PAS Package (each test runs in its own PAS package)', @@ -372,6 +384,81 @@ describe.concurrent( expect(Number(balance.balance)).toBe(15_000 * 1_000_000); }); + 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 }, + ]); + }); + it('v2 approval rejects transfers to 0x2', async () => { const toolbox = await setupToolbox(); const demoUsd = new DemoUsdTestHelpers(toolbox); diff --git a/sdk/pas/test/e2e/setup.ts b/sdk/pas/test/e2e/setup.ts index fbd51bd..2fc5b1a 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!(); From 8a0733372414c186f4f2bc29fbe959400da9d9db Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 20:18:55 +0200 Subject: [PATCH 4/8] naming fixes --- sdk/pas/src/client.ts | 10 ++-- sdk/pas/src/derivation.ts | 4 +- sdk/pas/src/intents.ts | 84 +++++++++++++-------------- sdk/pas/src/resolution.ts | 8 +-- sdk/pas/test/e2e/demoUsd.ts | 8 +-- sdk/pas/test/e2e/e2e.isolated.test.ts | 4 +- 6 files changed, 56 insertions(+), 62 deletions(-) diff --git a/sdk/pas/src/client.ts b/sdk/pas/src/client.ts index 59c9922..4ac0bea 100644 --- a/sdk/pas/src/client.ts +++ b/sdk/pas/src/client.ts @@ -12,8 +12,8 @@ import { import * as Vault from './contracts/pas/vault.js'; import { deriveRuleAddress, - deriveTemplateDFAddress, - deriveTemplatesObjectAddress, + deriveTemplateAddress, + deriveTemplateRegistryAddress, deriveVaultAddress, } from './derivation.js'; import { PASClientError } from './error.js'; @@ -130,8 +130,8 @@ export class PASClient { * * @returns The derived templates object ID */ - deriveTemplatesAddress(): string { - return deriveTemplatesObjectAddress(this.#packageConfig); + deriveTemplateRegistryAddress(): string { + return deriveTemplateRegistryAddress(this.#packageConfig); } /** @@ -141,7 +141,7 @@ export class PASClient { * @returns The derived dynamic field object ID */ deriveTemplateAddress(approvalTypeName: string): string { - return deriveTemplateDFAddress(this.deriveTemplatesAddress(), approvalTypeName); + return deriveTemplateAddress(this.deriveTemplateRegistryAddress(), approvalTypeName); } get call() { 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/intents.ts b/sdk/pas/src/intents.ts index 9708e26..a955d6a 100644 --- a/sdk/pas/src/intents.ts +++ b/sdk/pas/src/intents.ts @@ -16,14 +16,14 @@ import { normalizeStructTag } from '@mysten/sui/utils'; import { deriveRuleAddress, - deriveTemplateDFAddress, - deriveTemplatesObjectAddress, + deriveTemplateAddress, + deriveTemplateRegistryAddress, deriveVaultAddress, } from './derivation.js'; import { PASClientError, RuleNotFoundError } from './error.js'; import { buildMoveCallCommandFromTemplate, - getCommandFromTemplateDF, + getCommandFromTemplate, getRequiredApprovals, PASActionType, } from './resolution.js'; @@ -205,30 +205,27 @@ class Resolver { /** Pre-fetched on-chain objects (vaults, rules). null = does not exist. */ readonly objects: Map; /** Pre-fetched template dynamic field objects. */ - readonly templateDFs: Map; + readonly templates: Map; /** Pre-parsed template lookup: ruleId:actionType -> approval type names. */ - readonly templateLookup: Map; + readonly templateApprovals: Map; /** Vault existence / creation tracking. */ readonly vaults: Map; readonly #txData: TransactionDataBuilder; readonly #inputCache = new Map(); - readonly #templateCommandsCache = new Map< - string, - ReturnType[] - >(); + readonly #templateCommandsCache = new Map[]>(); constructor( txData: TransactionDataBuilder, objects: Map, - templateDFs: Map, - templateLookup: Map, + templates: Map, + templateApprovals: Map, vaults: Map, ) { this.#txData = txData; this.objects = objects; - this.templateDFs = templateDFs; - this.templateLookup = templateLookup; + this.templates = templates; + this.templateApprovals = templateApprovals; this.vaults = vaults; } @@ -325,23 +322,23 @@ class Resolver { const cached = this.#templateCommandsCache.get(cacheKey); if (cached) return cached; - const approvalTypeNames = this.templateLookup.get(cacheKey); + 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 = deriveTemplatesObjectAddress(cfg); + const templatesId = deriveTemplateRegistryAddress(cfg); const commands = approvalTypeNames.map((tn) => { - const dfId = deriveTemplateDFAddress(templatesId, tn); - const df = this.templateDFs.get(dfId); - if (!df) { + 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 getCommandFromTemplateDF(df); + return getCommandFromTemplate(template); }); this.#templateCommandsCache.set(cacheKey, commands); @@ -663,8 +660,8 @@ function collectPreFetchRequirements(commands: readonly Command[]): PreFetchRequ interface FetchedState { objects: Map; - templateDFs: Map; - templateLookup: Map; + templates: Map; + templateApprovals: Map; vaults: Map; } @@ -682,12 +679,10 @@ async function fetchOnChainState( }); const objects = new Map(); - for (let i = 0; i < allIds.length; i++) { - const obj = fetched[i]; - objects.set( - allIds[i], - obj && !(obj instanceof Error) && obj.content ? (obj as SuiObject) : null, - ); + + 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) @@ -699,8 +694,8 @@ async function fetchOnChainState( } // 3. Collect template DF IDs by parsing rules - const templateLookup = new Map(); - const templateDFIds: string[] = []; + const templateApprovals = new Map(); + const templateIds: string[] = []; const seen = new Set(); for (const data of intentDataList) { @@ -728,27 +723,26 @@ async function fetchOnChainState( const approvalTypeNames = getRequiredApprovals(ruleObject, actionType); if (!approvalTypeNames?.length) continue; - const templatesId = deriveTemplatesObjectAddress(data.packageConfig); - templateLookup.set(key, approvalTypeNames); - templateDFIds.push(...approvalTypeNames.map((tn) => deriveTemplateDFAddress(templatesId, tn))); + const templatesId = deriveTemplateRegistryAddress(data.packageConfig); + templateApprovals.set(key, approvalTypeNames); + templateIds.push(...approvalTypeNames.map((tn) => deriveTemplateAddress(templatesId, tn))); } - // 4. Batch-fetch all template DFs - const templateDFs = new Map(); - if (templateDFIds.length > 0) { - const { objects: dfObjects } = await client.core.getObjects({ - objectIds: templateDFIds, + // 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 (let i = 0; i < templateDFIds.length; i++) { - const obj = dfObjects[i]; - if (obj && !(obj instanceof Error) && obj.content) { - templateDFs.set(templateDFIds[i], obj as SuiObject); - } + + for (const obj of templateObjects) { + if (!('content' in obj)) continue; + templates.set(obj.objectId, obj); } } - return { objects, templateDFs, templateLookup, vaults }; + return { objects, templates, templateApprovals, vaults }; } // --------------------------------------------------------------------------- @@ -771,8 +765,8 @@ const resolvePASIntents: TransactionPlugin = async (transactionData, buildOption const ctx = new Resolver( transactionData, state.objects, - state.templateDFs, - state.templateLookup, + state.templates, + state.templateApprovals, state.vaults, ); diff --git a/sdk/pas/src/resolution.ts b/sdk/pas/src/resolution.ts index 14a2ad3..eb9cb1d 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -61,10 +61,10 @@ 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); } @@ -109,7 +109,7 @@ interface RawCommandBuildArgs { * concrete Argument references, and converts object/pure inputs via the provided * `addInput` callback. * - * @param command - The parsed MoveCall from a template DF + * @param command - The parsed MoveCall from a template object * @param args - The resolved arguments and addInput helper * @returns A Command object ready for `replaceCommand` */ diff --git a/sdk/pas/test/e2e/demoUsd.ts b/sdk/pas/test/e2e/demoUsd.ts index 6ac7647..3416ba8 100644 --- a/sdk/pas/test/e2e/demoUsd.ts +++ b/sdk/pas/test/e2e/demoUsd.ts @@ -37,14 +37,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 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), ], }); @@ -77,13 +77,13 @@ export class DemoUsdTestHelpers { async upgradeToV2() { const ruleId = this.toolbox.client.pas.deriveRuleAddress(this.demoUsdAssetType); - const templatesId = this.toolbox.client.pas.deriveTemplatesAddress(); + 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(templatesId), tx.object(faucetId)], + arguments: [tx.object(ruleId), tx.object(templateRegistryId), tx.object(faucetId)], }); await this.toolbox.executeTransaction(tx); } diff --git a/sdk/pas/test/e2e/e2e.isolated.test.ts b/sdk/pas/test/e2e/e2e.isolated.test.ts index f4d8fc7..811f5f2 100644 --- a/sdk/pas/test/e2e/e2e.isolated.test.ts +++ b/sdk/pas/test/e2e/e2e.isolated.test.ts @@ -12,8 +12,8 @@ async function expectBalances( const balances = await Promise.all( expected.map(({ vault, asset }) => toolbox.getBalance(vault, asset)), ); - for (let i = 0; i < expected.length; i++) { - expect(Number(balances[i].balance.balance)).toBe(expected[i].amount * 1_000_000); + for (const [idx, { amount }] of expected.entries()) { + expect(Number(balances[idx].balance.balance)).toBe(amount * 1_000_000); } } From 6ae654d773145709dd7abf5e348db79fbcd095ec Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 20:55:13 +0200 Subject: [PATCH 5/8] nits --- sdk/pas/src/intents.ts | 194 +++++++++++++++++++---------------------- 1 file changed, 89 insertions(+), 105 deletions(-) diff --git a/sdk/pas/src/intents.ts b/sdk/pas/src/intents.ts index a955d6a..41ae6ad 100644 --- a/sdk/pas/src/intents.ts +++ b/sdk/pas/src/intents.ts @@ -35,36 +35,36 @@ const PAS_INTENT_NAME = 'PAS'; // Intent data types // --------------------------------------------------------------------------- -interface TransferFundsIntentData { +type TransferFundsIntentData = { action: 'transferFunds'; from: string; to: string; amount: string; assetType: string; - packageConfig: PASPackageConfig; -} + cfg: PASPackageConfig; +}; -interface UnlockFundsIntentData { +type UnlockFundsIntentData = { action: 'unlockFunds'; from: string; amount: string; assetType: string; - packageConfig: PASPackageConfig; -} + cfg: PASPackageConfig; +}; -interface UnlockUnrestrictedFundsIntentData { +type UnlockUnrestrictedFundsIntentData = { action: 'unlockUnrestrictedFunds'; from: string; amount: string; assetType: string; - packageConfig: PASPackageConfig; -} + cfg: PASPackageConfig; +}; -interface VaultForAddressIntentData { +type VaultForAddressIntentData = { action: 'vaultForAddress'; owner: string; - packageConfig: PASPackageConfig; -} + cfg: PASPackageConfig; +}; type PASIntentData = | TransferFundsIntentData @@ -72,10 +72,6 @@ type PASIntentData = | UnlockUnrestrictedFundsIntentData | VaultForAddressIntentData; -// --------------------------------------------------------------------------- -// Intent creator helpers (called from PASClient.tx.*) -// --------------------------------------------------------------------------- - /** * Creates a memoized PAS intent closure. On first call it registers the * shared resolver and adds the $Intent command; subsequent calls return @@ -112,7 +108,7 @@ export function transferFundsIntent( to, amount: String(amount), assetType, - packageConfig, + cfg: packageConfig, }); } @@ -129,7 +125,7 @@ export function unlockFundsIntent( from, amount: String(amount), assetType, - packageConfig, + cfg: packageConfig, }); } @@ -146,14 +142,15 @@ export function unlockUnrestrictedFundsIntent( from, amount: String(amount), assetType, - packageConfig, + cfg: packageConfig, }); } export function vaultForAddressIntent( packageConfig: PASPackageConfig, ): (owner: string) => (tx: Transaction) => TransactionResult { - return (owner: string) => createPASIntent({ action: 'vaultForAddress', owner, packageConfig }); + return (owner: string) => + createPASIntent({ action: 'vaultForAddress', owner, cfg: packageConfig }); } // --------------------------------------------------------------------------- @@ -190,9 +187,7 @@ export function vaultForAddressIntent( type SuiObject = SuiClientTypes.Object<{ content: true }>; -type VaultState = - | { kind: 'existing' } - | { kind: 'created'; resultIndex: number; cfg: PASPackageConfig }; +type VaultState = { kind: 'existing' } | { kind: 'created'; resultIndex: number }; /** Return value from each per-action builder. */ interface BuildResult { @@ -214,6 +209,7 @@ class Resolver { readonly #txData: TransactionDataBuilder; readonly #inputCache = new Map(); readonly #templateCommandsCache = new Map[]>(); + readonly #config: PASPackageConfig; constructor( txData: TransactionDataBuilder, @@ -221,12 +217,14 @@ class Resolver { templates: Map, templateApprovals: Map, vaults: Map, + config: PASPackageConfig, ) { this.#txData = txData; this.objects = objects; this.templates = templates; this.templateApprovals = templateApprovals; this.vaults = vaults; + this.#config = config; } // -- Input helpers (deduplicated) ---------------------------------------- @@ -282,42 +280,35 @@ class Resolver { * @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, - cfg: PASPackageConfig, - commands: Command[], - baseIdx: number, - ): Argument { + 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); - } - if (state?.kind === 'created') { - return { $kind: 'Result', Result: state.resultIndex }; - } + 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: cfg.packageId, + package: this.#config.packageId, module: 'vault', function: 'create', arguments: [ - this.addObjectInput(cfg.namespaceId), + this.addObjectInput(this.#config.namespaceId), this.addPureInput(`address:${owner}`, Inputs.Pure(bcs.Address.serialize(owner))), ], }), ); - this.vaults.set(vaultId, { kind: 'created', resultIndex: absoluteIndex, cfg }); - return { $kind: 'Result', Result: absoluteIndex }; + 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, cfg: PASPackageConfig) { + resolveTemplateCommands(ruleObjectId: string, actionType: PASActionType) { const cacheKey = `${ruleObjectId}:${actionType}`; const cached = this.#templateCommandsCache.get(cacheKey); if (cached) return cached; @@ -329,7 +320,7 @@ class Resolver { ); } - const templatesId = deriveTemplateRegistryAddress(cfg); + const templatesId = deriveTemplateRegistryAddress(this.#config); const commands = approvalTypeNames.map((tn) => { const templateId = deriveTemplateAddress(templatesId, tn); const template = this.templates.get(templateId); @@ -345,14 +336,6 @@ class Resolver { return commands; } - // -- Command replacement -------------------------------------------------- - // - // `replaceCommand(index, commands, resultIndex)` splices the replacement - // commands in and automatically remaps all external argument references: - // - References past `index` are shifted by (commands.length - 1) - // - References to `index` itself are remapped to `resultIndex` - // So we just need to tell it which command produces the intent's output. - /** * Replaces a standard action intent (transfer/unlock) with its built * commands. The resolve call at `actualIdx + resultOffset` produces the @@ -400,28 +383,32 @@ class Resolver { // becomes the intent's output value. buildTransferFunds(data: TransferFundsIntentData, baseIdx: number): BuildResult { - const { from, to, assetType, amount, packageConfig: cfg } = data; - const fromVaultId = deriveVaultAddress(from, cfg); - const toVaultId = deriveVaultAddress(to, cfg); + const { from, to, assetType, amount } = data; + const fromVaultId = deriveVaultAddress(from, this.#config); + const toVaultId = deriveVaultAddress(to, this.#config); - const ruleId = deriveRuleAddress(assetType, cfg); + const ruleId = deriveRuleAddress(assetType, this.#config); const ruleObject = this.getObjectOrThrow(ruleId, () => new RuleNotFoundError(assetType)); const templateCmds = this.resolveTemplateCommands( ruleObject.objectId, PASActionType.TransferFunds, - cfg, ); - const commands: Command[] = []; - const toVaultArg = this.resolveVaultArg(toVaultId, to, cfg, commands, baseIdx); - const fromVaultArg = this.resolveVaultArg(fromVaultId, from, cfg, commands, baseIdx); + 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: cfg.packageId, + package: this.#config.packageId, module: 'vault', function: 'new_auth', }), @@ -431,7 +418,7 @@ class Resolver { const requestIdx = baseIdx + commands.length; commands.push( TransactionCommands.MoveCall({ - package: cfg.packageId, + package: this.#config.packageId, module: 'vault', function: 'transfer_funds', arguments: [ @@ -463,7 +450,7 @@ class Resolver { const resultOffset = commands.length; commands.push( TransactionCommands.MoveCall({ - package: cfg.packageId, + package: this.#config.packageId, module: 'transfer_funds', function: 'resolve', arguments: [requestArg, ruleArg], @@ -483,9 +470,9 @@ class Resolver { data: UnlockFundsIntentData | UnlockUnrestrictedFundsIntentData, baseIdx: number, ): BuildResult { - const { from, assetType, amount, packageConfig: cfg } = data; - const fromVaultId = deriveVaultAddress(from, cfg); - const ruleId = deriveRuleAddress(assetType, cfg); + const { from, assetType, amount } = data; + const fromVaultId = deriveVaultAddress(from, this.#config); + const ruleId = deriveRuleAddress(assetType, this.#config); const isRestricted = data.action === 'unlockFunds'; @@ -507,15 +494,14 @@ class Resolver { } } - const commands: Command[] = []; - const fromVaultArg = this.resolveVaultArg(fromVaultId, from, cfg, commands, baseIdx); + 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: cfg.packageId, + package: this.#config.packageId, module: 'vault', function: 'new_auth', }), @@ -525,7 +511,7 @@ class Resolver { const requestIdx = baseIdx + commands.length; commands.push( TransactionCommands.MoveCall({ - package: cfg.packageId, + package: this.#config.packageId, module: 'vault', function: 'unlock_funds', arguments: [ @@ -540,7 +526,7 @@ class Resolver { if (isRestricted) { // Issuer-defined approval commands from templates - const templateCmds = this.resolveTemplateCommands(ruleId, PASActionType.UnlockFunds, cfg); + const templateCmds = this.resolveTemplateCommands(ruleId, PASActionType.UnlockFunds); for (const templateCmd of templateCmds) { commands.push( buildMoveCallCommandFromTemplate(templateCmd, { @@ -557,7 +543,7 @@ class Resolver { const resultOffset = commands.length; commands.push( TransactionCommands.MoveCall({ - package: cfg.packageId, + package: this.#config.packageId, module: 'unlock_funds', function: 'resolve', arguments: [requestArg, ruleArg!], @@ -571,10 +557,10 @@ class Resolver { const resultOffset = commands.length; commands.push( TransactionCommands.MoveCall({ - package: cfg.packageId, + package: this.#config.packageId, module: 'unlock_funds', function: 'resolve_unrestricted', - arguments: [requestArg, this.addObjectInput(cfg.namespaceId)], + arguments: [requestArg, this.addObjectInput(this.#config.namespaceId)], typeArguments: [normalizeStructTag(assetType)], }), ); @@ -594,7 +580,7 @@ class Resolver { if (state.kind !== 'created') continue; this.#txData.commands.push( TransactionCommands.MoveCall({ - package: state.cfg.packageId, + package: this.#config.packageId, module: 'vault', function: 'share', arguments: [{ $kind: 'Result', Result: state.resultIndex }], @@ -608,24 +594,26 @@ class Resolver { // Data collection + fetching (pre-resolution) // --------------------------------------------------------------------------- -interface PreFetchRequirements { +interface IntentDataCollection { objectIds: Set; - vaultRequests: Map; + vaultRequests: Map; intentDataList: PASIntentData[]; + cfg: PASPackageConfig; } /** Scans commands for PAS intents and collects the object IDs we need to fetch. */ -function collectPreFetchRequirements(commands: readonly Command[]): PreFetchRequirements | null { +function collectIntentData(commands: readonly Command[]): IntentDataCollection | null { const objectIds = new Set(); - const vaultRequests = new Map(); + 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); - const cfg = data.packageConfig; switch (data.action) { case 'transferFunds': { @@ -634,28 +622,31 @@ function collectPreFetchRequirements(commands: readonly Command[]): PreFetchRequ objectIds.add(fromId); objectIds.add(toId); objectIds.add(deriveRuleAddress(data.assetType, cfg)); - vaultRequests.set(fromId, { owner: data.from, cfg }); - vaultRequests.set(toId, { owner: data.to, cfg }); + vaultRequests.set(fromId, { owner: data.from }); + vaultRequests.set(toId, { owner: data.to }); break; } case 'unlockFunds': case 'unlockUnrestrictedFunds': { - const fromId = deriveVaultAddress(data.from, cfg); + const fromId = deriveVaultAddress(data.from, data.cfg); objectIds.add(fromId); - objectIds.add(deriveRuleAddress(data.assetType, cfg)); - vaultRequests.set(fromId, { owner: data.from, cfg }); + objectIds.add(deriveRuleAddress(data.assetType, data.cfg)); + vaultRequests.set(fromId, { owner: data.from }); break; } case 'vaultForAddress': { - const id = deriveVaultAddress(data.owner, cfg); + const id = deriveVaultAddress(data.owner, data.cfg); objectIds.add(id); - vaultRequests.set(id, { owner: data.owner, cfg }); + vaultRequests.set(id, { owner: data.owner }); break; } } } - return intentDataList.length > 0 ? { objectIds, vaultRequests, intentDataList } : null; + 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; } interface FetchedState { @@ -668,8 +659,9 @@ interface FetchedState { async function fetchOnChainState( client: NonNullable[1]['client']>, objectIds: Set, - vaultRequests: Map, + vaultRequests: Map, intentDataList: PASIntentData[], + config: PASPackageConfig, ): Promise { // 1. Batch-fetch all vaults + rules const allIds = [...objectIds]; @@ -712,7 +704,7 @@ async function fetchOnChainState( if (!actionType || !assetType) continue; - const ruleId = deriveRuleAddress(assetType, data.packageConfig); + const ruleId = deriveRuleAddress(assetType, config); const key = `${ruleId}:${actionType}`; if (seen.has(key)) continue; seen.add(key); @@ -723,7 +715,7 @@ async function fetchOnChainState( const approvalTypeNames = getRequiredApprovals(ruleObject, actionType); if (!approvalTypeNames?.length) continue; - const templatesId = deriveTemplateRegistryAddress(data.packageConfig); + const templatesId = deriveTemplateRegistryAddress(config); templateApprovals.set(key, approvalTypeNames); templateIds.push(...approvalTypeNames.map((tn) => deriveTemplateAddress(templatesId, tn))); } @@ -736,8 +728,7 @@ async function fetchOnChainState( include: { content: true }, }); - for (const obj of templateObjects) { - if (!('content' in obj)) continue; + for (const obj of templateObjects.filter((o) => 'content' in o)) { templates.set(obj.objectId, obj); } } @@ -751,23 +742,23 @@ async function fetchOnChainState( const resolvePASIntents: TransactionPlugin = async (transactionData, buildOptions, next) => { const client = buildOptions.client; - if (!client) { + if (!client) throw new PASClientError( 'A SuiClient must be provided to build transactions with PAS intents.', ); - } - const requirements = collectPreFetchRequirements(transactionData.commands); + const requirements = collectIntentData(transactionData.commands); if (!requirements) return next(); - const { objectIds, vaultRequests, intentDataList } = requirements; - const state = await fetchOnChainState(client, objectIds, vaultRequests, intentDataList); + const { objectIds, vaultRequests, intentDataList, cfg } = requirements; + const state = await fetchOnChainState(client, objectIds, vaultRequests, intentDataList, cfg); const ctx = new Resolver( transactionData, state.objects, state.templates, state.templateApprovals, state.vaults, + cfg, ); // Iterate the live command list. replaceCommand mutates the array in place @@ -780,15 +771,8 @@ const resolvePASIntents: TransactionPlugin = async (transactionData, buildOption // -- vaultForAddress is handled separately (may produce 0 commands) -- if (data.action === 'vaultForAddress') { - const vaultId = deriveVaultAddress(data.owner, data.packageConfig); - const commands: Command[] = []; - const vaultArg = ctx.resolveVaultArg( - vaultId, - data.owner, - data.packageConfig, - commands, - index, - ); + const vaultId = deriveVaultAddress(data.owner, cfg); + const [vaultArg, commands] = ctx.resolveVaultArg(vaultId, data.owner, index); if (commands.length === 0) { ctx.replaceIntentWithExistingVault(index, vaultArg); From 4f13e0b8073cf960f16ceb32d0ddd8a82ad747cb Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Mon, 16 Feb 2026 21:00:35 +0200 Subject: [PATCH 6/8] nits --- sdk/example-app/src/extension-example.ts | 2 +- sdk/pas/src/client.ts | 33 +++--------------------- sdk/pas/test/e2e/setup.ts | 2 +- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/sdk/example-app/src/extension-example.ts b/sdk/example-app/src/extension-example.ts index 3c1cc92..05e2536 100644 --- a/sdk/example-app/src/extension-example.ts +++ b/sdk/example-app/src/extension-example.ts @@ -95,7 +95,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/pas/src/client.ts b/sdk/pas/src/client.ts index 4ac0bea..da177af 100644 --- a/sdk/pas/src/client.ts +++ b/sdk/pas/src/client.ts @@ -2,14 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import type { ClientWithCoreApi } from '@mysten/sui/client'; -import { Transaction } from '@mysten/sui/transactions'; import { DEVNET_PAS_PACKAGE_CONFIG, MAINNET_PAS_PACKAGE_CONFIG, TESTNET_PAS_PACKAGE_CONFIG, } from './constants.js'; -import * as Vault from './contracts/pas/vault.js'; import { deriveRuleAddress, deriveTemplateAddress, @@ -144,35 +142,12 @@ export class PASClient { return deriveTemplateAddress(this.deriveTemplateRegistryAddress(), approvalTypeName); } - get call() { - const cfg = this.#packageConfig; - return { - createVault: (owner: string) => { - return (tx: Transaction) => { - return Vault.create({ - package: cfg.packageId, - arguments: [cfg.namespaceId, owner], - })(tx); - }; - }, - createAndShareVault: (owner: string) => { - return (tx: Transaction) => { - return Vault.createAndShare({ - package: cfg.packageId, - arguments: [cfg.namespaceId, owner], - })(tx); - }; - }, - }; - } - /** * Intent-based transaction builders. Each method returns a synchronous closure * that registers a `$Intent` placeholder in the transaction. The actual PTB commands * are resolved lazily at `tx.build()` time via the shared PAS resolver plugin. */ get tx() { - const cfg = this.#packageConfig; return { /** * Creates a transfer funds intent. At build time, it auto-resolves the issuer's @@ -186,7 +161,7 @@ export class PASClient { * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") * @returns A sync closure `(tx: Transaction) => TransactionResult` */ - transferFunds: transferFundsIntent(cfg), + transferFunds: transferFundsIntent(this.#packageConfig), /** * Creates an unlock funds intent. At build time, it resolves the issuer's @@ -199,7 +174,7 @@ export class PASClient { * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") * @returns A sync closure `(tx: Transaction) => TransactionResult` */ - unlockFunds: unlockFundsIntent(cfg), + unlockFunds: unlockFundsIntent(this.#packageConfig), /** * Creates an unlock funds intent for unrestricted (non-managed) assets. @@ -211,7 +186,7 @@ export class PASClient { * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") * @returns A sync closure `(tx: Transaction) => TransactionResult` */ - unlockUnrestrictedFunds: unlockUnrestrictedFundsIntent(cfg), + unlockUnrestrictedFunds: unlockUnrestrictedFundsIntent(this.#packageConfig), /** * Returns a vault object for the given address. At build time, if the vault @@ -221,7 +196,7 @@ export class PASClient { * @param owner - The owner address * @returns A sync closure `(tx: Transaction) => TransactionResult` (the vault) */ - vaultForAddress: vaultForAddressIntent(cfg), + vaultForAddress: vaultForAddressIntent(this.#packageConfig), }; } } diff --git a/sdk/pas/test/e2e/setup.ts b/sdk/pas/test/e2e/setup.ts index 2fc5b1a..01e3697 100644 --- a/sdk/pas/test/e2e/setup.ts +++ b/sdk/pas/test/e2e/setup.ts @@ -110,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); } From 91eb962d33ae2144576ac3eb4bbfc264ed4e40e3 Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Tue, 17 Feb 2026 16:40:22 +0200 Subject: [PATCH 7/8] fix example --- sdk/example-app/src/extension-example.ts | 7 ++++--- sdk/pas/src/constants.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/sdk/example-app/src/extension-example.ts b/sdk/example-app/src/extension-example.ts index 05e2536..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); 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', }; From a353a34504fe380aae2686e77da4d68d58ae6bcb Mon Sep 17 00:00:00 2001 From: Manos Liolios Date: Tue, 17 Feb 2026 16:54:48 +0200 Subject: [PATCH 8/8] Cleanup more --- sdk/pas/src/intents.ts | 106 ++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/sdk/pas/src/intents.ts b/sdk/pas/src/intents.ts index 41ae6ad..bfc6ff8 100644 --- a/sdk/pas/src/intents.ts +++ b/sdk/pas/src/intents.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { bcs } from '@mysten/sui/bcs'; -import type { SuiClientTypes } from '@mysten/sui/client'; +import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; import { Inputs, Transaction, TransactionCommands } from '@mysten/sui/transactions'; import type { Argument, @@ -206,20 +206,27 @@ class Resolver { /** Vault existence / creation tracking. */ readonly vaults: Map; - readonly #txData: TransactionDataBuilder; + readonly #tx: TransactionDataBuilder; readonly #inputCache = new Map(); readonly #templateCommandsCache = new Map[]>(); readonly #config: PASPackageConfig; - constructor( - txData: TransactionDataBuilder, - objects: Map, - templates: Map, - templateApprovals: Map, - vaults: Map, - config: PASPackageConfig, - ) { - this.#txData = txData; + 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; @@ -232,7 +239,7 @@ class Resolver { addObjectInput(objectId: string): Argument { let arg = this.#inputCache.get(objectId); if (!arg) { - arg = this.#txData.addInput('object', { + arg = this.#tx.addInput('object', { $kind: 'UnresolvedObject', UnresolvedObject: { objectId }, }); @@ -244,7 +251,7 @@ class Resolver { addPureInput(key: string, value: ReturnType): Argument { let arg = this.#inputCache.get(key); if (!arg) { - arg = this.#txData.addInput('pure', value); + arg = this.#tx.addInput('pure', value); this.#inputCache.set(key, arg); } return arg; @@ -254,7 +261,7 @@ class Resolver { if (type === 'object' && arg.$kind === 'UnresolvedObject') { return this.addObjectInput(arg.UnresolvedObject.objectId); } - return this.#txData.addInput(type, arg); + return this.#tx.addInput(type, arg); } // -- Object lookup ------------------------------------------------------- @@ -275,7 +282,7 @@ class Resolver { * - 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 `appendVaultShares()`. + * 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. @@ -342,7 +349,7 @@ class Resolver { * intent's output value. */ replaceIntent(actualIdx: number, commands: Command[], resultOffset: number) { - this.#txData.replaceCommand(actualIdx, commands, { Result: actualIdx + resultOffset }); + this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx + resultOffset }); } /** @@ -354,7 +361,7 @@ class Resolver { * resultIndex, but the runtime handles it correctly via ArgumentSchema.parse(). */ replaceIntentWithExistingVault(actualIdx: number, vaultArg: Argument) { - this.#txData.replaceCommand(actualIdx, [], vaultArg as any); + this.#tx.replaceCommand(actualIdx, [], vaultArg as any); } /** @@ -363,7 +370,7 @@ class Resolver { * references are remapped to the first command's Result (the new vault). */ replaceIntentWithCreatedVault(actualIdx: number, commands: Command[]) { - this.#txData.replaceCommand(actualIdx, commands, { Result: actualIdx }); + this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx }); } // -- Per-action builders -------------------------------------------------- @@ -575,10 +582,10 @@ class Resolver { * so that each vault is shared exactly once regardless of how many intents * referenced it. */ - appendVaultShares() { - for (const [, state] of this.vaults) { + shareNewVaults() { + for (const state of this.vaults.values()) { if (state.kind !== 'created') continue; - this.#txData.commands.push( + this.#tx.commands.push( TransactionCommands.MoveCall({ package: this.#config.packageId, module: 'vault', @@ -594,9 +601,11 @@ class Resolver { // Data collection + fetching (pre-resolution) // --------------------------------------------------------------------------- +type VaultOwner = { owner: string }; + interface IntentDataCollection { objectIds: Set; - vaultRequests: Map; + vaultRequests: Map; intentDataList: PASIntentData[]; cfg: PASPackageConfig; } @@ -604,14 +613,14 @@ interface IntentDataCollection { /** 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 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); @@ -628,14 +637,14 @@ function collectIntentData(commands: readonly Command[]): IntentDataCollection | } case 'unlockFunds': case 'unlockUnrestrictedFunds': { - const fromId = deriveVaultAddress(data.from, data.cfg); + const fromId = deriveVaultAddress(data.from, cfg); objectIds.add(fromId); - objectIds.add(deriveRuleAddress(data.assetType, data.cfg)); + objectIds.add(deriveRuleAddress(data.assetType, cfg)); vaultRequests.set(fromId, { owner: data.from }); break; } case 'vaultForAddress': { - const id = deriveVaultAddress(data.owner, data.cfg); + const id = deriveVaultAddress(data.owner, cfg); objectIds.add(id); vaultRequests.set(id, { owner: data.owner }); break; @@ -649,20 +658,14 @@ function collectIntentData(commands: readonly Command[]): IntentDataCollection | return intentDataList.length > 0 ? { objectIds, vaultRequests, intentDataList, cfg } : null; } -interface FetchedState { - objects: Map; - templates: Map; - templateApprovals: Map; - vaults: Map; -} - -async function fetchOnChainState( - client: NonNullable[1]['client']>, +async function initializeContext( + transactionData: TransactionDataBuilder, + client: ClientWithCoreApi, objectIds: Set, - vaultRequests: Map, + vaultRequests: Map, intentDataList: PASIntentData[], config: PASPackageConfig, -): Promise { +): Promise { // 1. Batch-fetch all vaults + rules const allIds = [...objectIds]; const { objects: fetched } = await client.core.getObjects({ @@ -733,13 +736,16 @@ async function fetchOnChainState( } } - return { objects, templates, templateApprovals, vaults }; + return new Resolver({ + transactionData, + objects, + templates, + templateApprovals, + vaults, + config, + }); } -// --------------------------------------------------------------------------- -// Shared resolver (TransactionPlugin) -// --------------------------------------------------------------------------- - const resolvePASIntents: TransactionPlugin = async (transactionData, buildOptions, next) => { const client = buildOptions.client; if (!client) @@ -751,13 +757,13 @@ const resolvePASIntents: TransactionPlugin = async (transactionData, buildOption if (!requirements) return next(); const { objectIds, vaultRequests, intentDataList, cfg } = requirements; - const state = await fetchOnChainState(client, objectIds, vaultRequests, intentDataList, cfg); - const ctx = new Resolver( + + const ctx = await initializeContext( transactionData, - state.objects, - state.templates, - state.templateApprovals, - state.vaults, + client, + objectIds, + vaultRequests, + intentDataList, cfg, ); @@ -799,6 +805,6 @@ const resolvePASIntents: TransactionPlugin = async (transactionData, buildOption ctx.replaceIntent(index, result.commands, result.resultOffset); } - ctx.appendVaultShares(); + ctx.shareNewVaults(); return next(); };