diff --git a/packages/testing/demo_usd/sources/demo_usd.move b/packages/testing/demo_usd/sources/demo_usd.move index 6178389..20f4266 100644 --- a/packages/testing/demo_usd/sources/demo_usd.move +++ b/packages/testing/demo_usd/sources/demo_usd.move @@ -15,7 +15,6 @@ use pas::templates::Templates; use pas::transfer_funds::TransferFunds; use ptb::ptb; use std::type_name; -use sui::accumulator::AccumulatorRoot; use sui::balance::Balance; use sui::clock::Clock; use sui::coin::TreasuryCap; @@ -105,7 +104,7 @@ public fun use_v2(rule: &mut Rule, templates: &mut Templates, faucet: type_name::with_defining_ids().address_string().to_string(), "demo_usd", "approve_transfer_v2", - vector[ptb::ext_input("pas:request"), ptb::object_by_id(@0xacc.to_id())], + vector[ptb::ext_input("pas:request"), ptb::object_by_id(object::id(faucet))], vector[], ); @@ -125,10 +124,7 @@ public fun approve_transfer(request: &mut Request>, _clock: } /// V2 function allows all transfers, besides transferring to 0x2. -public fun approve_transfer_v2( - request: &mut Request>, - _acc: &AccumulatorRoot, -) { +public fun approve_transfer_v2(request: &mut Request>, _faucet: &Faucet) { assert!(request.data().recipient() != @0x2, ENotAllowedRecipient); request.approve(TransferApprovalV2()); } diff --git a/sdk/example-app/package.json b/sdk/example-app/package.json index 963c6b4..3fcdbde 100644 --- a/sdk/example-app/package.json +++ b/sdk/example-app/package.json @@ -11,7 +11,7 @@ "@mysten/sui": "workspace:^" }, "devDependencies": { - "@mysten/sui": "^2.0.1", + "@mysten/sui": "^2.4.0", "@types/node": "^25.0.8", "tsx": "^4.19.2", "typescript": "^5.9.3" diff --git a/sdk/example-app/src/extension-example.ts b/sdk/example-app/src/extension-example.ts index 3c1cc92..c796743 100644 --- a/sdk/example-app/src/extension-example.ts +++ b/sdk/example-app/src/extension-example.ts @@ -5,8 +5,8 @@ * Example demonstrating PAS SDK usage with the SDK v2.0 $extend pattern */ -const assetType = '0xf4874d6d4854f92019a2b3914d3838522a72f3c02658893488417ac90c00189b::demo_usd::DEMO_USD'; -const demoAssetFaucet = '0x6e48b7accee3e69f3b2ac3d86b9496dac059bd5b7ee3e8869a70ceb1f78ee20b' +const assetType = '0xbcd1cffae40317c7870e55c65af7fc20a8c46ce0ed1a1b24b1edf576480e2fa8::demo_usd::DEMO_USD'; +const demoAssetFaucet = '0x9d1fb399a8748a6afdd687a93c4c9303e6f7787c860d5e705136f7b979a3b4d7' import { SuiGrpcClient } from '@mysten/sui/grpc'; import { decodeSuiPrivateKey, Signer } from '@mysten/sui/cryptography'; @@ -26,6 +26,7 @@ async function main(): Promise { baseUrl: 'https://fullnode.devnet.sui.io:443', }).$extend(pas()); + // await finalizeTestAssetSetup(client); // console.log(await getBalancesForAddress(client, sender)); // await createVaultForAddress(client, sender); @@ -87,7 +88,7 @@ async function finalizeTestAssetSetup(client: PasClientType) { tx.moveCall({ target: assetType.split('::')[0] + '::demo_usd::setup', - arguments: [tx.object(client.pas.getPackageConfig().namespaceId)] + arguments: [tx.object(client.pas.getPackageConfig().namespaceId), tx.object(client.pas.deriveTemplateRegistryAddress()), tx.object(demoAssetFaucet)] }); await signAndExecute(client, tx); @@ -95,7 +96,7 @@ async function finalizeTestAssetSetup(client: PasClientType) { async function createVaultForAddress(client: PasClientType, address: string) { const tx = new Transaction(); - tx.add(client.pas.call.createAndShareVault(address)); + tx.add(client.pas.tx.vaultForAddress(address)); return signAndExecute(client, tx); } diff --git a/sdk/package.json b/sdk/package.json index 4b6b06f..76992f8 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -10,12 +10,12 @@ "author": "Mysten Labs", "license": "Apache-2.0", "devDependencies": { - "@mysten/sui": "^2.0.1", - "@mysten/bcs": "^2.0.1", + "@mysten/sui": "^2.4.0", + "@mysten/bcs": "^2.0.2", "@mysten/codegen": "^0.6.0" }, "peerDependencies": { - "@mysten/sui": "^2.0.0", - "@mysten/bcs": "^2.0.0" + "@mysten/sui": "^2.4.0", + "@mysten/bcs": "^2.0.2" } } diff --git a/sdk/pas/package.json b/sdk/pas/package.json index cd30348..71ff774 100644 --- a/sdk/pas/package.json +++ b/sdk/pas/package.json @@ -43,8 +43,8 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mysten/codegen": "^0.6.0", - "@mysten/sui": "^2.0.1", - "@mysten/bcs": "^2.0.1", + "@mysten/sui": "^2.4.0", + "@mysten/bcs": "^2.0.2", "@testcontainers/postgresql": "^11.11.0", "@types/node": "^25.0.8", "@types/ws": "^8.18.1", diff --git a/sdk/pas/src/bcs.ts b/sdk/pas/src/bcs.ts index c9c1337..bae713c 100644 --- a/sdk/pas/src/bcs.ts +++ b/sdk/pas/src/bcs.ts @@ -2,26 +2,6 @@ import { bcs, BcsType } from '@mysten/sui/bcs'; import { MoveStruct } from './contracts/utils/index.js'; -/** An entry in the map */ -export function Entry, V extends BcsType>(...typeParameters: [K, V]) { - return new MoveStruct({ - name: `0x2::vec_map::Entry<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, - fields: { - key: typeParameters[0], - value: typeParameters[1], - }, - }); -} -/* VecMap representation */ -export function VecMap, V extends BcsType>(...typeParameters: [K, V]) { - return new MoveStruct({ - name: `0x2::vec_map::VecMap<${typeParameters[0].name as K['name']}, ${typeParameters[1].name as V['name']}>`, - fields: { - contents: bcs.vector(Entry(typeParameters[0], typeParameters[1])), - }, - }); -} - /** dynamic Field representation */ export function Field, Value extends BcsType>( ...typeParameters: [Name, Value] diff --git a/sdk/pas/src/client.ts b/sdk/pas/src/client.ts index 643d9d4..da177af 100644 --- a/sdk/pas/src/client.ts +++ b/sdk/pas/src/client.ts @@ -1,33 +1,26 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; -import { Transaction } from '@mysten/sui/transactions'; +import type { ClientWithCoreApi } from '@mysten/sui/client'; import { DEVNET_PAS_PACKAGE_CONFIG, MAINNET_PAS_PACKAGE_CONFIG, TESTNET_PAS_PACKAGE_CONFIG, } from './constants.js'; -import { resolve as resolveTransferFunds } from './contracts/pas/transfer_funds.js'; -import { - resolve as resolveUnlockFunds, - resolveUnrestricted, -} from './contracts/pas/unlock_funds.js'; -import * as Vault from './contracts/pas/vault.js'; import { deriveRuleAddress, - deriveTemplateDFAddress, - deriveTemplatesObjectAddress, + deriveTemplateAddress, + deriveTemplateRegistryAddress, deriveVaultAddress, } from './derivation.js'; -import { PASClientError, RuleNotFoundError, VaultNotFoundError } from './error.js'; +import { PASClientError } from './error.js'; import { - addMoveCallFromCommand, - getCommandFromTemplateDF, - getRequiredApprovals, - PASActionType, -} from './resolution.js'; + transferFundsIntent, + unlockFundsIntent, + unlockUnrestrictedFundsIntent, + vaultForAddressIntent, +} from './intents.js'; import type { PASClientConfig, PASOptions, PASPackageConfig } from './types.js'; export function pas({ @@ -135,8 +128,8 @@ export class PASClient { * * @returns The derived templates object ID */ - deriveTemplatesAddress(): string { - return deriveTemplatesObjectAddress(this.#packageConfig); + deriveTemplateRegistryAddress(): string { + return deriveTemplateRegistryAddress(this.#packageConfig); } /** @@ -146,335 +139,64 @@ export class PASClient { * @returns The derived dynamic field object ID */ deriveTemplateAddress(approvalTypeName: string): string { - return deriveTemplateDFAddress(this.deriveTemplatesAddress(), approvalTypeName); + return deriveTemplateAddress(this.deriveTemplateRegistryAddress(), approvalTypeName); } - call = { - createVault: (owner: string) => { - return (tx: Transaction) => { - return Vault.create({ - package: this.#packageConfig.packageId, - arguments: [this.#packageConfig.namespaceId, owner], - })(tx); - }; - }, - createAndShareVault: (owner: string) => { - return (tx: Transaction) => { - return Vault.createAndShare({ - package: this.#packageConfig.packageId, - arguments: [this.#packageConfig.namespaceId, owner], - })(tx); - }; - }, - }; - /** - * Fetches the Rule object for a given asset type and extracts the required approval - * type names for the specified action. Then fetches the template DFs for each approval. - * - * @returns The list of parsed commands from the template DFs + * Intent-based transaction builders. Each method returns a synchronous closure + * that registers a `$Intent` placeholder in the transaction. The actual PTB commands + * are resolved lazily at `tx.build()` time via the shared PAS resolver plugin. */ - async #resolveTemplateCommands( - ruleObject: SuiClientTypes.Object<{ content: true }>, - actionType: PASActionType, - ) { - const approvalTypeNames = getRequiredApprovals(ruleObject, actionType); - - if (!approvalTypeNames || approvalTypeNames.length === 0) { - throw new PASClientError( - `No required approvals found for action "${actionType}". The issuer has not configured this action.`, - ); - } - - // Derive template DF addresses for each approval type - const templateDFIds = approvalTypeNames.map((typeName) => this.deriveTemplateAddress(typeName)); - - // Fetch all template DFs - const { objects: templateDFs } = await this.#suiClient.core.getObjects({ - objectIds: templateDFIds, - include: { content: true }, - }); - - // Parse commands from each template DF - const commands = []; - for (let i = 0; i < approvalTypeNames.length; i++) { - const templateDF = templateDFs[i]; - if (!templateDF || templateDF instanceof Error || !templateDF.content) { - throw new PASClientError( - `Template not found for approval type "${approvalTypeNames[i]}". The issuer has not set up the template command.`, - ); - } - commands.push(getCommandFromTemplateDF(templateDF)); - } - - return commands; + get tx() { + return { + /** + * Creates a transfer funds intent. At build time, it auto-resolves the issuer's + * approval template commands by reading the Rule and Templates objects on-chain. + * If the recipient vault does not exist, it will be created and shared automatically. + * + * @param options - Transfer options + * @param options.from - The sender's address (owner of the source vault) + * @param options.to - The receiver's address (owner of the destination vault) + * @param options.amount - The amount to transfer + * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") + * @returns A sync closure `(tx: Transaction) => TransactionResult` + */ + transferFunds: transferFundsIntent(this.#packageConfig), + + /** + * Creates an unlock funds intent. At build time, it resolves the issuer's + * approval template commands. This will fail if the issuer has not configured + * unlock approvals for the asset type. + * + * @param options - Unlock options + * @param options.from - The sender's address (owner of the source vault) + * @param options.amount - The amount to unlock + * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") + * @returns A sync closure `(tx: Transaction) => TransactionResult` + */ + unlockFunds: unlockFundsIntent(this.#packageConfig), + + /** + * Creates an unlock funds intent for unrestricted (non-managed) assets. + * Use this when no Rule exists for the asset type (e.g., SUI). + * + * @param options - Unlock options + * @param options.from - The sender's address (owner of the source vault) + * @param options.amount - The amount to unlock + * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") + * @returns A sync closure `(tx: Transaction) => TransactionResult` + */ + unlockUnrestrictedFunds: unlockUnrestrictedFundsIntent(this.#packageConfig), + + /** + * Returns a vault object for the given address. At build time, if the vault + * already exists on-chain it resolves to an object reference; otherwise it + * creates the vault and shares it. + * + * @param owner - The owner address + * @returns A sync closure `(tx: Transaction) => TransactionResult` (the vault) + */ + vaultForAddress: vaultForAddressIntent(this.#packageConfig), + }; } - - /** - * Methods that create transactions without executing them - */ - tx = { - /** - * Creates a transfer funds transaction. It auto-resolves the creator's transfer function - * by reading the Rule's required approvals and fetching the corresponding template commands. - * - * @param options - Transfer options - * @param options.from - The sender's address (owner of the source vault) - * @param options.to - The receiver's address (owner of the destination vault) - * @param options.amount - The amount to transfer - * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") - * @returns An async thunk that takes a Transaction and executes the transfer - */ - transferFunds: (options: { - from: string; - to: string; - amount: number | bigint; - assetType: string; - }) => { - const { from, to, amount, assetType } = options; - - return async (tx: Transaction) => { - // 1. Derive addresses - const fromVaultId = this.deriveVaultAddress(from); - const toVaultId = this.deriveVaultAddress(to); - const ruleId = this.deriveRuleAddress(assetType); - - // 2. Fetch vaults and rule in a single batch call - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [fromVaultId, toVaultId, ruleId], - include: { content: true }, - }); - - // 3. Find objects by ID - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - const toVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === toVaultId, - ); - const ruleResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === ruleId, - ); - - if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) { - throw new VaultNotFoundError(from); - } - - if (!ruleResult || ruleResult instanceof Error || !ruleResult.content) { - throw new RuleNotFoundError(assetType); - } - - // 4. Resolve template commands for the transfer action - const templateCommands = await this.#resolveTemplateCommands( - ruleResult as SuiClientTypes.Object<{ content: true }>, - PASActionType.TransferFunds, - ); - - // 5. Check if recipient vault exists - const toVaultExists = - toVaultResult && !(toVaultResult instanceof Error) && toVaultResult.content !== null; - - // 6. Create auth proof from transaction sender - const auth = Vault.newAuth({ - package: this.#packageConfig.packageId, - })(tx); - - // 7. Create recipient vault if needed - let toVault; - let shouldShareVault = false; - if (toVaultExists) { - toVault = tx.object(toVaultId); - } else { - toVault = Vault.create({ - package: this.#packageConfig.packageId, - arguments: [this.#packageConfig.namespaceId, to], - })(tx); - shouldShareVault = true; - } - - // 8. Create the transfer request using vault::transfer_funds - const transferRequest = Vault.transferFunds({ - package: this.#packageConfig.packageId, - arguments: [tx.object(fromVaultId), auth, toVault, amount], - typeArguments: [assetType], - })(tx); - - // 9. Execute each template command (approval) - for (const command of templateCommands) { - addMoveCallFromCommand(command, { - tx, - senderVault: tx.object(fromVaultId), - receiverVault: toVault, - rule: tx.object(ruleId), - request: transferRequest, - systemType: assetType, - }); - } - - // 10. Resolve the transfer request (consumes the request after all approvals) - resolveTransferFunds({ - package: this.#packageConfig.packageId, - arguments: [transferRequest, tx.object(ruleId)], - typeArguments: [assetType], - })(tx); - - // 11. Share the vault if it was just created - if (shouldShareVault) { - Vault.share({ - package: this.#packageConfig.packageId, - arguments: [toVault], - })(tx); - } - }; - }, - - /** - * Creates an unlock funds transaction. It is quite likely that this won't succeed - * unless the issuer has specific circumstances under which they allow unlocks. - * - * @param options - Unlock options - * @param options.from - The sender's address (owner of the source vault) - * @param options.amount - The amount to unlock - * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") - * @returns An async thunk that takes a Transaction and executes the unlock - */ - unlockFunds: (options: { from: string; amount: number | bigint; assetType: string }) => { - const { from, amount, assetType } = options; - - return async (tx: Transaction) => { - // 1. Derive addresses - const fromVaultId = this.deriveVaultAddress(from); - const ruleId = this.deriveRuleAddress(assetType); - - // 2. Fetch vault and rule - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [fromVaultId, ruleId], - include: { content: true }, - }); - - // 3. Find objects by ID - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - const ruleResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === ruleId, - ); - - if (!ruleResult || ruleResult instanceof Error || !ruleResult.content) { - throw new PASClientError( - `Rule does not exist for asset type ${assetType}. ` + - `That means that the issuer has not yet enabled funds management for this asset. ` + - `If this is a non-managed asset, you can use the unrestricted unlock flow by calling unlockUnrestrictedFunds() instead.`, - ); - } - - if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) { - throw new VaultNotFoundError(from); - } - - // 4. Resolve template commands for the unlock action - const templateCommands = await this.#resolveTemplateCommands( - ruleResult as SuiClientTypes.Object<{ content: true }>, - PASActionType.UnlockFunds, - ); - - // 5. Create auth proof from transaction sender - const auth = Vault.newAuth({ - package: this.#packageConfig.packageId, - })(tx); - - // 6. Create the unlock request using vault::unlock_funds - const unlockRequest = Vault.unlockFunds({ - package: this.#packageConfig.packageId, - arguments: [tx.object(fromVaultId), auth, amount], - typeArguments: [assetType], - })(tx); - - // 7. Execute each template command (approval) - for (const command of templateCommands) { - addMoveCallFromCommand(command, { - tx, - senderVault: tx.object(fromVaultId), - rule: tx.object(ruleId), - request: unlockRequest, - systemType: assetType, - }); - } - - // 8. Resolve the unlock request (consumes the request after all approvals) - return resolveUnlockFunds({ - package: this.#packageConfig.packageId, - arguments: [unlockRequest, tx.object(ruleId)], - typeArguments: [assetType], - })(tx); - }; - }, - - /** - * Creates an unlock funds transaction for unrestricted assets. - * Unrestricted are assets that are not managed by the system, with this offering - * a way to unlock funds, when a rule does not exist. - * - * @param options - Unlock options - * @param options.from - The sender's address (owner of the source vault) - * @param options.amount - The amount to unlock - * @param options.assetType - The full asset type (e.g., "0x2::sui::SUI") - * @returns An async thunk that takes a Transaction and executes the unlock - */ - unlockUnrestrictedFunds: (options: { - from: string; - amount: number | bigint; - assetType: string; - }) => { - const { from, amount, assetType } = options; - - return async (tx: Transaction) => { - // 1. Derive addresses - const fromVaultId = this.deriveVaultAddress(from); - const ruleId = this.deriveRuleAddress(assetType); - - // 2. fetch objects - const { objects } = await this.#suiClient.core.getObjects({ - objectIds: [ruleId, fromVaultId], - include: { content: true }, - }); - - // 3. Find objects by ID - const ruleResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === ruleId, - ); - - const fromVaultResult = objects.find( - (obj) => !(obj instanceof Error) && obj.objectId === fromVaultId, - ); - - // If `from` vault does not exist, error out. - if (!fromVaultResult || fromVaultResult instanceof Error || !fromVaultResult.content) - throw new VaultNotFoundError(from); - - if (ruleResult) { - throw new PASClientError( - `A rule exists for asset type ${assetType}. That means that the issuer has enabled funds management for this asset and you can no longer use the unrestricted unlock flow.`, - ); - } - - const auth = Vault.newAuth({ - package: this.#packageConfig.packageId, - })(tx); - - // 4. Create the unlock request using vault::unlock_funds - const unlockRequest = Vault.unlockFunds({ - package: this.#packageConfig.packageId, - arguments: [tx.object(fromVaultId), auth, amount], - typeArguments: [assetType], - })(tx); - - return resolveUnrestricted({ - package: this.#packageConfig.packageId, - arguments: [unlockRequest, this.#packageConfig.namespaceId], - typeArguments: [assetType], - })(tx); - }; - }, - }; } diff --git a/sdk/pas/src/constants.ts b/sdk/pas/src/constants.ts index 9841feb..ffeb65e 100644 --- a/sdk/pas/src/constants.ts +++ b/sdk/pas/src/constants.ts @@ -15,6 +15,6 @@ export const MAINNET_PAS_PACKAGE_CONFIG: PASPackageConfig = { // TODO: Remove devnet when going live with the client. export const DEVNET_PAS_PACKAGE_CONFIG: PASPackageConfig = { - packageId: '0x8edb029482f90e4160db01841f8b51bf1602df8f83728c5af596522b30836595', - namespaceId: '0xc79061241d77af9907bd0c7c4163f864c1a5deaaadb3526502df5fb9963e1423', + packageId: '0xcb8c93eab81b9a4f0cb48382962cdac0f16767a23ae81e5f7c4c44690afd4f2a', + namespaceId: '0xf7cb5378eefb861af87eaa9c621e29d7f061a6f3919d241502dc1549b7718a1c', }; diff --git a/sdk/pas/src/derivation.ts b/sdk/pas/src/derivation.ts index 415121f..80aeec9 100644 --- a/sdk/pas/src/derivation.ts +++ b/sdk/pas/src/derivation.ts @@ -64,7 +64,7 @@ export function deriveRuleAddress(assetType: string, packageConfig: PASPackageCo * @param packageConfig - PAS package configuration * @returns The derived templates object ID */ -export function deriveTemplatesObjectAddress(packageConfig: PASPackageConfig): string { +export function deriveTemplateRegistryAddress(packageConfig: PASPackageConfig): string { const { packageId, namespaceId } = packageConfig; // The type tag is the TemplateKey type from the PAS package @@ -84,7 +84,7 @@ export function deriveTemplatesObjectAddress(packageConfig: PASPackageConfig): s * @param approvalTypeName - The fully qualified approval type name (e.g., "0x123::demo_usd::TransferApproval") * @returns The derived dynamic field object ID */ -export function deriveTemplateDFAddress(templatesId: string, approvalTypeName: string): string { +export function deriveTemplateAddress(templatesId: string, approvalTypeName: string): string { // TypeName is a struct { name: String }, serialized as BCS string const key = bcs.string().serialize(approvalTypeName).toBytes(); diff --git a/sdk/pas/src/error.ts b/sdk/pas/src/error.ts index 57d52fc..9abdf65 100644 --- a/sdk/pas/src/error.ts +++ b/sdk/pas/src/error.ts @@ -11,13 +11,6 @@ export class PASClientError extends Error { } } -export class VaultNotFoundError extends PASClientError { - constructor(address: string) { - super(`Vault not found for address ${address}.`); - this.name = 'VaultNotFoundError'; - } -} - export class RuleNotFoundError extends PASClientError { constructor(assetType: string, message?: string) { super(message ?? `Rule not found for asset type ${assetType}.`); diff --git a/sdk/pas/src/intents.ts b/sdk/pas/src/intents.ts new file mode 100644 index 0000000..bfc6ff8 --- /dev/null +++ b/sdk/pas/src/intents.ts @@ -0,0 +1,810 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { bcs } from '@mysten/sui/bcs'; +import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; +import { Inputs, Transaction, TransactionCommands } from '@mysten/sui/transactions'; +import type { + Argument, + CallArg, + Command, + TransactionDataBuilder, + TransactionPlugin, + TransactionResult, +} from '@mysten/sui/transactions'; +import { normalizeStructTag } from '@mysten/sui/utils'; + +import { + deriveRuleAddress, + deriveTemplateAddress, + deriveTemplateRegistryAddress, + deriveVaultAddress, +} from './derivation.js'; +import { PASClientError, RuleNotFoundError } from './error.js'; +import { + buildMoveCallCommandFromTemplate, + getCommandFromTemplate, + getRequiredApprovals, + PASActionType, +} from './resolution.js'; +import type { PASPackageConfig } from './types.js'; + +const PAS_INTENT_NAME = 'PAS'; + +// --------------------------------------------------------------------------- +// Intent data types +// --------------------------------------------------------------------------- + +type TransferFundsIntentData = { + action: 'transferFunds'; + from: string; + to: string; + amount: string; + assetType: string; + cfg: PASPackageConfig; +}; + +type UnlockFundsIntentData = { + action: 'unlockFunds'; + from: string; + amount: string; + assetType: string; + cfg: PASPackageConfig; +}; + +type UnlockUnrestrictedFundsIntentData = { + action: 'unlockUnrestrictedFunds'; + from: string; + amount: string; + assetType: string; + cfg: PASPackageConfig; +}; + +type VaultForAddressIntentData = { + action: 'vaultForAddress'; + owner: string; + cfg: PASPackageConfig; +}; + +type PASIntentData = + | TransferFundsIntentData + | UnlockFundsIntentData + | UnlockUnrestrictedFundsIntentData + | VaultForAddressIntentData; + +/** + * Creates a memoized PAS intent closure. On first call it registers the + * shared resolver and adds the $Intent command; subsequent calls return + * the cached TransactionResult. + */ +function createPASIntent(data: PASIntentData): (tx: Transaction) => TransactionResult { + let result: TransactionResult | null = null; + return (tx: Transaction) => { + if (result) return result; + tx.addIntentResolver(PAS_INTENT_NAME, resolvePASIntents); + result = tx.add( + TransactionCommands.Intent({ + name: PAS_INTENT_NAME, + inputs: {}, + data: data as unknown as Record, + }), + ); + return result; + }; +} + +export function transferFundsIntent( + packageConfig: PASPackageConfig, +): (options: { + from: string; + to: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { + return ({ from, to, amount, assetType }) => + createPASIntent({ + action: 'transferFunds', + from, + to, + amount: String(amount), + assetType, + cfg: packageConfig, + }); +} + +export function unlockFundsIntent( + packageConfig: PASPackageConfig, +): (options: { + from: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { + return ({ from, amount, assetType }) => + createPASIntent({ + action: 'unlockFunds', + from, + amount: String(amount), + assetType, + cfg: packageConfig, + }); +} + +export function unlockUnrestrictedFundsIntent( + packageConfig: PASPackageConfig, +): (options: { + from: string; + amount: number | bigint; + assetType: string; +}) => (tx: Transaction) => TransactionResult { + return ({ from, amount, assetType }) => + createPASIntent({ + action: 'unlockUnrestrictedFunds', + from, + amount: String(amount), + assetType, + cfg: packageConfig, + }); +} + +export function vaultForAddressIntent( + packageConfig: PASPackageConfig, +): (owner: string) => (tx: Transaction) => TransactionResult { + return (owner: string) => + createPASIntent({ action: 'vaultForAddress', owner, cfg: packageConfig }); +} + +// --------------------------------------------------------------------------- +// Resolver -- holds mutable state shared across all intent builders +// --------------------------------------------------------------------------- +// +// ## How intent resolution works +// +// Each PAS intent occupies a single $Intent slot in the transaction's command +// list. At build time, the resolver replaces each $Intent with a sequence of +// concrete MoveCall commands via `replaceCommand`. +// +// The tricky part is **indexing**. Commands within a PTB reference each +// other's outputs by absolute command index (e.g. `{ Result: 5 }` means +// "the output of command #5"). When we build the replacement commands for +// an intent, we need to know what absolute index each new command will land +// at in the final PTB. That's what `baseIdx` is for: +// +// baseIdx = the position of the $Intent slot being replaced +// +// So if baseIdx is 3 and we push 2 vault-creation commands before the +// new_auth call, new_auth lands at absolute index 5 (= 3 + 2). +// +// The SDK's `replaceCommand` handles index shifting automatically: after +// splicing N commands in place of 1, it adjusts all Result/NestedResult +// references in subsequent commands by (N - 1). So we iterate the live +// command list directly -- no manual offset tracking needed. +// +// Each builder returns a `BuildResult` containing: +// - `commands`: the replacement commands (local array, 0-indexed) +// - `resultOffset`: which command in that array produces the intent's +// output value (so external references to the intent can be remapped) +// + +type SuiObject = SuiClientTypes.Object<{ content: true }>; + +type VaultState = { kind: 'existing' } | { kind: 'created'; resultIndex: number }; + +/** Return value from each per-action builder. */ +interface BuildResult { + commands: Command[]; + /** Offset within `commands` of the command whose Result is the intent's output. */ + resultOffset: number; +} + +class Resolver { + /** Pre-fetched on-chain objects (vaults, rules). null = does not exist. */ + readonly objects: Map; + /** Pre-fetched template dynamic field objects. */ + readonly templates: Map; + /** Pre-parsed template lookup: ruleId:actionType -> approval type names. */ + readonly templateApprovals: Map; + /** Vault existence / creation tracking. */ + readonly vaults: Map; + + readonly #tx: TransactionDataBuilder; + readonly #inputCache = new Map(); + readonly #templateCommandsCache = new Map[]>(); + readonly #config: PASPackageConfig; + + constructor({ + transactionData, + objects, + templates, + templateApprovals, + vaults, + config, + }: { + transactionData: TransactionDataBuilder; + objects: Map; + templates: Map; + templateApprovals: Map; + vaults: Map; + config: PASPackageConfig; + }) { + this.#tx = transactionData; + this.objects = objects; + this.templates = templates; + this.templateApprovals = templateApprovals; + this.vaults = vaults; + this.#config = config; + } + + // -- Input helpers (deduplicated) ---------------------------------------- + + addObjectInput(objectId: string): Argument { + let arg = this.#inputCache.get(objectId); + if (!arg) { + arg = this.#tx.addInput('object', { + $kind: 'UnresolvedObject', + UnresolvedObject: { objectId }, + }); + this.#inputCache.set(objectId, arg); + } + return arg; + } + + addPureInput(key: string, value: ReturnType): Argument { + let arg = this.#inputCache.get(key); + if (!arg) { + arg = this.#tx.addInput('pure', value); + this.#inputCache.set(key, arg); + } + return arg; + } + + addTemplateInput(type: 'object' | 'pure', arg: CallArg): Argument { + if (type === 'object' && arg.$kind === 'UnresolvedObject') { + return this.addObjectInput(arg.UnresolvedObject.objectId); + } + return this.#tx.addInput(type, arg); + } + + // -- Object lookup ------------------------------------------------------- + + getObjectOrThrow(objectId: string, errorFactory: () => Error): SuiObject { + const obj = this.objects.get(objectId); + if (!obj) throw errorFactory(); + return obj; + } + + // -- Vault resolution ---------------------------------------------------- + + /** + * Returns an Argument referencing the vault for `vaultId`. + * + * - Existing on-chain vault: returns an object Input. + * - Already created earlier in this PTB: returns the stored Result ref. + * - Does not exist yet: **pushes** a `vault::create` MoveCall into the + * caller's `commands` array (mutating it) and records the creation so + * subsequent calls for the same vault reuse the same Result. The vault + * will be shared at the end of the PTB via `shareNewVaults()`. + * + * @param commands - The caller's local command array (may be mutated). + * @param baseIdx - Absolute PTB index where `commands[0]` will land. + */ + resolveVaultArg(vaultId: string, owner: string, baseIdx: number): [Argument, Command[]] { + const state = this.vaults.get(vaultId); + const commands: Command[] = []; + + if (state?.kind === 'existing') return [this.addObjectInput(vaultId), commands]; + + if (state?.kind === 'created') + return [{ $kind: 'Result', Result: state.resultIndex }, commands]; + + const absoluteIndex = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'create', + arguments: [ + this.addObjectInput(this.#config.namespaceId), + this.addPureInput(`address:${owner}`, Inputs.Pure(bcs.Address.serialize(owner))), + ], + }), + ); + + this.vaults.set(vaultId, { kind: 'created', resultIndex: absoluteIndex }); + return [{ $kind: 'Result', Result: absoluteIndex }, commands]; + } + + // -- Template resolution (synchronous, all data pre-fetched) ------------- + + resolveTemplateCommands(ruleObjectId: string, actionType: PASActionType) { + const cacheKey = `${ruleObjectId}:${actionType}`; + const cached = this.#templateCommandsCache.get(cacheKey); + if (cached) return cached; + + const approvalTypeNames = this.templateApprovals.get(cacheKey); + if (!approvalTypeNames) { + throw new PASClientError( + `No required approvals found for action "${actionType}". The issuer has not configured this action.`, + ); + } + + const templatesId = deriveTemplateRegistryAddress(this.#config); + const commands = approvalTypeNames.map((tn) => { + const templateId = deriveTemplateAddress(templatesId, tn); + const template = this.templates.get(templateId); + if (!template) { + throw new PASClientError( + `Template not found for approval type "${tn}". The issuer has not set up the template command.`, + ); + } + return getCommandFromTemplate(template); + }); + + this.#templateCommandsCache.set(cacheKey, commands); + return commands; + } + + /** + * Replaces a standard action intent (transfer/unlock) with its built + * commands. The resolve call at `actualIdx + resultOffset` produces the + * intent's output value. + */ + replaceIntent(actualIdx: number, commands: Command[], resultOffset: number) { + this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx + resultOffset }); + } + + /** + * Replaces a vaultForAddress intent when the vault already exists. + * The intent is removed (0 replacement commands) and external references + * are remapped to the existing vault's Input argument. + * + * Note: SDK's replaceCommand signature doesn't accept Input args as + * resultIndex, but the runtime handles it correctly via ArgumentSchema.parse(). + */ + replaceIntentWithExistingVault(actualIdx: number, vaultArg: Argument) { + this.#tx.replaceCommand(actualIdx, [], vaultArg as any); + } + + /** + * Replaces a vaultForAddress intent when the vault needs to be created. + * The intent is replaced with the vault::create command(s), and external + * references are remapped to the first command's Result (the new vault). + */ + replaceIntentWithCreatedVault(actualIdx: number, commands: Command[]) { + this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx }); + } + + // -- Per-action builders -------------------------------------------------- + // + // Each builder constructs a local `commands` array representing the + // sequence of MoveCall commands that replace the intent. Commands + // reference each other using absolute indices (baseIdx + local offset). + // + // The general pattern for a transfer is: + // [vault::create (0..N)] -- only if vaults don't exist yet + // vault::new_auth -- create ownership proof + // vault::transfer_funds -- initiate the request + // [approval commands] -- issuer-defined template commands + // transfer_funds::resolve -- finalize and produce the output + // + // `resultOffset` points at the last command (resolve), whose Result + // becomes the intent's output value. + + buildTransferFunds(data: TransferFundsIntentData, baseIdx: number): BuildResult { + const { from, to, assetType, amount } = data; + const fromVaultId = deriveVaultAddress(from, this.#config); + const toVaultId = deriveVaultAddress(to, this.#config); + + const ruleId = deriveRuleAddress(assetType, this.#config); + const ruleObject = this.getObjectOrThrow(ruleId, () => new RuleNotFoundError(assetType)); + const templateCmds = this.resolveTemplateCommands( + ruleObject.objectId, + PASActionType.TransferFunds, + ); + + const [toVaultArg, commands] = this.resolveVaultArg(toVaultId, to, baseIdx); + const [fromVaultArg, fromVaultCommands] = this.resolveVaultArg( + fromVaultId, + from, + baseIdx + commands.length, + ); + commands.push(...fromVaultCommands); + + const ruleArg = this.addObjectInput(ruleId); + + // vault::new_auth + const authIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'new_auth', + }), + ); + + // vault::transfer_funds + const requestIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'transfer_funds', + arguments: [ + fromVaultArg, + { $kind: 'Result', Result: authIdx }, + toVaultArg, + this.addTemplateInput('pure', Inputs.Pure(bcs.u64().serialize(BigInt(amount)))), + ], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + const requestArg: Argument = { $kind: 'Result', Result: requestIdx }; + + // Issuer-defined approval commands from templates + for (const templateCmd of templateCmds) { + commands.push( + buildMoveCallCommandFromTemplate(templateCmd, { + addInput: (type, arg) => this.addTemplateInput(type, arg), + senderVault: fromVaultArg, + receiverVault: toVaultArg, + rule: ruleArg, + request: requestArg, + systemType: assetType, + }), + ); + } + + // transfer_funds::resolve + const resultOffset = commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'transfer_funds', + function: 'resolve', + arguments: [requestArg, ruleArg], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + + return { commands, resultOffset }; + } + + /** + * Builds commands for both restricted and unrestricted unlock flows. + * Restricted: requires a Rule, runs issuer approval templates, then resolve. + * Unrestricted: no Rule needed, calls resolve_unrestricted directly. + */ + buildUnlockFunds( + data: UnlockFundsIntentData | UnlockUnrestrictedFundsIntentData, + baseIdx: number, + ): BuildResult { + const { from, assetType, amount } = data; + const fromVaultId = deriveVaultAddress(from, this.#config); + const ruleId = deriveRuleAddress(assetType, this.#config); + + const isRestricted = data.action === 'unlockFunds'; + + if (isRestricted) { + this.getObjectOrThrow( + ruleId, + () => + new PASClientError( + `Rule does not exist for asset type ${assetType}. ` + + `That means that the issuer has not yet enabled funds management for this asset. ` + + `If this is a non-managed asset, you can use the unrestricted unlock flow by calling unlockUnrestrictedFunds() instead.`, + ), + ); + } else { + if (this.objects.get(ruleId) !== null) { + throw new PASClientError( + `A rule exists for asset type ${assetType}. That means that the issuer has enabled funds management for this asset and you can no longer use the unrestricted unlock flow.`, + ); + } + } + + const [fromVaultArg, commands] = this.resolveVaultArg(fromVaultId, from, baseIdx); + const ruleArg = isRestricted ? this.addObjectInput(ruleId) : undefined; + + // vault::new_auth + const authIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'new_auth', + }), + ); + + // vault::unlock_funds + const requestIdx = baseIdx + commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'unlock_funds', + arguments: [ + fromVaultArg, + { $kind: 'Result', Result: authIdx }, + this.addTemplateInput('pure', Inputs.Pure(bcs.u64().serialize(BigInt(amount)))), + ], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + const requestArg: Argument = { $kind: 'Result', Result: requestIdx }; + + if (isRestricted) { + // Issuer-defined approval commands from templates + const templateCmds = this.resolveTemplateCommands(ruleId, PASActionType.UnlockFunds); + for (const templateCmd of templateCmds) { + commands.push( + buildMoveCallCommandFromTemplate(templateCmd, { + addInput: (type, arg) => this.addTemplateInput(type, arg), + senderVault: fromVaultArg, + rule: ruleArg, + request: requestArg, + systemType: assetType, + }), + ); + } + + // unlock_funds::resolve + const resultOffset = commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'unlock_funds', + function: 'resolve', + arguments: [requestArg, ruleArg!], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + return { commands, resultOffset }; + } + + // unlock_funds::resolve_unrestricted + const resultOffset = commands.length; + commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'unlock_funds', + function: 'resolve_unrestricted', + arguments: [requestArg, this.addObjectInput(this.#config.namespaceId)], + typeArguments: [normalizeStructTag(assetType)], + }), + ); + return { commands, resultOffset }; + } + + // -- Finalization --------------------------------------------------------- + + /** + * Appends `vault::share` commands for every vault that was created during + * resolution. Called once at the end, after all intents have been resolved, + * so that each vault is shared exactly once regardless of how many intents + * referenced it. + */ + shareNewVaults() { + for (const state of this.vaults.values()) { + if (state.kind !== 'created') continue; + this.#tx.commands.push( + TransactionCommands.MoveCall({ + package: this.#config.packageId, + module: 'vault', + function: 'share', + arguments: [{ $kind: 'Result', Result: state.resultIndex }], + }), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Data collection + fetching (pre-resolution) +// --------------------------------------------------------------------------- + +type VaultOwner = { owner: string }; + +interface IntentDataCollection { + objectIds: Set; + vaultRequests: Map; + intentDataList: PASIntentData[]; + cfg: PASPackageConfig; +} + +/** Scans commands for PAS intents and collects the object IDs we need to fetch. */ +function collectIntentData(commands: readonly Command[]): IntentDataCollection | null { + const objectIds = new Set(); + const vaultRequests = new Map(); + const intentDataList: PASIntentData[] = []; + let cfg: PASPackageConfig | null = null; + + for (const command of commands) { + if (command.$kind !== '$Intent' || command.$Intent.name !== PAS_INTENT_NAME) continue; + const data = command.$Intent.data as unknown as PASIntentData; + + if (!cfg) cfg = data.cfg; + intentDataList.push(data); + + switch (data.action) { + case 'transferFunds': { + const fromId = deriveVaultAddress(data.from, cfg); + const toId = deriveVaultAddress(data.to, cfg); + objectIds.add(fromId); + objectIds.add(toId); + objectIds.add(deriveRuleAddress(data.assetType, cfg)); + vaultRequests.set(fromId, { owner: data.from }); + vaultRequests.set(toId, { owner: data.to }); + break; + } + case 'unlockFunds': + case 'unlockUnrestrictedFunds': { + const fromId = deriveVaultAddress(data.from, cfg); + objectIds.add(fromId); + objectIds.add(deriveRuleAddress(data.assetType, cfg)); + vaultRequests.set(fromId, { owner: data.from }); + break; + } + case 'vaultForAddress': { + const id = deriveVaultAddress(data.owner, cfg); + objectIds.add(id); + vaultRequests.set(id, { owner: data.owner }); + break; + } + } + } + + if (!cfg) + throw new PASClientError('No package configuration found in intents. This is an internal bug.'); + + return intentDataList.length > 0 ? { objectIds, vaultRequests, intentDataList, cfg } : null; +} + +async function initializeContext( + transactionData: TransactionDataBuilder, + client: ClientWithCoreApi, + objectIds: Set, + vaultRequests: Map, + intentDataList: PASIntentData[], + config: PASPackageConfig, +): Promise { + // 1. Batch-fetch all vaults + rules + const allIds = [...objectIds]; + const { objects: fetched } = await client.core.getObjects({ + objectIds: allIds, + include: { content: true }, + }); + + const objects = new Map(); + + for (const id of allIds) { + const obj = fetched.filter((o) => 'content' in o).find((o) => o.objectId === id); + objects.set(id, obj ?? null); + } + + // 2. Build initial vault map (existing vs needs-creation) + const vaults = new Map(); + for (const [vaultId] of vaultRequests) { + if (objects.get(vaultId) !== null) { + vaults.set(vaultId, { kind: 'existing' }); + } + } + + // 3. Collect template DF IDs by parsing rules + const templateApprovals = new Map(); + const templateIds: string[] = []; + const seen = new Set(); + + for (const data of intentDataList) { + let actionType: PASActionType | null = null; + let assetType: string | null = null; + + if (data.action === 'transferFunds') { + actionType = PASActionType.TransferFunds; + assetType = data.assetType; + } else if (data.action === 'unlockFunds') { + actionType = PASActionType.UnlockFunds; + assetType = data.assetType; + } + + if (!actionType || !assetType) continue; + + const ruleId = deriveRuleAddress(assetType, config); + const key = `${ruleId}:${actionType}`; + if (seen.has(key)) continue; + seen.add(key); + + const ruleObject = objects.get(ruleId); + if (!ruleObject) continue; + + const approvalTypeNames = getRequiredApprovals(ruleObject, actionType); + if (!approvalTypeNames?.length) continue; + + const templatesId = deriveTemplateRegistryAddress(config); + templateApprovals.set(key, approvalTypeNames); + templateIds.push(...approvalTypeNames.map((tn) => deriveTemplateAddress(templatesId, tn))); + } + + // 4. Batch-fetch all template data + const templates = new Map(); + if (templateIds.length > 0) { + const { objects: templateObjects } = await client.core.getObjects({ + objectIds: templateIds, + include: { content: true }, + }); + + for (const obj of templateObjects.filter((o) => 'content' in o)) { + templates.set(obj.objectId, obj); + } + } + + return new Resolver({ + transactionData, + objects, + templates, + templateApprovals, + vaults, + config, + }); +} + +const resolvePASIntents: TransactionPlugin = async (transactionData, buildOptions, next) => { + const client = buildOptions.client; + if (!client) + throw new PASClientError( + 'A SuiClient must be provided to build transactions with PAS intents.', + ); + + const requirements = collectIntentData(transactionData.commands); + if (!requirements) return next(); + + const { objectIds, vaultRequests, intentDataList, cfg } = requirements; + + const ctx = await initializeContext( + transactionData, + client, + objectIds, + vaultRequests, + intentDataList, + cfg, + ); + + // Iterate the live command list. replaceCommand mutates the array in place + // and shifts all subsequent indices automatically, so we don't need to + // track index offsets ourselves -- the iterator sees correct positions. + for (const [index, command] of transactionData.commands.entries()) { + if (command.$kind !== '$Intent' || command.$Intent.name !== PAS_INTENT_NAME) continue; + + const data = command.$Intent.data as unknown as PASIntentData; + + // -- vaultForAddress is handled separately (may produce 0 commands) -- + if (data.action === 'vaultForAddress') { + const vaultId = deriveVaultAddress(data.owner, cfg); + const [vaultArg, commands] = ctx.resolveVaultArg(vaultId, data.owner, index); + + if (commands.length === 0) { + ctx.replaceIntentWithExistingVault(index, vaultArg); + } else { + ctx.replaceIntentWithCreatedVault(index, commands); + } + continue; + } + + // -- Standard action intents -- + let result: BuildResult; + switch (data.action) { + case 'transferFunds': + result = ctx.buildTransferFunds(data, index); + break; + case 'unlockFunds': + case 'unlockUnrestrictedFunds': + result = ctx.buildUnlockFunds(data, index); + break; + default: + continue; + } + + ctx.replaceIntent(index, result.commands, result.resultOffset); + } + + ctx.shareNewVaults(); + return next(); +}; diff --git a/sdk/pas/src/resolution.ts b/sdk/pas/src/resolution.ts index 037f2ed..eb9cb1d 100644 --- a/sdk/pas/src/resolution.ts +++ b/sdk/pas/src/resolution.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { SuiClientTypes } from '@mysten/sui/client'; -import { type Transaction, type TransactionObjectArgument } from '@mysten/sui/transactions'; +import { Inputs, TransactionCommands } from '@mysten/sui/transactions'; +import type { Argument, CallArg, Command as SdkCommand } from '@mysten/sui/transactions'; import { normalizeStructTag } from '@mysten/sui/utils'; import { Field } from './bcs.js'; @@ -60,16 +61,16 @@ export function getRequiredApprovals( * @param templateDF - The Template DF object fetched with content * @returns The parsed Command, or undefined if parsing fails */ -export function getCommandFromTemplateDF( - templateDF: SuiClientTypes.Object<{ content: true }>, +export function getCommandFromTemplate( + template: SuiClientTypes.Object<{ content: true }>, ): ReturnType { - const df = Field(TypeName, Command).parse(templateDF.content); + const df = Field(TypeName, Command).parse(template.content); return parseCommand(df.value); } // TODO: Discuss why this is interpreted as `(number | number[])[])` instead of `[number, number[]]` // and if there's a way to solve that. -export function parseCommand([key, cmd]: ReturnType) { +function parseCommand([key, cmd]: ReturnType) { // Support only `Command` for now. if (key !== 0) throw new Error(`Unknown command type: ${key}`); @@ -77,48 +78,50 @@ export function parseCommand([key, cmd]: ReturnType) { return MoveCall.parse(new Uint8Array(cmd as number[])); } +// --------------------------------------------------------------------------- +// Command builder (for use with TransactionDataBuilder / replaceCommand) +// --------------------------------------------------------------------------- + /** - * Context provided when building a PTB from a command + * Arguments for building a MoveCall Command from a template. + * Used by the intent resolver which works directly with TransactionDataBuilder. */ -export interface CommandBuildContext { - /** The transaction builder */ - tx: Transaction; - /** The sender vault (for transfers/unlocks) */ - senderVault?: TransactionObjectArgument; - /** The receiver vault (for transfers) */ - receiverVault?: TransactionObjectArgument; - /** The rule object */ - rule?: TransactionObjectArgument; - /** The transfer/unlock request */ - request?: TransactionObjectArgument; +interface RawCommandBuildArgs { + /** Adds an input to the parent transaction and returns the Argument ref. */ + addInput: (type: 'object' | 'pure', arg: CallArg) => Argument; + /** The sender vault argument (already resolved) */ + senderVault?: Argument; + /** The receiver vault argument (already resolved) */ + receiverVault?: Argument; + /** The rule argument (already resolved) */ + rule?: Argument; + /** The request argument (already resolved) */ + request?: Argument; /** The system type T (e.g., "0x2::sui::SUI") */ systemType?: string; - /** Additional custom arguments */ - customArgs?: Map; } /** - * Adds the `tx.moveCall()` as it is resolved from `Command`. + * Builds a `Command` (TransactionCommands.MoveCall) from a parsed template command, + * suitable for use with `transactionData.replaceCommand()`. * - * This function translates the Command structure into actual moveCall operations - * in the transaction, resolving placeholders like "sender_vault", "receiver_vault", etc. + * Resolves template argument placeholders (pas:request, pas:rule, etc.) into + * concrete Argument references, and converts object/pure inputs via the provided + * `addInput` callback. * - * @param command - The parsed Command object - * @param context - The build context with required objects - * @returns The result of the moveCall + * @param command - The parsed MoveCall from a template object + * @param args - The resolved arguments and addInput helper + * @returns A Command object ready for `replaceCommand` */ -export function addMoveCallFromCommand( +export function buildMoveCallCommandFromTemplate( command: ReturnType, - context: CommandBuildContext, -) { - const { tx } = context; - - // Resolve arguments - const resolvedArgs: TransactionObjectArgument[] = []; + args: RawCommandBuildArgs, +): SdkCommand { + const resolvedArgs: Argument[] = []; for (const arg of command.arguments) { if (arg.Ext) throw new PASClientError(`There are no supported ext arguments in this client.`); - else if (arg.GasCoin) resolvedArgs.push(tx.gas); + else if (arg.GasCoin) resolvedArgs.push({ $kind: 'GasCoin', GasCoin: true }); else if (arg.NestedResult) resolvedArgs.push({ $kind: 'NestedResult', @@ -126,34 +129,44 @@ export function addMoveCallFromCommand( }); else if (arg.Result) resolvedArgs.push({ $kind: 'Result', Result: arg.Result }); else if (arg.Input) { - if (arg.Input.Pure) resolvedArgs.push(tx.pure(new Uint8Array(arg.Input.Pure))); + if (arg.Input.Pure) + resolvedArgs.push(args.addInput('pure', Inputs.Pure(new Uint8Array(arg.Input.Pure)))); else if (arg.Input.Object) { switch (arg.Input.Object.$kind) { case 'ImmOrOwnedObject': resolvedArgs.push( - tx.objectRef({ - objectId: arg.Input.Object.ImmOrOwnedObject.object_id, - version: arg.Input.Object.ImmOrOwnedObject.sequence_number, - digest: arg.Input.Object.ImmOrOwnedObject.digest, - }), + args.addInput( + 'object', + Inputs.ObjectRef({ + objectId: arg.Input.Object.ImmOrOwnedObject.object_id, + version: arg.Input.Object.ImmOrOwnedObject.sequence_number, + digest: arg.Input.Object.ImmOrOwnedObject.digest, + }), + ), ); break; case 'SharedObject': resolvedArgs.push( - tx.sharedObjectRef({ - objectId: arg.Input.Object.SharedObject.object_id, - initialSharedVersion: arg.Input.Object.SharedObject.initial_shared_version, - mutable: arg.Input.Object.SharedObject.is_mutable, - }), + args.addInput( + 'object', + Inputs.SharedObjectRef({ + objectId: arg.Input.Object.SharedObject.object_id, + initialSharedVersion: arg.Input.Object.SharedObject.initial_shared_version, + mutable: arg.Input.Object.SharedObject.is_mutable, + }), + ), ); break; case 'Receiving': resolvedArgs.push( - tx.receivingRef({ - objectId: arg.Input.Object.Receiving.object_id, - version: arg.Input.Object.Receiving.sequence_number, - digest: arg.Input.Object.Receiving.digest, - }), + args.addInput( + 'object', + Inputs.ReceivingRef({ + objectId: arg.Input.Object.Receiving.object_id, + version: arg.Input.Object.Receiving.sequence_number, + digest: arg.Input.Object.Receiving.digest, + }), + ), ); break; case 'Ext': @@ -162,7 +175,12 @@ export function addMoveCallFromCommand( switch (kind) { case OBJECT_BY_ID_EXT: case RECEIVING_BY_ID_EXT: - resolvedArgs.push(tx.object(value)); + resolvedArgs.push( + args.addInput('object', { + $kind: 'UnresolvedObject', + UnresolvedObject: { objectId: value }, + } as CallArg), + ); break; case OBJECT_BY_TYPE_EXT: throw new PASClientError( @@ -178,48 +196,46 @@ export function addMoveCallFromCommand( ); } } else if (arg.Input.Ext) { - resolvedArgs.push(resolvePasRequest(context, arg.Input.Ext)); + resolvedArgs.push(resolveRawPasRequest(args, arg.Input.Ext)); } else { throw new PASClientError(`Unsupported input kind: ${arg.Input.$kind}`); } } } - // Resolve type arguments const typeArgs: string[] = []; for (const typeArg of command.type_arguments) typeArgs.push(normalizeStructTag(typeArg).toString()); - // Build the moveCall if (!command.module_name || !command.function) throw new PASClientError( 'Module name or function name is missing from the on-chain rule. This means that the issuer has not set up the rule correctly.', ); - return tx.moveCall({ - target: `${command.package_id}::${command.module_name}::${command.function}`, + return TransactionCommands.MoveCall({ + package: command.package_id, + module: command.module_name, + function: command.function, arguments: resolvedArgs, typeArguments: typeArgs.length > 0 ? typeArgs : [], }); } -/// Handle the special resolvers for PAS. -/// This includes the `rul`, the `request`, the `sender_vault` as well as the `receiver_vault`. -function resolvePasRequest(context: CommandBuildContext, value: string) { +function resolveRawPasRequest(args: RawCommandBuildArgs, value: string): Argument { switch (value) { case 'pas:request': - if (!context.request) throw new PASClientError(`Request is not set in the context.`); - return context.request; + if (!args.request) throw new PASClientError(`Request is not set in the context.`); + return args.request; case 'pas:rule': - if (!context.rule) throw new PASClientError(`Rule is not set in the context.`); - return context.rule; + if (!args.rule) throw new PASClientError(`Rule is not set in the context.`); + return args.rule; case 'pas:sender_vault': - if (!context.senderVault) throw new PASClientError(`Sender vault is not set in the context.`); - return context.senderVault; + if (!args.senderVault) throw new PASClientError(`Sender vault is not set in the context.`); + return args.senderVault; case 'pas:receiver_vault': - if (!context.receiverVault) + if (!args.receiverVault) throw new PASClientError(`Receiver vault is not set in the context.`); - return context.receiverVault; + return args.receiverVault; default: throw new PASClientError(`Unknown pas request: ${value}`); } diff --git a/sdk/pas/test/e2e/data/demo_usd/Move.lock b/sdk/pas/test/e2e/data/demo_usd/Move.lock new file mode 100644 index 0000000..381b097 --- /dev/null +++ b/sdk/pas/test/e2e/data/demo_usd/Move.lock @@ -0,0 +1,35 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "868c226359ef914f1f3b080518f27eb13d8967f5" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } + +[pinned.testnet.demo_usd] +source = { root = true } +use_environment = "testnet" +manifest_digest = "F3F3BE825FCACCADB2ECE4ADCDD2DA4CD2C8D0DDAC32D4F23CBCE3F2760282C5" +deps = { pas = "pas", ptb = "ptb", std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.pas] +source = { local = "../pas" } +use_environment = "testnet" +manifest_digest = "38AA62656ABE7551C444DA427ADBAA7751CB67250663D39FCDE36E938138EA7D" +deps = { ptb = "ptb", std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.ptb] +source = { local = "../ptb" } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } diff --git a/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move b/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move index 6178389..20f4266 100644 --- a/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move +++ b/sdk/pas/test/e2e/data/demo_usd/sources/demo_usd.move @@ -15,7 +15,6 @@ use pas::templates::Templates; use pas::transfer_funds::TransferFunds; use ptb::ptb; use std::type_name; -use sui::accumulator::AccumulatorRoot; use sui::balance::Balance; use sui::clock::Clock; use sui::coin::TreasuryCap; @@ -105,7 +104,7 @@ public fun use_v2(rule: &mut Rule, templates: &mut Templates, faucet: type_name::with_defining_ids().address_string().to_string(), "demo_usd", "approve_transfer_v2", - vector[ptb::ext_input("pas:request"), ptb::object_by_id(@0xacc.to_id())], + vector[ptb::ext_input("pas:request"), ptb::object_by_id(object::id(faucet))], vector[], ); @@ -125,10 +124,7 @@ public fun approve_transfer(request: &mut Request>, _clock: } /// V2 function allows all transfers, besides transferring to 0x2. -public fun approve_transfer_v2( - request: &mut Request>, - _acc: &AccumulatorRoot, -) { +public fun approve_transfer_v2(request: &mut Request>, _faucet: &Faucet) { assert!(request.data().recipient() != @0x2, ENotAllowedRecipient); request.approve(TransferApprovalV2()); } diff --git a/sdk/pas/test/e2e/demoUsd.ts b/sdk/pas/test/e2e/demoUsd.ts index 4504533..3416ba8 100644 --- a/sdk/pas/test/e2e/demoUsd.ts +++ b/sdk/pas/test/e2e/demoUsd.ts @@ -1,13 +1,15 @@ import { Transaction } from '@mysten/sui/transactions'; -import { type PublishedPackage, type TestToolbox } from './setup.ts'; +import { execSuiTools, type PublishedPackage, type TestToolbox } from './setup.ts'; export class DemoUsdTestHelpers { toolbox: TestToolbox; #publicationData: PublishedPackage; + #cacheKey: string; - constructor(toolbox: TestToolbox) { + constructor(toolbox: TestToolbox, cacheKey?: string) { this.toolbox = toolbox; + this.#cacheKey = cacheKey ?? 'demo_usd'; } get pub() { @@ -23,18 +25,26 @@ export class DemoUsdTestHelpers { return this.#publicationData; } - const result = await this.toolbox.publishPackage('demo_usd'); + // When using a custom cache key, copy the source to a unique container + // directory so test-publish treats it as a separate package instance. + let packagePath = 'demo_usd'; + if (this.#cacheKey !== 'demo_usd') { + await execSuiTools(['cp', '-r', '/test-data/demo_usd', `/test-data/${this.#cacheKey}`]); + packagePath = this.#cacheKey; + } + + const result = await this.toolbox.publishPackage(packagePath, this.#cacheKey); this.#publicationData = result; const faucetId = result.createdObjects.find((o) => o.type.endsWith('demo_usd::Faucet'))!.id; - const templatesId = this.toolbox.client.pas.deriveTemplatesAddress(); + const templateRegistryId = this.toolbox.client.pas.deriveTemplateRegistryAddress(); const transaction = new Transaction(); transaction.moveCall({ target: `${result.originalId}::demo_usd::setup`, arguments: [ transaction.object(this.toolbox.client.pas.getPackageConfig().namespaceId), - transaction.object(templatesId), + transaction.object(templateRegistryId), transaction.object(faucetId), ], }); @@ -65,6 +75,19 @@ export class DemoUsdTestHelpers { await this.toolbox.executeTransaction(transaction); } + async upgradeToV2() { + const ruleId = this.toolbox.client.pas.deriveRuleAddress(this.demoUsdAssetType); + const templateRegistryId = this.toolbox.client.pas.deriveTemplateRegistryAddress(); + const faucetId = this.pub.createdObjects.find((o) => o.type.endsWith('demo_usd::Faucet'))!.id; + + const tx = new Transaction(); + tx.moveCall({ + target: `${this.pub.originalId}::demo_usd::use_v2`, + arguments: [tx.object(ruleId), tx.object(templateRegistryId), tx.object(faucetId)], + }); + await this.toolbox.executeTransaction(tx); + } + get demoUsdAssetType() { return `${this.pub.originalId}::demo_usd::DEMO_USD`; } diff --git a/sdk/pas/test/e2e/e2e.isolated.test.ts b/sdk/pas/test/e2e/e2e.isolated.test.ts index e7249b7..811f5f2 100644 --- a/sdk/pas/test/e2e/e2e.isolated.test.ts +++ b/sdk/pas/test/e2e/e2e.isolated.test.ts @@ -1,199 +1,494 @@ import { Transaction } from '@mysten/sui/transactions'; import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { DemoUsdTestHelpers } from './demoUsd.ts'; -import { setupToolbox, TestToolbox } from './setup.ts'; +import { setupToolbox, simulateFailingTransaction, type TestToolbox } from './setup.ts'; + +async function expectBalances( + toolbox: TestToolbox, + expected: { vault: string; asset: string; amount: number }[], +) { + const balances = await Promise.all( + expected.map(({ vault, asset }) => toolbox.getBalance(vault, asset)), + ); + for (const [idx, { amount }] of expected.entries()) { + expect(Number(balances[idx].balance.balance)).toBe(amount * 1_000_000); + } +} + +describe.concurrent( + 'e2e tests with isolated PAS Package (each test runs in its own PAS package)', + () => { + it('unlocks non-managed funds (e.g. SUI), but only through the unrestricted unlock flow', async () => { + const toolbox = await setupToolbox(); + const vaultId = toolbox.client.pas.deriveVaultAddress(toolbox.address()); + + const suiTypeName = normalizeStructTag('0x2::sui::SUI').toString(); + + const { balance } = await toolbox.getBalance(vaultId, suiTypeName); + expect(Number(balance.balance)).toBe(0); + + // Transfer 1 SUI to the vault. + const fundTransferTx = new Transaction(); + const sui = fundTransferTx.splitCoins(fundTransferTx.gas, [ + fundTransferTx.pure.u64(1_000_000_000), + ]); + + const into_balance = fundTransferTx.moveCall({ + target: '0x2::coin::into_balance', + arguments: [sui], + typeArguments: [suiTypeName], + }); + fundTransferTx.moveCall({ + target: '0x2::balance::send_funds', + arguments: [into_balance, fundTransferTx.pure.address(vaultId)], + typeArguments: [suiTypeName], + }); + await toolbox.executeTransaction(fundTransferTx); + + // Create the vault for the address. + await toolbox.createVaultForAddress(toolbox.address()); + + const { balance: vaultBalanceAfterTransfer } = await toolbox.getBalance(vaultId, suiTypeName); + expect(Number(vaultBalanceAfterTransfer.balance)).toBe(1_000_000_000); + + // try to do an unlock but it should fail because `rule` for Sui does not exist. + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.unlockFunds({ + from: toolbox.address(), + amount: 1_000_000_000, + assetType: suiTypeName, + }), + ); + // Should fail because SUI is not a managed asset + await expect(toolbox.executeTransaction(tx)).rejects.toThrowError( + 'Rule does not exist for asset type ', + ); + + // Now let's unlock funds properly. + const unlockTx = new Transaction(); + const withdrawal = unlockTx.add( + toolbox.client.pas.tx.unlockUnrestrictedFunds({ + from: toolbox.address(), + amount: 1_000_000_000, + assetType: suiTypeName, + }), + ); + unlockTx.moveCall({ + target: '0x2::balance::send_funds', + arguments: [withdrawal, unlockTx.pure.address(toolbox.address())], + typeArguments: [suiTypeName], + }); + + await toolbox.executeTransaction(unlockTx); + + const { balance: vaultBalanceAfterUnlock } = await toolbox.getBalance(vaultId, suiTypeName); + expect(Number(vaultBalanceAfterUnlock.balance)).toBe(0); + }); -describe('e2e tests with isolated PAS Package (each test runs in its own PAS package)', () => { - let toolbox: TestToolbox; + it('Should be able to transfer between vaults, going through the rule of the issuer;', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); - // Each execution should use its own runner to avoid shared state of PAS package. - beforeEach(async () => { - toolbox = await setupToolbox(); - }); + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); - it('unlocks non-managed funds (e.g. SUI), but only through the unrestricted unlock flow', async () => { - const vaultId = toolbox.client.pas.deriveVaultAddress(toolbox.address()); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + const toVaultId = toolbox.client.pas.deriveVaultAddress(to); - const suiTypeName = normalizeStructTag('0x2::sui::SUI').toString(); + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); - const { balance } = await toolbox.getBalance(vaultId, suiTypeName); - expect(Number(balance.balance)).toBe(0); + await demoUsd.mintFromFaucetInto(100, fromVaultId); - // Transfer 1 SUI to the vault. - const fundTransferTx = new Transaction(); - const sui = fundTransferTx.splitCoins(fundTransferTx.gas, [ - fundTransferTx.pure.u64(1_000_000_000), - ]); + const [{ balance: fromBalanceBefore }, { balance: toBalanceBefore }] = await Promise.all([ + toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), + ]); - const into_balance = fundTransferTx.moveCall({ - target: '0x2::coin::into_balance', - arguments: [sui], - typeArguments: [suiTypeName], - }); - fundTransferTx.moveCall({ - target: '0x2::balance::send_funds', - arguments: [into_balance, fundTransferTx.pure.address(vaultId)], - typeArguments: [suiTypeName], - }); - await toolbox.executeTransaction(fundTransferTx); - - // Create the vault for the address. - await toolbox.createVaultForAddress(toolbox.address()); - - const { balance: vaultBalanceAfterTransfer } = await toolbox.getBalance(vaultId, suiTypeName); - expect(Number(vaultBalanceAfterTransfer.balance)).toBe(1_000_000_000); - - // try to do an unlock but it should fail because `rule` for Sui does not exist. - const tx = new Transaction(); - tx.add( - toolbox.client.pas.tx.unlockFunds({ - from: toolbox.address(), - amount: 1_000_000_000, - assetType: suiTypeName, - }), - ); - // Should fail because SUI is not a managed asset - await expect(toolbox.executeTransaction(tx)).rejects.toThrowError( - 'Rule does not exist for asset type ', - ); - - // Now let's unlock funds properly. - const unlockTx = new Transaction(); - const withdrawal = unlockTx.add( - toolbox.client.pas.tx.unlockUnrestrictedFunds({ - from: toolbox.address(), - amount: 1_000_000_000, - assetType: suiTypeName, - }), - ); - unlockTx.moveCall({ - target: '0x2::balance::send_funds', - arguments: [withdrawal, unlockTx.pure.address(toolbox.address())], - typeArguments: [suiTypeName], - }); + expect(Number(fromBalanceBefore.balance)).toBe(100 * 1_000_000); + expect(Number(toBalanceBefore.balance)).toBe(0); - await toolbox.executeTransaction(unlockTx); + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 100 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); - const { balance: vaultBalanceAfterUnlock } = await toolbox.getBalance(vaultId, suiTypeName); - expect(Number(vaultBalanceAfterUnlock.balance)).toBe(0); - }); + await toolbox.executeTransaction(tx); - it('Should be able to transfer between vaults, going through the rule of the issuer;', async () => { - const demoUsd = new DemoUsdTestHelpers(toolbox); - await demoUsd.createRule(); + const [{ balance: fromBalanceAfter }, { balance: toBalanceAfter }] = await Promise.all([ + toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), + ]); - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); + expect(Number(fromBalanceAfter.balance)).toBe(0); + expect(Number(toBalanceAfter.balance)).toBe(100 * 1_000_000); + }); - const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); - const toVaultId = toolbox.client.pas.deriveVaultAddress(to); + it('Should be able to create the recipient vault if it does not exist ahead of time', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); - await toolbox.createVaultForAddress(from); - await toolbox.createVaultForAddress(to); + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); - await demoUsd.mintFromFaucetInto(100, fromVaultId); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + const toVaultId = toolbox.client.pas.deriveVaultAddress(to); - const [{ balance: fromBalanceBefore }, { balance: toBalanceBefore }] = await Promise.all([ - toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), - toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), - ]); + await demoUsd.mintFromFaucetInto(100, fromVaultId); + await toolbox.createVaultForAddress(from); - expect(Number(fromBalanceBefore.balance)).toBe(100 * 1_000_000); - expect(Number(toBalanceBefore.balance)).toBe(0); + await expect( + toolbox.client.core.getObject({ + objectId: toVaultId, + }), + ).rejects.toThrowError('not found'); - const tx = new Transaction(); - tx.add( - toolbox.client.pas.tx.transferFunds({ - from, - to, - amount: 100 * 1_000_000, - assetType: demoUsd.demoUsdAssetType, - }), - ); + const transaction = new Transaction(); + transaction.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); - await toolbox.executeTransaction(tx); + await toolbox.executeTransaction(transaction); - const [{ balance: fromBalanceAfter }, { balance: toBalanceAfter }] = await Promise.all([ - toolbox.getBalance(fromVaultId, demoUsd.demoUsdAssetType), - toolbox.getBalance(toVaultId, demoUsd.demoUsdAssetType), - ]); + // Object should now exist after the first transfer. + const responseAfter = await toolbox.client.core.getObject({ + objectId: toVaultId, + }); - expect(Number(fromBalanceAfter.balance)).toBe(0); - expect(Number(toBalanceAfter.balance)).toBe(100 * 1_000_000); - }); + expect(responseAfter.object).toBeDefined(); + }); - it('Should be able to create the recipient vault if it does not exist ahead of time', async () => { - const demoUsd = new DemoUsdTestHelpers(toolbox); - await demoUsd.createRule(); + it('Should deduplicate vault creation when multiple intents reference the same non-existent vaults', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + // Sender is the test keypair (required for Auth), receiver is fresh. + const sender = toolbox.address(); + const receiver = normalizeSuiAddress('0xB2'); + + const senderVaultId = toolbox.client.pas.deriveVaultAddress(sender); + const receiverVaultId = toolbox.client.pas.deriveVaultAddress(receiver); + + // Verify neither vault exists. + await expect(toolbox.client.core.getObject({ objectId: senderVaultId })).rejects.toThrowError( + 'not found', + ); + await expect( + toolbox.client.core.getObject({ objectId: receiverVaultId }), + ).rejects.toThrowError('not found'); + + // Mint funds directly into the sender vault's address (balance::send_funds + // works even before the vault object exists). + await demoUsd.mintFromFaucetInto(200, senderVaultId); + + // Build a single PTB that: + // 1. Implicitly creates the sender vault (via vaultForAddress) + // 2. Has an intermediate non-PAS moveCall (a no-op) + // 3. Transfers 50 DEMO_USD from sender -> receiver (receiver vault created implicitly) + // 4. Has another intermediate non-PAS moveCall + // 5. Transfers another 50 DEMO_USD from sender -> receiver (same vaults, no re-creation) + const tx = new Transaction(); + + // (1) vaultForAddress for sender -- forces implicit creation + tx.add(toolbox.client.pas.tx.vaultForAddress(sender)); + + // (2) Intermediate command: a harmless moveCall (merge empty split back into gas) + const split1 = tx.splitCoins(tx.gas, [tx.pure.u64(0)]); + tx.mergeCoins(tx.gas, [split1]); + + // (3) First transfer: sender -> receiver (receiver vault does not exist) + tx.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 50 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + // (4) Another intermediate command + const split2 = tx.splitCoins(tx.gas, [tx.pure.u64(0)]); + tx.mergeCoins(tx.gas, [split2]); + + // (5) Second transfer: sender -> receiver (both vaults already created in this PTB) + tx.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 50 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + await toolbox.executeTransaction(tx); + + // Verify both vaults now exist. + const [senderObj, receiverObj] = await Promise.all([ + toolbox.client.core.getObject({ objectId: senderVaultId }), + toolbox.client.core.getObject({ objectId: receiverVaultId }), + ]); + expect(senderObj.object).toBeDefined(); + expect(receiverObj.object).toBeDefined(); + + // Verify balances: sender started with 200, transferred 50+50 = 100. + const [{ balance: senderBalance }, { balance: receiverBalance }] = await Promise.all([ + toolbox.getBalance(senderVaultId, demoUsd.demoUsdAssetType), + toolbox.getBalance(receiverVaultId, demoUsd.demoUsdAssetType), + ]); + + expect(Number(senderBalance.balance)).toBe(100 * 1_000_000); + expect(Number(receiverBalance.balance)).toBe(100 * 1_000_000); + }); - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); + it('v1 approval rejects transfers over 10K', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x3'); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + await demoUsd.mintFromFaucetInto(15_000, fromVaultId); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 15_000 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await simulateFailingTransaction(toolbox, tx); + expect(resp.FailedTransaction).toBeDefined(); + expect(resp.FailedTransaction!.effects.status.error!.message).toContain( + 'Any amount over 10K is not allowed in this demo.', + ); + }); - const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); - const toVaultId = toolbox.client.pas.deriveVaultAddress(to); + it('self-transfer is rejected (same vault cannot be borrowed mutably twice)', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const addr = toolbox.address(); + const vaultId = toolbox.client.pas.deriveVaultAddress(addr); + + await toolbox.createVaultForAddress(addr); + await demoUsd.mintFromFaucetInto(10, vaultId); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from: addr, + to: addr, + amount: 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await simulateFailingTransaction(toolbox, tx); + expect(resp.FailedTransaction).toBeDefined(); + // Same vault passed as both &mut sender and &mut receiver -- Move rejects + // this before the approval function even runs. + expect(resp.FailedTransaction!.effects.status.error!.message).toContain( + 'InvalidReferenceArgument', + ); + }); - await demoUsd.mintFromFaucetInto(100, fromVaultId); - await toolbox.createVaultForAddress(from); + it('Should fail to transfer between vaults, if there are not enough funds in the source vault', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + + const transaction = new Transaction(); + transaction.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 100 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await toolbox.client.signAndExecuteTransaction({ + signer: toolbox.keypair, + transaction, + include: { + effects: true, + }, + }); + + expect(resp.FailedTransaction).toBeDefined(); + expect(resp.FailedTransaction!.effects.status.error!.message).toEqual( + 'InsufficientFundsForWithdraw', + ); + }); - await expect( - toolbox.client.core.getObject({ - objectId: toVaultId, - }), - ).rejects.toThrowError('not found'); - - const transaction = new Transaction(); - transaction.add( - toolbox.client.pas.tx.transferFunds({ - from, - to, - amount: 1_000_000, - assetType: demoUsd.demoUsdAssetType, - }), - ); - - await toolbox.executeTransaction(transaction); - - // Object should now exist after the first transfer. - const responseAfter = await toolbox.client.core.getObject({ - objectId: toVaultId, + it('use_v2 upgrades approval logic and the resolver picks up the new template', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x3'); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + await demoUsd.mintFromFaucetInto(15_000, fromVaultId); + + await demoUsd.upgradeToV2(); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 15_000 * 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + await toolbox.executeTransaction(tx); + + const { balance } = await toolbox.getBalance( + toolbox.client.pas.deriveVaultAddress(to), + demoUsd.demoUsdAssetType, + ); + expect(Number(balance.balance)).toBe(15_000 * 1_000_000); }); - expect(responseAfter.object).toBeDefined(); - }); - - it('Should fail to transfer between vaults, if there are not enough funds in the source vault', async () => { - const demoUsd = new DemoUsdTestHelpers(toolbox); - await demoUsd.createRule(); - - const from = toolbox.address(); - const to = normalizeSuiAddress('0x2'); - - await toolbox.createVaultForAddress(from); - await toolbox.createVaultForAddress(to); - - const transaction = new Transaction(); - transaction.add( - toolbox.client.pas.tx.transferFunds({ - from, - to, - amount: 100 * 1_000_000, - assetType: demoUsd.demoUsdAssetType, - }), - ); - - const resp = await toolbox.client.signAndExecuteTransaction({ - signer: toolbox.keypair, - transaction, - include: { - effects: true, - }, + it('transfers two different asset types (v1 and v2 approval) in a single PTB', async () => { + const toolbox = await setupToolbox(); + const asset1 = new DemoUsdTestHelpers(toolbox, 'demo_usd_1'); + const asset2 = new DemoUsdTestHelpers(toolbox, 'demo_usd_2'); + await asset1.createRule(); + await asset2.createRule(); + + // Upgrade asset2 to v2 so the two assets use completely different approval code paths. + await asset2.upgradeToV2(); + + const sender = toolbox.address(); + const receiver = normalizeSuiAddress('0xB3'); + const senderVaultId = toolbox.client.pas.deriveVaultAddress(sender); + const receiverVaultId = toolbox.client.pas.deriveVaultAddress(receiver); + + await asset1.mintFromFaucetInto(500, senderVaultId); + await asset2.mintFromFaucetInto(800, senderVaultId); + + // --- First PTB: transfers both asset types, implicitly creates receiver vault --- + const tx1 = new Transaction(); + tx1.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 120 * 1_000_000, + assetType: asset1.demoUsdAssetType, + }), + ); + tx1.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 350 * 1_000_000, + assetType: asset2.demoUsdAssetType, + }), + ); + await toolbox.executeTransaction(tx1); + + const receiverObj = await toolbox.client.core.getObject({ objectId: receiverVaultId }); + expect(receiverObj.object).toBeDefined(); + await expectBalances(toolbox, [ + { vault: senderVaultId, asset: asset1.demoUsdAssetType, amount: 380 }, + { vault: senderVaultId, asset: asset2.demoUsdAssetType, amount: 450 }, + { vault: receiverVaultId, asset: asset1.demoUsdAssetType, amount: 120 }, + { vault: receiverVaultId, asset: asset2.demoUsdAssetType, amount: 350 }, + ]); + + // --- Second PTB: both vaults already exist, different amounts --- + const tx2 = new Transaction(); + tx2.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 80 * 1_000_000, + assetType: asset1.demoUsdAssetType, + }), + ); + tx2.add( + toolbox.client.pas.tx.transferFunds({ + from: sender, + to: receiver, + amount: 150 * 1_000_000, + assetType: asset2.demoUsdAssetType, + }), + ); + await toolbox.executeTransaction(tx2); + + await expectBalances(toolbox, [ + { vault: senderVaultId, asset: asset1.demoUsdAssetType, amount: 300 }, + { vault: senderVaultId, asset: asset2.demoUsdAssetType, amount: 300 }, + { vault: receiverVaultId, asset: asset1.demoUsdAssetType, amount: 200 }, + { vault: receiverVaultId, asset: asset2.demoUsdAssetType, amount: 500 }, + ]); }); - expect(resp.FailedTransaction).toBeDefined(); - expect(resp.FailedTransaction!.effects.status.error!.message).toEqual( - 'InsufficientFundsForWithdraw', - ); - }); -}); + it('v2 approval rejects transfers to 0x2', async () => { + const toolbox = await setupToolbox(); + const demoUsd = new DemoUsdTestHelpers(toolbox); + await demoUsd.createRule(); + + const from = toolbox.address(); + const to = normalizeSuiAddress('0x2'); + const fromVaultId = toolbox.client.pas.deriveVaultAddress(from); + + await toolbox.createVaultForAddress(from); + await toolbox.createVaultForAddress(to); + await demoUsd.mintFromFaucetInto(10, fromVaultId); + + await demoUsd.upgradeToV2(); + + const tx = new Transaction(); + tx.add( + toolbox.client.pas.tx.transferFunds({ + from, + to, + amount: 1_000_000, + assetType: demoUsd.demoUsdAssetType, + }), + ); + + const resp = await simulateFailingTransaction(toolbox, tx); + expect(resp.FailedTransaction).toBeDefined(); + expect(resp.FailedTransaction!.effects.status.error!.message).toContain( + 'Transfers to the address 0x2 are not allowed in this demo.', + ); + }); + }, +); diff --git a/sdk/pas/test/e2e/setup.ts b/sdk/pas/test/e2e/setup.ts index 743705e..01e3697 100644 --- a/sdk/pas/test/e2e/setup.ts +++ b/sdk/pas/test/e2e/setup.ts @@ -56,9 +56,13 @@ export class TestToolbox { } /// Publishes a package at a given path. - /// IF the package is already published, we return its data. - /// It only does sequential writes to avoid equivocation (we use a mutex) - async publishPackage(packagePath: string) { + /// IF the package is already published under the same key, we return its data. + /// It only does sequential writes to avoid equivocation (we use a mutex). + /// An optional `cacheKey` allows publishing the same package path multiple + /// times under different keys (e.g. two independent demo_usd instances). + async publishPackage(packagePath: string, cacheKey?: string) { + const key = cacheKey ?? packagePath; + // Ensure only one publish happens at a time using the mutex const currentLock = this.publishLock; let releaseLock: () => void; @@ -69,9 +73,9 @@ export class TestToolbox { await currentLock; // If the package has already been published, return the published data. - if (this.publishedPackages[packagePath]) { + if (this.publishedPackages[key]) { releaseLock!(); - return this.publishedPackages[packagePath]; + return this.publishedPackages[key]; } try { @@ -81,14 +85,14 @@ export class TestToolbox { baseClient: this.client, }); - this.publishedPackages[packagePath] = { + this.publishedPackages[key] = { digest: publicationData.digest, createdObjects: publicationData.createdObjects, originalId: publicationData.packageId, publishedAt: publicationData.packageId, }; - return this.publishedPackages[packagePath]; + return this.publishedPackages[key]; } finally { // Release the lock so the next publish can proceed releaseLock!(); @@ -106,7 +110,7 @@ export class TestToolbox { // Creates a vault for a given address. async createVaultForAddress(address: string) { const tx = new Transaction(); - tx.add(this.client.pas.call.createAndShareVault(address)); + tx.add(this.client.pas.tx.vaultForAddress(address)); return this.executeTransaction(tx); } @@ -153,9 +157,6 @@ export async function setupToolbox() { // Get some gas for any publishes. await execSuiTools(['sui', 'client', '--client.config', configPath, 'faucet']); - // wait for the faucet to be ready (give it 2s, should probably be like 100ms) - await new Promise((resolve) => setTimeout(resolve, 2000)); - // Track the published packages. const publishedPackages: Record = {}; @@ -207,39 +208,19 @@ export async function setupToolbox() { // Link the UpgradeCap to the Namespace (required before any derived object operations). // This must be done via CLI since the UpgradeCap is owned by the CLI address, not the test keypair. - await execSuiTools([ - 'sui', - 'client', - '--client.config', - configPath, - 'call', - '--package', - pasPackageId, - '--module', - 'namespace', - '--function', - 'setup', - '--args', - namespaceId, - upgradeCapId, - '--json', - ]); - // Create the Templates object (required for template-based resolution) await execSuiTools([ 'sui', 'client', '--client.config', configPath, - 'call', - '--package', - pasPackageId, - '--module', - 'templates', - '--function', - 'setup', - '--args', - namespaceId, + 'ptb', + '--move-call', + `${pasPackageId}::namespace::setup`, + `@${namespaceId} @${upgradeCapId}`, + '--move-call', + `${pasPackageId}::templates::setup`, + `@${namespaceId}`, '--json', ]); @@ -357,6 +338,24 @@ export async function executeTransaction(toolbox: TestToolbox, tx: Transaction) return resp; } +/** + * Simulate a transaction that is expected to fail, returning the structured + * error with smart-error messages. Uses `simulateTransaction` (not dry-run + * budget estimation) so Move aborts surface as `FailedTransaction` responses + * rather than thrown RPC errors. + */ +export async function simulateFailingTransaction(toolbox: TestToolbox, tx: Transaction) { + tx.setSenderIfNotSet(toolbox.address()); + await tx.prepareForSerialization({ client: toolbox.client }); + + const resp = await toolbox.client.core.simulateTransaction({ + transaction: tx, + include: { effects: true }, + }); + + return resp; +} + export async function simulateTransaction(toolbox: TestToolbox, tx: Transaction) { tx.setSender(toolbox.address()); await tx.prepareForSerialization({ client: toolbox.client }); diff --git a/sdk/pnpm-lock.yaml b/sdk/pnpm-lock.yaml index 7980b9e..ac0b9a6 100644 --- a/sdk/pnpm-lock.yaml +++ b/sdk/pnpm-lock.yaml @@ -9,20 +9,20 @@ importers: .: devDependencies: '@mysten/bcs': - specifier: ^2.0.1 - version: 2.0.1 + specifier: ^2.0.2 + version: 2.0.2 '@mysten/codegen': specifier: ^0.6.0 version: 0.6.0 '@mysten/sui': - specifier: ^2.0.1 - version: 2.0.1(typescript@5.9.3) + specifier: ^2.4.0 + version: 2.4.0(typescript@5.9.3) example-app: devDependencies: '@mysten/sui': - specifier: ^2.0.1 - version: 2.0.1(typescript@5.9.3) + specifier: ^2.4.0 + version: 2.4.0(typescript@5.9.3) '@types/node': specifier: ^25.0.8 version: 25.0.9 @@ -39,14 +39,14 @@ importers: specifier: ^4.7.0 version: 4.7.0(prettier@3.8.0) '@mysten/bcs': - specifier: ^2.0.1 - version: 2.0.1 + specifier: ^2.0.2 + version: 2.0.2 '@mysten/codegen': specifier: ^0.6.0 version: 0.6.0 '@mysten/sui': - specifier: ^2.0.1 - version: 2.0.1(typescript@5.9.3) + specifier: ^2.4.0 + version: 2.4.0(typescript@5.9.3) '@testcontainers/postgresql': specifier: ^11.11.0 version: 11.11.0 @@ -439,19 +439,19 @@ packages: resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} - '@mysten/bcs@2.0.1': - resolution: {integrity: sha512-/q1uC5Vw/RjXzR4fE9QiIwmTvRx9BstduxkR1j1Z5EbE7ZJynLkFKcJJlXRcnnEytIPcsyiR+TkQixzSbz3msQ==} + '@mysten/bcs@2.0.2': + resolution: {integrity: sha512-c/nVRPJEV1fRZdKXhysVsy/yCPdiFt7jn6A4/7W2LH1ZPSVPzRkxtLY362D0zaLuBnyT5Y9d9nFLm3ixI8Goug==} '@mysten/codegen@0.6.0': resolution: {integrity: sha512-35QCW9E6JRx17XHhh3GjQbPyVzvr7UsGPmnQxc9QDXZmgY4+P6AaksDke+pTi/hBi/L2nH6M09umJBBv3Q6XIw==} hasBin: true - '@mysten/sui@2.0.1': - resolution: {integrity: sha512-uFaD9sPwi9Wy+KlSc2uMhjo+yWEay4SOc6RP9cj16+CpzbzRijXtRFagQytQJ0KXM6qCTlwWtXpkmkFGuMA98w==} + '@mysten/sui@2.4.0': + resolution: {integrity: sha512-2EG5+lTypWMgU3lWyKlcjopQ++Ae9BkoROOBVcJ6mYwM5o1IcKnoc7rFqr94KQClYmBFV2aFz5+aKm5okatoBw==} engines: {node: '>=22'} - '@mysten/utils@0.3.0': - resolution: {integrity: sha512-paVyFTP+1yHXDO8uorU6YuT1EAIh/GMKXeHbxtGFPJbLzwc5jk1qfUMrks/S7MAwJHMOHcSOx+e2Mwx8ejaIew==} + '@mysten/utils@0.3.1': + resolution: {integrity: sha512-36KhxG284uhDdSnlkyNaS6fzKTX9FpP2WQWOwUKIRsqQFFIm2ooCf2TP1IuqrtMpkairwpiWkAS0eg7cpemVzg==} '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -2233,15 +2233,15 @@ snapshots: strict-event-emitter: 0.5.1 optional: true - '@mysten/bcs@2.0.1': + '@mysten/bcs@2.0.2': dependencies: - '@mysten/utils': 0.3.0 + '@mysten/utils': 0.3.1 '@scure/base': 2.0.0 '@mysten/codegen@0.6.0': dependencies: - '@mysten/bcs': 2.0.1 - '@mysten/sui': 2.0.1(typescript@5.9.3) + '@mysten/bcs': 2.0.2 + '@mysten/sui': 2.4.0(typescript@5.9.3) '@stricli/auto-complete': 1.2.5 '@stricli/core': 1.2.5 '@types/node': 25.0.9 @@ -2254,11 +2254,11 @@ snapshots: - '@gql.tada/svelte-support' - '@gql.tada/vue-support' - '@mysten/sui@2.0.1(typescript@5.9.3)': + '@mysten/sui@2.4.0(typescript@5.9.3)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - '@mysten/bcs': 2.0.1 - '@mysten/utils': 0.3.0 + '@mysten/bcs': 2.0.2 + '@mysten/utils': 0.3.1 '@noble/curves': 2.0.1 '@noble/hashes': 2.0.1 '@protobuf-ts/grpcweb-transport': 2.11.1 @@ -2276,7 +2276,7 @@ snapshots: - '@gql.tada/vue-support' - typescript - '@mysten/utils@0.3.0': + '@mysten/utils@0.3.1': dependencies: '@scure/base': 2.0.0