From d507d37fcb17da46943af8c3d1a5290161abac23 Mon Sep 17 00:00:00 2001 From: Rasa Welcher Date: Tue, 11 Feb 2025 13:47:02 -0500 Subject: [PATCH 1/5] Adds permissioned signer support --- CHANGELOG.md | 2 + examples/typescript/permission_signer.ts | 105 +++++ src/api/aptos.ts | 7 + src/api/permissions.ts | 120 +++++ src/index.ts | 1 + src/internal/permissions.ts | 293 ++++++++++++ src/types/permissions/delegationKey.ts | 30 ++ src/types/permissions/index.ts | 132 ++++++ src/types/permissions/rateLimiter.ts | 130 ++++++ .../transaction/permissionedSigner.test.ts | 424 ++++++++++++++++++ 10 files changed, 1244 insertions(+) create mode 100644 examples/typescript/permission_signer.ts create mode 100644 src/api/permissions.ts create mode 100644 src/internal/permissions.ts create mode 100644 src/types/permissions/delegationKey.ts create mode 100644 src/types/permissions/index.ts create mode 100644 src/types/permissions/rateLimiter.ts create mode 100644 tests/e2e/transaction/permissionedSigner.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e731d1f7d..cf30bbfeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to the Aptos TypeScript SDK will be captured in this file. T # Unreleased +- Add permissioned signer support for FungibleAssetPermission, GasPermission, and NFTPermission using `Ed25519Account`s + # 1.35.0 (2025-02-11) - Add `MultiEd25519Account` to support the legacy MultiEd25519 authentication scheme. diff --git a/examples/typescript/permission_signer.ts b/examples/typescript/permission_signer.ts new file mode 100644 index 000000000..a8430e507 --- /dev/null +++ b/examples/typescript/permission_signer.ts @@ -0,0 +1,105 @@ +import { + Ed25519Account, + AbstractedAccount, + FungibleAssetPermission, + AccountAddress, + Aptos, + AptosConfig, + Network, +} from "@aptos-labs/ts-sdk"; + +// Initialize the Aptos client +const config = new AptosConfig({ network: Network.DEVNET }); +const aptos = new Aptos(config); + +async function demoPermissions() { + /** + * This would be the account that we want to request permissions from. + * This would come back from the wallet. + */ + const primaryAccount = Ed25519Account.generate(); + + /** + * This is not a true account on chain, just a key-pair that we will use to + * request permissions. You can save the private key of the delegation account + * and use it later. + */ + const delegationAccount = Ed25519Account.generate(); + + /** + * We take the delegation account and create an abstract account from it. We + * then use this abstract account to execute transactions on behalf of the + * primary account. + */ + const abstractAccount = AbstractedAccount.fromPermissionedSigner({ signer: delegationAccount }); + + /** + * This is the transaction that we will use to request permissions. Note that + * we must specify the primary account address and the delegation public key. + * We also must sign the request transactions with the primary account. + */ + const txn1 = await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: delegationAccount.publicKey, + permissions: [ + FungibleAssetPermission.from({ + asset: AccountAddress.A, // Replace with the actual asset address + amount: 10, // Amount of the asset + }), + ], + }); + const txn1Result = await aptos.signAndSubmitTransaction({ + signer: primaryAccount, + transaction: txn1, + }); + await aptos.waitForTransaction({ transactionHash: txn1Result.hash }); + + /** + * This is the transaction that we will use to execute the function on behalf + * of the primary account. Here we are transferring 5 units of the asset to + * another account. Note how the sender is the primary account address and the + * signer is the abstract account. + */ + const txn2 = await aptos.signAndSubmitTransaction({ + signer: abstractAccount, + transaction: await aptos.transaction.build.simple({ + sender: primaryAccount.accountAddress, + data: { + function: "0x1::primary_fungible_store::transfer", + functionArguments: [AccountAddress.A, "receiver_account_address", 5], // Replace with actual receiver address + typeArguments: ["0x1::fungible_asset::Metadata"], + }, + }), + }); + const txn2Result = await aptos.waitForTransaction({ transactionHash: txn2.hash, options: { checkSuccess: true } }); + console.log("Transaction success:", txn2Result.success); + + /** + * This is how we can fetch existing permissions for a delegated account. + * Note, a primary account can have any number of delegated accounts. + */ + const permissions = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: delegationAccount.publicKey, + filter: FungibleAssetPermission, + }); + + console.log("Existing permissions:", permissions); + + /** + * This is how we can revoke permissions. + */ + const txn3 = await aptos.signAndSubmitTransaction({ + signer: primaryAccount, + transaction: await aptos.permissions.revokePermission({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: delegationAccount.publicKey, + permissions: [FungibleAssetPermission.revoke({ asset: AccountAddress.A })], + }), + }); + const txn3Result = await aptos.waitForTransaction({ transactionHash: txn3.hash }); + console.log("Transaction success:", txn3Result.success); +} + +// Execute the demo +demoPermissions().catch(console.error); diff --git a/src/api/aptos.ts b/src/api/aptos.ts index 6dad55aa4..2d05231e9 100644 --- a/src/api/aptos.ts +++ b/src/api/aptos.ts @@ -10,6 +10,7 @@ import { Faucet } from "./faucet"; import { FungibleAsset } from "./fungibleAsset"; import { General } from "./general"; import { ANS } from "./ans"; +import { Permissions } from "./permissions"; import { Staking } from "./staking"; import { Transaction } from "./transaction"; import { Table } from "./table"; @@ -43,6 +44,7 @@ import { Experimental } from "./experimental"; * ``` * @group Client */ + export class Aptos { readonly config: AptosConfig; @@ -50,6 +52,8 @@ export class Aptos { readonly ans: ANS; + readonly permissions: Permissions; + readonly coin: Coin; readonly digitalAsset: DigitalAsset; @@ -100,6 +104,7 @@ export class Aptos { this.account = new Account(this.config); this.abstraction = new AccountAbstraction(this.config); this.ans = new ANS(this.config); + this.permissions = new Permissions(this.config); this.coin = new Coin(this.config); this.digitalAsset = new DigitalAsset(this.config); this.event = new Event(this.config); @@ -120,6 +125,7 @@ export class Aptos { export interface Aptos extends Account, ANS, + Permissions, Coin, DigitalAsset, Event, @@ -158,6 +164,7 @@ function applyMixin(targetClass: any, baseClass: any, baseClassProp: string) { applyMixin(Aptos, Account, "account"); applyMixin(Aptos, AccountAbstraction, "abstraction"); applyMixin(Aptos, ANS, "ans"); +applyMixin(Aptos, Permissions, "permissions"); applyMixin(Aptos, Coin, "coin"); applyMixin(Aptos, DigitalAsset, "digitalAsset"); applyMixin(Aptos, Event, "event"); diff --git a/src/api/permissions.ts b/src/api/permissions.ts new file mode 100644 index 000000000..3cdb5625c --- /dev/null +++ b/src/api/permissions.ts @@ -0,0 +1,120 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +import { getPermissions, requestPermission, revokePermissions } from "../internal/permissions"; +import { AptosConfig } from "./aptosConfig"; +import { SimpleTransaction } from "../transactions/instances/simpleTransaction"; +import { Permission } from "../types/permissions"; +import { AccountAddress, PublicKey } from "../core"; + +/** + * Manages permission operations for delegated accounts. + * Handles granting, revoking, and querying permissions for fungible assets, gas, and NFTs. + */ +export class Permissions { + constructor(readonly config: AptosConfig) {} + + /** + * Gets current permissions for a delegation key + * + * @example + * ```typescript + * const permissions = await aptos.permissions.getPermissions({ + * primaryAccountAddress: AccountAddress.fromString("0x1"), + * delegationPublicKey: delegatedAccount.publicKey + * filter: FungibleAssetPermission + * }); + * ``` + */ + async getPermissions(args: { + primaryAccountAddress: AccountAddress; + delegationPublicKey: PublicKey; + filter?: new (...a: any) => T; + }): Promise { + return getPermissions({ + aptosConfig: this.config, + primaryAccountAddress: args.primaryAccountAddress, + delegationPublicKey: args.delegationPublicKey, + filter: args.filter, + }); + } + + /** + * Requests permissions for a delegation key + * + * @param args + * @param args.primaryAccountAddress - The primary account address + * @param args.delegationPublicKey - The delegation public key + * @param args.permissions - The permissions to request + * @param args.expiration - The expiration time of the permissions, in epoch seconds. Defaults to 1 day from now. + * @param args.refreshInterval - The refresh interval of the permissions, in seconds. Defaults to 60 seconds. + * @param args.maxTransactionsPerInterval - The maximum number of transactions per interval. Defaults to 1000. + * + * @example + * ```typescript + * const txn = await aptos.permissions.requestPermissions({ + * primaryAccountAddress: AccountAddress.fromString("0x1"), + * delegationPublicKey: delegatedAccount.publicKey, + * permissions: [ + * FungibleAssetPermission.from({ asset: AccountAddress.A, amount: 100 }) + * ] + * }); + * + * await aptos.signAndSubmitTransaction({ + * signer: primaryAccount, + * transaction: txn, + * }); + * ``` + */ + async requestPermissions(args: { + primaryAccountAddress: AccountAddress; + delegationPublicKey: PublicKey; + permissions: Permission[]; + expiration?: number; + refreshInterval?: number; + maxTransactionsPerInterval?: number; + }): Promise { + return requestPermission({ + aptosConfig: this.config, + primaryAccountAddress: args.primaryAccountAddress, + delegationPublicKey: args.delegationPublicKey, + permissions: args.permissions, + expiration: args.expiration ?? Date.now() + 24 * 60 * 60 * 1000, + refreshInterval: args.refreshInterval ?? 60, + maxTransactionsPerInterval: args.maxTransactionsPerInterval ?? 1000, + }); + } + + /** + * Revokes permissions from a delegation key. Note: You can pass an entire permission you get back from `getPermissions` + * or call the static `revoke` function on the permission. + * + * @example + * ```typescript + * const txn = await aptos.permissions.revokePermission({ + * primaryAccountAddress: AccountAddress.fromString("0x1"), + * delegationPublicKey: delegatedAccount.publicKey, + * permissions: [ + * FungibleAssetPermission.revoke({ asset: AccountAddress.A }) + * ] + * }); + * + * await aptos.signAndSubmitTransaction({ + * signer: primaryAccount, + * transaction: txn, + * }); + * ``` + */ + async revokePermission(args: { + primaryAccountAddress: AccountAddress; + delegationPublicKey: PublicKey; + permissions: Permission[]; + }): Promise { + return revokePermissions({ + aptosConfig: this.config, + primaryAccountAddress: args.primaryAccountAddress, + delegationPublicKey: args.delegationPublicKey, + permissions: args.permissions, + }); + } +} diff --git a/src/index.ts b/src/index.ts index 5ad05f5f8..ff9d8f446 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,4 +10,5 @@ export * from "./errors"; export * from "./transactions"; export * from "./transactions/management"; export * from "./types"; +export * from "./types/permissions"; export * from "./utils"; diff --git a/src/internal/permissions.ts b/src/internal/permissions.ts new file mode 100644 index 000000000..fc95b2c8c --- /dev/null +++ b/src/internal/permissions.ts @@ -0,0 +1,293 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +import { AptosConfig } from "../api/aptosConfig"; +import { Transaction } from "../api/transaction"; +import { AccountAddress, PublicKey } from "../core"; +import { SimpleTransaction } from "../transactions/instances/simpleTransaction"; +import { + MoveVMPermissionType, + Permission, + FungibleAssetPermission, + NFTPermission, + GasPermission, +} from "../types/permissions"; +import { CallArgument } from "../types"; +import { DelegationKey } from "../types/permissions/delegationKey"; +import { RateLimiter } from "../types/permissions/rateLimiter"; +import { view } from "./view"; + +/** + * Resource structure returned by the Aptos API for permissions + */ +type PermissionResource = { + type: string; + data: { + perms: { + data: Array<{ + key: { data: string; type_name: string }; + value: string; + }>; + }; + }; +}; + +/** + * TODO: We should be fetching this from the indexer, not the fullnode + */ +export async function getPermissions({ + aptosConfig, + primaryAccountAddress, + delegationPublicKey, + filter, +}: { + aptosConfig: AptosConfig; + primaryAccountAddress: AccountAddress; + delegationPublicKey: PublicKey; + filter?: new (...a: any) => T; +}): Promise { + const handle = await getHandleAddress({ aptosConfig, primaryAccountAddress, delegationPublicKey }); + if (!handle) return []; + + const res = await fetch(`${aptosConfig.fullnode}/accounts/${handle}/resources`); + if (!res.ok) { + throw new Error(`Failed to fetch permissions: ${res.statusText}`); + } + + const data = (await res.json()) as PermissionResource[]; + const permissions = + data?.[0]?.data?.perms?.data?.map((d) => { + const address = AccountAddress.fromString(d.key.data); + + switch (d.key.type_name) { + case MoveVMPermissionType.FungibleAsset: + return FungibleAssetPermission.from({ + asset: address, + amount: Number(d.value), + }); + case MoveVMPermissionType.TransferPermission: + return NFTPermission.from({ + assetAddress: address, + capabilities: { transfer: true, mutate: false }, + }); + default: + throw new Error(`Unknown permission type: ${d.key.type_name}`); + } + }) ?? []; + + return (filter ? permissions.filter((p) => p instanceof filter) : permissions) as T[]; +} + +export async function requestPermission(args: { + aptosConfig: AptosConfig; + primaryAccountAddress: AccountAddress; + delegationPublicKey: PublicKey; + permissions: Permission[]; + expiration: number; + refreshInterval: number | bigint; + maxTransactionsPerInterval: number | bigint; +}): Promise { + const { + aptosConfig, + primaryAccountAddress, + delegationPublicKey, + permissions, + expiration, + refreshInterval, + maxTransactionsPerInterval, + } = args; + const transaction = new Transaction(aptosConfig); + const existingHandleAddress = await getHandleAddress({ aptosConfig, primaryAccountAddress, delegationPublicKey }); + + return transaction.build.scriptComposer({ + sender: primaryAccountAddress, + builder: async (builder) => { + // Get or create permissioned signer + const delegationKey = new DelegationKey({ publicKey: delegationPublicKey }); + let signer; + + // Use existing permissioned signer if it exists + if (existingHandleAddress) { + signer = await builder.addBatchedCalls({ + function: "0x1::permissioned_delegation::permissioned_signer_by_key", + functionArguments: [CallArgument.newSigner(0), delegationKey.bcsToBytes()], + typeArguments: [], + }); + } + // Create a new permissioned signer with rate limiting + else { + signer = await builder.addBatchedCalls({ + function: "0x1::permissioned_delegation::add_permissioned_handle", + functionArguments: [ + CallArgument.newSigner(0), + delegationKey.bcsToBytes(), + RateLimiter.fromDefaultTokenBucket({ + capacity: maxTransactionsPerInterval, + refillInterval: refreshInterval, + }).bcsToBytes(), + expiration, + ], + typeArguments: [], + }); + + // Add authentication function for new signer + await builder.addBatchedCalls({ + function: "0x1::account_abstraction::add_authentication_function", + functionArguments: [CallArgument.newSigner(0), AccountAddress.ONE, "permissioned_delegation", "authenticate"], + typeArguments: [], + }); + } + + // Each capability (mutate, transfer, etc) is a separate permission, we need to expand them + const expandedPermissions = permissions.flatMap((permission) => { + if (permission instanceof NFTPermission && permission.capabilities) { + const expanded: Permission[] = []; + if (permission.capabilities.transfer) { + expanded.push( + NFTPermission.from({ + assetAddress: permission.assetAddress, + capabilities: { transfer: true, mutate: false }, + }), + ); + } + if (permission.capabilities.mutate) { + expanded.push( + NFTPermission.from({ + assetAddress: permission.assetAddress, + capabilities: { transfer: false, mutate: true }, + }), + ); + } + return expanded; + } + return permission; + }); + + await Promise.all( + expandedPermissions.map(async (permission) => { + const signerBorrow = signer[0].borrow(); + + if (permission instanceof FungibleAssetPermission) { + return builder.addBatchedCalls({ + function: "0x1::fungible_asset::grant_permission", + functionArguments: [CallArgument.newSigner(0), signerBorrow, permission.asset, permission.amount], + typeArguments: [], + }); + } + + if (permission instanceof GasPermission) { + return builder.addBatchedCalls({ + function: "0x1::transaction_validation::grant_gas_permission", + functionArguments: [CallArgument.newSigner(0), signerBorrow, permission.amount], + typeArguments: [], + }); + } + + if (permission instanceof NFTPermission) { + if (permission.capabilities.transfer) { + return builder.addBatchedCalls({ + function: "0x1::object::grant_permission", + functionArguments: [CallArgument.newSigner(0), signerBorrow, permission.assetAddress], + typeArguments: ["0x4::token::Token"], + }); + } + if (permission.capabilities.mutate) { + throw new Error("NFT mutate permission not implemented"); + } + return Promise.resolve([]); + } + + throw new Error(`Unknown permission type: ${permission}`); + }), + ); + + return builder; + }, + }); +} + +export async function revokePermissions({ + aptosConfig, + primaryAccountAddress, + delegationPublicKey, + permissions, +}: { + aptosConfig: AptosConfig; + primaryAccountAddress: AccountAddress; + delegationPublicKey: PublicKey; + permissions: Permission[]; +}): Promise { + const transaction = new Transaction(aptosConfig); + const delegationKey = new DelegationKey({ publicKey: delegationPublicKey }); + + return transaction.build.scriptComposer({ + sender: primaryAccountAddress, + builder: async (builder) => { + // Get the permissioned signer + const [signer] = await builder.addBatchedCalls({ + function: "0x1::permissioned_delegation::permissioned_signer_by_key", + functionArguments: [CallArgument.newSigner(0), delegationKey.bcsToBytes()], + typeArguments: [], + }); + + const signerBorrow = signer.borrow(); + + // Revoke each permission + await Promise.all( + permissions.map(async (permission) => { + if (permission instanceof FungibleAssetPermission) { + return builder.addBatchedCalls({ + function: "0x1::fungible_asset::revoke_permission", + functionArguments: [signerBorrow, permission.asset], + typeArguments: [], + }); + } + + if (permission instanceof GasPermission) { + return builder.addBatchedCalls({ + function: "0x1::transaction_validation::revoke_permission", + functionArguments: [signerBorrow], + typeArguments: [], + }); + } + + if (permission instanceof NFTPermission) { + return builder.addBatchedCalls({ + function: "0x1::object::revoke_permission", + functionArguments: [signerBorrow, permission.assetAddress], + typeArguments: ["0x4::token::Token"], + }); + } + + throw new Error(`Unknown permission type: ${permission}`); + }), + ); + + return builder; + }, + }); +} + +async function getHandleAddress({ + aptosConfig, + primaryAccountAddress, + delegationPublicKey, +}: { + aptosConfig: AptosConfig; + primaryAccountAddress: AccountAddress; + delegationPublicKey: PublicKey; +}): Promise { + try { + const delegationKey = new DelegationKey({ publicKey: delegationPublicKey }); + const [handle] = await view({ + aptosConfig, + payload: { + function: "0x1::permissioned_delegation::handle_address_by_key", + functionArguments: [primaryAccountAddress, delegationKey.bcsToBytes()], + }, + }); + return handle; + } catch { + return null; + } +} diff --git a/src/types/permissions/delegationKey.ts b/src/types/permissions/delegationKey.ts new file mode 100644 index 000000000..e0406b670 --- /dev/null +++ b/src/types/permissions/delegationKey.ts @@ -0,0 +1,30 @@ +import { Deserializer } from "../../bcs/deserializer"; +import { Serializer, Serializable } from "../../bcs/serializer"; +import { Ed25519PublicKey, PublicKey } from "../../core"; + +export class DelegationKey extends Serializable { + readonly publicKey: Ed25519PublicKey; + + constructor({ publicKey }: { publicKey: PublicKey }) { + super(); + if (publicKey instanceof Ed25519PublicKey) { + this.publicKey = publicKey; + } else { + throw new Error("Invalid public key"); + } + } + + serialize(serializer: Serializer): void { + serializer.serializeU32AsUleb128(0); + this.publicKey.serialize(serializer); + } + + static deserialize(deserializer: Deserializer): DelegationKey { + const variant = deserializer.deserializeUleb128AsU32(); + if (variant !== 0) { + throw new Error("Invalid delegation key variant"); + } + const publicKey = Ed25519PublicKey.deserialize(deserializer); + return new DelegationKey({ publicKey }); + } +} diff --git a/src/types/permissions/index.ts b/src/types/permissions/index.ts new file mode 100644 index 000000000..6a1f159c1 --- /dev/null +++ b/src/types/permissions/index.ts @@ -0,0 +1,132 @@ +/** + * Types and utilities for managing permissions in the system. + * Includes permission types for fungible assets, gas, NFTs and NFT collections, + * along with interfaces and factory functions for creating and revoking permissions. + */ + +import type { Deserializer } from "../../bcs/deserializer"; +import { Serializer, Serializable } from "../../bcs/serializer"; +import { AccountAddress } from "../../core/accountAddress"; + +/** + * Core permission type definitions + */ +export type Permission = FungibleAssetPermission | GasPermission | NFTPermission; + +export enum MoveVMPermissionType { + FungibleAsset = "0x1::fungible_asset::WithdrawPermission", + TransferPermission = "0x1::object::TransferPermission", +} + +export class FungibleAssetPermission extends Serializable { + readonly asset: AccountAddress; + + readonly amount: bigint; + + constructor({ asset, amount }: { asset: AccountAddress; amount: number | bigint }) { + super(); + this.asset = asset; + this.amount = BigInt(amount); + } + + static from(args: { asset: AccountAddress; amount: number | bigint }): FungibleAssetPermission { + return new FungibleAssetPermission(args); + } + + static revoke(args: { asset: AccountAddress }): FungibleAssetPermission { + return new FungibleAssetPermission({ asset: args.asset, amount: 0n }); + } + + serialize(serializer: Serializer): void { + this.asset.serialize(serializer); + serializer.serializeStr(this.amount.toString()); + } + + static deserialize(deserializer: Deserializer): FungibleAssetPermission { + const asset = AccountAddress.deserialize(deserializer); + const amount = BigInt(deserializer.deserializeStr()); + return FungibleAssetPermission.from({ asset, amount }); + } +} + +export class GasPermission extends Serializable { + readonly amount: bigint; + + constructor({ amount }: { amount: number | bigint }) { + super(); + this.amount = BigInt(amount); + } + + static from = (args: { amount: number | bigint }): GasPermission => new GasPermission(args); + + static revoke(): GasPermission { + return new GasPermission({ amount: 0n }); + } + + serialize(serializer: Serializer): void { + serializer.serializeStr(this.amount.toString()); + } + + static deserialize(deserializer: Deserializer): GasPermission { + const amount = BigInt(deserializer.deserializeStr()); + return GasPermission.from({ amount }); + } +} + +export enum NFTCapability { + transfer = "transfer", + mutate = "mutate", +} + +export class NFTPermission extends Serializable { + readonly assetAddress: AccountAddress; + + readonly capabilities: Record; + + constructor({ + assetAddress, + capabilities, + }: { + assetAddress: AccountAddress; + capabilities: Record; + }) { + super(); + this.assetAddress = assetAddress; + this.capabilities = capabilities; + } + + static from = (args: { assetAddress: AccountAddress; capabilities: Record }): NFTPermission => + new NFTPermission(args); + + static revoke(args: { assetAddress: AccountAddress }): NFTPermission { + return new NFTPermission({ + assetAddress: args.assetAddress, + capabilities: { + mutate: false, + transfer: false, + }, + }); + } + + serialize(serializer: Serializer): void { + this.assetAddress.serialize(serializer); + + const [capabilityKeys, capabilityValues] = Object.entries(this.capabilities).reduce( + ([keys, values], [key, value]) => [keys.concat(key), values.concat(value)], + [[] as string[], [] as boolean[]], + ); + serializer.serializeStr(JSON.stringify(capabilityKeys)); + serializer.serializeStr(JSON.stringify(capabilityValues)); + } + + static deserialize(deserializer: Deserializer): NFTPermission { + const assetAddress = AccountAddress.deserialize(deserializer); + const capabilityKeys = JSON.parse(deserializer.deserializeStr()) as string[]; + const capabilityValues = JSON.parse(deserializer.deserializeStr()) as boolean[]; + const capabilities = capabilityKeys.reduce( + (acc, key, i) => ({ ...acc, [key]: capabilityValues[i] }), + {} as Record, + ); + return NFTPermission.from({ assetAddress, capabilities }); + } +} diff --git a/src/types/permissions/rateLimiter.ts b/src/types/permissions/rateLimiter.ts new file mode 100644 index 000000000..aab5bd3e2 --- /dev/null +++ b/src/types/permissions/rateLimiter.ts @@ -0,0 +1,130 @@ +import { Serializer, Serializable } from "../../bcs/serializer"; +import { Deserializer } from "../../bcs/deserializer"; + +/** + * Represents a rate limiter that enforces a token bucket-based rate limit. + * This class implements the RateLimiter enum from the Move language. + * + * @property {TokenBucket} tokenBucket - The token bucket configuration for rate limiting. + */ +export class RateLimiter extends Serializable { + readonly tokenBucket: TokenBucket; + + constructor({ tokenBucket }: { tokenBucket: TokenBucket }) { + super(); + this.tokenBucket = tokenBucket; + } + + serialize(serializer: Serializer): void { + serializer.serializeU32AsUleb128(0); + this.tokenBucket.serialize(serializer); + } + + /** + * A default rate limiter that refills 1000 tokens every 60 seconds. + */ + static fromDefaultTokenBucket({ + capacity = 1000, + refillInterval = 60, + }: { + capacity?: bigint | number; + refillInterval?: bigint | number; + }): RateLimiter { + return new RateLimiter({ + tokenBucket: TokenBucket.from({ + capacity: BigInt(capacity), + currentAmount: BigInt(0), + refillInterval: BigInt(refillInterval), + lastRefillTimestamp: BigInt(0), + fractionalAccumulated: BigInt(0), + }), + }); + } + + static from(args: { tokenBucket: TokenBucket }): RateLimiter { + return new RateLimiter(args); + } + + static deserialize(deserializer: Deserializer): RateLimiter { + const variant = deserializer.deserializeUleb128AsU32(); + if (variant !== 0) { + throw new Error("Invalid rate limiter variant"); + } + return new RateLimiter({ tokenBucket: TokenBucket.deserialize(deserializer) }); + } +} + +/** + * Represents a token bucket that enforces a token bucket-based rate limit. + * This class implements the TokenBucket struct used in the RateLimiter enum. + * + * @property {bigint} capacity - The maximum number of tokens allowed at any time. + * @property {bigint} currentAmount - The current number of tokens remaining in this interval. + * @property {bigint} refillInterval - The interval at which the bucket refills. + * @property {bigint} lastRefillTimestamp - The timestamp of the last refill. + * @property {bigint} fractionalAccumulated - The accumulated amount that hasn't yet added up to a full token. + */ +export class TokenBucket extends Serializable { + readonly capacity: bigint; + + readonly currentAmount: bigint; + + readonly refillInterval: bigint; + + readonly lastRefillTimestamp: bigint; + + readonly fractionalAccumulated: bigint; + + constructor({ + capacity, + currentAmount, + refillInterval, + lastRefillTimestamp, + fractionalAccumulated, + }: { + capacity: bigint; + currentAmount: bigint; + refillInterval: bigint; + lastRefillTimestamp: bigint; + fractionalAccumulated: bigint; + }) { + super(); + this.capacity = capacity; + this.currentAmount = currentAmount; + this.refillInterval = refillInterval; + this.lastRefillTimestamp = lastRefillTimestamp; + this.fractionalAccumulated = fractionalAccumulated; + } + + static from(args: { + capacity: bigint; + currentAmount: bigint; + refillInterval: bigint; + lastRefillTimestamp: bigint; + fractionalAccumulated: bigint; + }): TokenBucket { + return new TokenBucket(args); + } + + serialize(serializer: Serializer): void { + serializer.serializeU64(this.capacity); + serializer.serializeU64(this.currentAmount); + serializer.serializeU64(this.refillInterval); + serializer.serializeU64(this.lastRefillTimestamp); + serializer.serializeU64(this.fractionalAccumulated); + } + + static deserialize(deserializer: Deserializer): TokenBucket { + const variant = deserializer.deserializeUleb128AsU32(); + if (variant !== 0) { + throw new Error("Invalid token bucket variant"); + } + return new TokenBucket({ + capacity: deserializer.deserializeU64(), + currentAmount: deserializer.deserializeU64(), + refillInterval: deserializer.deserializeU64(), + lastRefillTimestamp: deserializer.deserializeU64(), + fractionalAccumulated: deserializer.deserializeU64(), + }); + } +} diff --git a/tests/e2e/transaction/permissionedSigner.test.ts b/tests/e2e/transaction/permissionedSigner.test.ts new file mode 100644 index 000000000..c8841c40f --- /dev/null +++ b/tests/e2e/transaction/permissionedSigner.test.ts @@ -0,0 +1,424 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +import { + Account, + AccountAddress, + Ed25519Account, + PendingTransactionResponse, + InputGenerateTransactionPayloadData, + Serializer, + Deserializer, + AbstractedAccount, + SimpleTransaction, +} from "../../../src"; +import { longTestTimeout } from "../../unit/helper"; +import { getAptosClient } from "../helper"; +import { fundAccounts } from "./helper"; +import { FungibleAssetPermission, GasPermission, NFTPermission } from "../../../src/types/permissions"; + +const { aptos } = getAptosClient(); + +describe("transaction submission", () => { + let primaryAccount: Ed25519Account; + let permissionedAccount1: Ed25519Account; + let permissionedAccount2: Ed25519Account; + let abstractAccount: AbstractedAccount; + let receiverAccounts: Ed25519Account[]; + + beforeEach(async () => { + primaryAccount = Account.generate(); + permissionedAccount1 = Ed25519Account.generate(); + permissionedAccount2 = Ed25519Account.generate(); + abstractAccount = AbstractedAccount.fromPermissionedSigner({ signer: permissionedAccount1 }); + receiverAccounts = [Account.generate(), Account.generate()]; + await fundAccounts(aptos, [primaryAccount, ...receiverAccounts]); + }, longTestTimeout); + + test("Able to grant permissions to multiple sub accounts with the same primary account", async () => { + const APT_PERMISSION = FungibleAssetPermission.from({ + asset: AccountAddress.A, // apt address + amount: 10, + }); + + try { + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [APT_PERMISSION], + }), + }); + + const perm1 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + filter: FungibleAssetPermission, + }); + expect(perm1.length).toBe(1); + } catch (e) { + console.log("Error: ", e); + expect(false).toBeTruthy(); + } + + try { + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount2.publicKey, + permissions: [APT_PERMISSION], + }), + }); + + const perm2 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount2.publicKey, + filter: FungibleAssetPermission, + }); + expect(perm2.length).toBe(1); + } catch (e) { + console.log("Error: ", e); + expect(false).toBeTruthy(); + } + }); + + test("Able to re-grant permissions for the same subaccount", async () => { + // account + const APT_PERMISSION = FungibleAssetPermission.from({ + asset: AccountAddress.A, // apt address + amount: 10, + }); + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [APT_PERMISSION], + }), + }); + + const perm1 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + filter: FungibleAssetPermission, + }); + expect(perm1.length).toBe(1); + expect(perm1[0].amount).toEqual(BigInt(10)); + + const APT_PERMISSION2 = FungibleAssetPermission.from({ + asset: AccountAddress.A, + amount: 20, + }); + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [APT_PERMISSION2], + }), + }); + + const perm2 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + filter: FungibleAssetPermission, + }); + expect(perm2.length).toBe(1); + expect(perm2[0].amount).toEqual(BigInt(30)); + }); + + test("Able to grant permissions for NFTs", async () => { + const nftAddress = await generateNFT({ account: primaryAccount }); + // TODO: Add this back in when Runtian is done with his refactor + // const nftAddress2 = await generateNFT({ account: primaryAccount }); + + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [ + NFTPermission.from({ assetAddress: nftAddress, capabilities: { transfer: true, mutate: false } }), + ], + }), + }); + + const perm1 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + filter: NFTPermission, + }); + expect(perm1.length).toBe(1); + + const txn1 = await signSubmitAndWait({ + sender: primaryAccount, + signer: abstractAccount, + data: { + function: "0x1::object::transfer", + typeArguments: ["0x4::token::Token"], + functionArguments: [nftAddress, receiverAccounts[0].accountAddress], + }, + }); + expect(txn1.submittedTransaction.success).toBe(true); + }); + + test("Able to view active permissions and remaining balances for APT", async () => { + // Convert APT to FA + await signSubmitAndWait({ + sender: primaryAccount, + data: { + function: "0x1::coin::migrate_to_fungible_store", + functionArguments: [], + typeArguments: ["0x1::aptos_coin::AptosCoin"], + }, + }); + + const APT_PERMISSION = FungibleAssetPermission.from({ + asset: AccountAddress.A, + amount: 10, + }); + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [APT_PERMISSION], + }), + }); + + const perm1 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + filter: FungibleAssetPermission, + }); + expect(perm1.length).toBe(1); + expect(perm1[0].amount).toEqual(BigInt(10)); + + const txn1 = await signSubmitAndWait({ + sender: primaryAccount, + signer: abstractAccount, + data: { + function: "0x1::primary_fungible_store::transfer", + functionArguments: [AccountAddress.A, receiverAccounts[0].accountAddress, 1], + typeArguments: ["0x1::fungible_asset::Metadata"], + }, + }); + expect(txn1.response.signature?.type).toBe("single_sender"); + expect(txn1.submittedTransaction.success).toBe(true); + + const perm2 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + filter: FungibleAssetPermission, + }); + expect(perm2.length).toBe(1); + expect(perm2[0].amount).toEqual(BigInt(9)); + + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.revokePermission({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [APT_PERMISSION], + }), + }); + + const perm3 = await aptos.getPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + }); + expect(perm3.length).toBe(0); + }); + + test("Revoking transactions", async () => { + // Convert APT to FA + await signSubmitAndWait({ + sender: primaryAccount, + data: { + function: "0x1::coin::migrate_to_fungible_store", + functionArguments: [], + typeArguments: ["0x1::aptos_coin::AptosCoin"], + }, + }); + + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [FungibleAssetPermission.from({ asset: AccountAddress.A, amount: 10 })], + }), + }); + + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.revokePermission({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [FungibleAssetPermission.revoke({ asset: AccountAddress.A })], + }), + }); + + const txn1 = await signSubmitAndWait({ + sender: primaryAccount, + signer: abstractAccount, + data: { + function: "0x1::primary_fungible_store::transfer", + functionArguments: [AccountAddress.A, receiverAccounts[0].accountAddress, 10], + typeArguments: ["0x1::fungible_asset::Metadata"], + }, + }); + expect(txn1.response.signature?.type).toBe("single_sender"); + expect(txn1.submittedTransaction.success).toBe(false); + }); + + test.only("Aaron's test case", async () => { + await signSubmitAndWait({ + sender: primaryAccount, + data: { + function: "0x1::coin::migrate_to_fungible_store", + functionArguments: [], + typeArguments: ["0x1::aptos_coin::AptosCoin"], + }, + }); + await signSubmitAndWait({ + sender: primaryAccount, + transaction: await aptos.permissions.requestPermissions({ + primaryAccountAddress: primaryAccount.accountAddress, + delegationPublicKey: permissionedAccount1.publicKey, + permissions: [FungibleAssetPermission.from({ asset: AccountAddress.A, amount: 10 })], + }), + }); + const txn1 = await signSubmitAndWait({ + sender: primaryAccount, + signer: abstractAccount, + data: { + function: "0x1::primary_fungible_store::transfer", + functionArguments: [AccountAddress.A, receiverAccounts[0].accountAddress, 10], + typeArguments: ["0x1::fungible_asset::Metadata"], + }, + }); + expect(txn1.response.signature?.type).toBe("single_sender"); + expect(txn1.submittedTransaction.success).toBe(true); + + // step 3: use AA to send APT FA again. should fail. + const txn2 = await signSubmitAndWait({ + sender: primaryAccount, + signer: abstractAccount, + data: { + function: "0x1::primary_fungible_store::transfer", + functionArguments: [AccountAddress.A, receiverAccounts[0].accountAddress, 1], + typeArguments: ["0x1::fungible_asset::Metadata"], + }, + }); + expect(txn2.response.signature?.type).toBe("single_sender"); + expect(txn2.submittedTransaction.success).toBe(false); + }); + + test("Serializer", async () => { + const APT_PERMISSION = FungibleAssetPermission.from({ + asset: AccountAddress.A, + amount: 10, + }); + const GAS_PERMISSION = GasPermission.from({ + amount: 1, + }); + const NFT_PERMISSION = NFTPermission.from({ + assetAddress: AccountAddress.A, + capabilities: { transfer: true, mutate: false }, + }); + + const serializer = new Serializer(); + APT_PERMISSION.serialize(serializer); + GAS_PERMISSION.serialize(serializer); + NFT_PERMISSION.serialize(serializer); + const serialized = serializer.toUint8Array(); + + const deserializer = new Deserializer(serialized); + expect(FungibleAssetPermission.deserialize(deserializer)).toEqual(APT_PERMISSION); + expect(GasPermission.deserialize(deserializer)).toEqual(GAS_PERMISSION); + expect(NFTPermission.deserialize(deserializer)).toEqual(NFT_PERMISSION); + }); +}); + +// ==================================================================== +// Test Helper Functions +// =================================================================== +async function generateNFT({ account }: { account: Account }) { + let pendingTxn: PendingTransactionResponse; + + const str = () => (Math.random() * 100000000).toString().slice(4, 16); + + const COLLECTION_NAME = str(); + + pendingTxn = await aptos.signAndSubmitTransaction({ + signer: account, + transaction: await aptos.createCollectionTransaction({ + creator: account, + description: str(), + name: COLLECTION_NAME, + uri: "https://aptos.dev", + }), + }); + await aptos.waitForTransaction({ transactionHash: pendingTxn.hash }); + + pendingTxn = await aptos.signAndSubmitTransaction({ + signer: account, + transaction: await aptos.transaction.build.simple({ + sender: account.accountAddress, + data: { + function: "0x4::aptos_token::mint", + functionArguments: [COLLECTION_NAME, "my token description", "my token", "https://aptos.dev/nft", [], [], []], + }, + }), + }); + await aptos.waitForTransaction({ transactionHash: pendingTxn.hash }); + + const txn: any = await aptos.transaction.getTransactionByHash({ transactionHash: pendingTxn.hash }); + + return txn.events?.find((e: any) => e.type === "0x4::collection::Mint")?.data.token; +} + +async function signSubmitAndWait({ + sender, + signer, + checkSuccess = false, + data, + transaction: userTransaction, +}: { + sender: Ed25519Account; + signer?: AbstractedAccount | Ed25519Account; + checkSuccess?: boolean; + data?: InputGenerateTransactionPayloadData; + transaction?: SimpleTransaction; +}) { + const transaction = + userTransaction ?? + (await aptos.transaction.build.simple({ + sender: sender.accountAddress, + data: data as InputGenerateTransactionPayloadData, + })); + + if (!transaction) { + throw new Error("Transaction is undefined"); + } + + const response = await aptos.signAndSubmitTransaction({ + signer: signer || sender, + transaction, + }); + const submittedTransaction = await aptos.waitForTransaction({ + transactionHash: response.hash, + options: { + checkSuccess, + }, + }); + + return { + transaction, + response, + submittedTransaction, + }; +} From b45289d1de740edb8054758db3b3863385547ffd Mon Sep 17 00:00:00 2001 From: Rasa Welcher Date: Wed, 12 Feb 2025 11:29:43 -0500 Subject: [PATCH 2/5] Adds unified Permission class --- src/internal/permissions.ts | 92 +++++++++++--- src/types/permissions/index.ts | 113 ++++++++++++++---- .../transaction/permissionedSigner.test.ts | 53 +++++--- 3 files changed, 202 insertions(+), 56 deletions(-) diff --git a/src/internal/permissions.ts b/src/internal/permissions.ts index fc95b2c8c..0fb2b31e1 100644 --- a/src/internal/permissions.ts +++ b/src/internal/permissions.ts @@ -6,11 +6,13 @@ import { Transaction } from "../api/transaction"; import { AccountAddress, PublicKey } from "../core"; import { SimpleTransaction } from "../transactions/instances/simpleTransaction"; import { - MoveVMPermissionType, Permission, FungibleAssetPermission, NFTPermission, GasPermission, + NFTPermissionCapability, + FungibleAssetPermissionCapability, + GasPermissionCapability, } from "../types/permissions"; import { CallArgument } from "../types"; import { DelegationKey } from "../types/permissions/delegationKey"; @@ -33,7 +35,8 @@ type PermissionResource = { }; /** - * TODO: We should be fetching this from the indexer, not the fullnode + * TODO: We should be fetching this from the indexer, not the fullnode. + * With the current refactor to a unified class, this function is broken. */ export async function getPermissions({ aptosConfig, @@ -56,26 +59,71 @@ export async function getPermissions({ const data = (await res.json()) as PermissionResource[]; const permissions = - data?.[0]?.data?.perms?.data?.map((d) => { + data?.[0]?.data?.perms?.data?.reduce((acc, d) => { const address = AccountAddress.fromString(d.key.data); switch (d.key.type_name) { - case MoveVMPermissionType.FungibleAsset: - return FungibleAssetPermission.from({ - asset: address, - amount: Number(d.value), - }); - case MoveVMPermissionType.TransferPermission: - return NFTPermission.from({ - assetAddress: address, - capabilities: { transfer: true, mutate: false }, - }); + case FungibleAssetPermissionCapability.Withdraw: { + acc.push( + FungibleAssetPermission.from({ + asset: address, + amount: Number(d.value), + }), + ); + break; + } + + case GasPermissionCapability.Withdraw: { + acc.push( + GasPermission.from({ + amount: Number(d.value), + }), + ); + break; + } + + case NFTPermissionCapability.transfer: + case NFTPermissionCapability.mutate: { + const existingIndex = acc.findIndex( + (p): p is NFTPermission => p instanceof NFTPermission && p.assetAddress.equals(address), + ); + + if (existingIndex !== -1) { + const existingNFT = acc[existingIndex] as NFTPermission; + // Create new NFT permission with merged capabilities + acc[existingIndex] = NFTPermission.from({ + assetAddress: address, + capabilities: { + [NFTPermissionCapability.transfer]: + existingNFT.capabilities[NFTPermissionCapability.transfer] || + d.key.type_name === NFTPermissionCapability.transfer, + [NFTPermissionCapability.mutate]: + existingNFT.capabilities[NFTPermissionCapability.mutate] || + d.key.type_name === NFTPermissionCapability.mutate, + }, + }); + } else { + // Create new NFT permission + acc.push( + NFTPermission.from({ + assetAddress: address, + capabilities: { + [NFTPermissionCapability.transfer]: d.key.type_name === NFTPermissionCapability.transfer, + [NFTPermissionCapability.mutate]: d.key.type_name === NFTPermissionCapability.mutate, + }, + }), + ); + } + break; + } + default: throw new Error(`Unknown permission type: ${d.key.type_name}`); } - }) ?? []; + return acc; + }, [] as Permission[]) ?? []; - return (filter ? permissions.filter((p) => p instanceof filter) : permissions) as T[]; + return filter ? (permissions.filter((p) => p instanceof filter) as unknown as T[]) : (permissions as unknown as T[]); } export async function requestPermission(args: { @@ -142,11 +190,14 @@ export async function requestPermission(args: { const expandedPermissions = permissions.flatMap((permission) => { if (permission instanceof NFTPermission && permission.capabilities) { const expanded: Permission[] = []; - if (permission.capabilities.transfer) { + if (permission.capabilities[NFTPermissionCapability.transfer]) { expanded.push( NFTPermission.from({ assetAddress: permission.assetAddress, - capabilities: { transfer: true, mutate: false }, + capabilities: { + [NFTPermissionCapability.transfer]: true, + [NFTPermissionCapability.mutate]: false, + }, }), ); } @@ -154,7 +205,10 @@ export async function requestPermission(args: { expanded.push( NFTPermission.from({ assetAddress: permission.assetAddress, - capabilities: { transfer: false, mutate: true }, + capabilities: { + [NFTPermissionCapability.transfer]: false, + [NFTPermissionCapability.mutate]: true, + }, }), ); } @@ -184,7 +238,7 @@ export async function requestPermission(args: { } if (permission instanceof NFTPermission) { - if (permission.capabilities.transfer) { + if (permission.capabilities[NFTPermissionCapability.transfer]) { return builder.addBatchedCalls({ function: "0x1::object::grant_permission", functionArguments: [CallArgument.newSigner(0), signerBorrow, permission.assetAddress], diff --git a/src/types/permissions/index.ts b/src/types/permissions/index.ts index 6a1f159c1..58d061d40 100644 --- a/src/types/permissions/index.ts +++ b/src/types/permissions/index.ts @@ -8,21 +8,63 @@ import type { Deserializer } from "../../bcs/deserializer"; import { Serializer, Serializable } from "../../bcs/serializer"; import { AccountAddress } from "../../core/accountAddress"; + +export type Permissions = FungibleAssetPermission | GasPermission | NFTPermission; + /** * Core permission type definitions */ -export type Permission = FungibleAssetPermission | GasPermission | NFTPermission; +export abstract class Permission extends Serializable { + static readonly type: string; + + abstract readonly capabilities: Record; + + serialize(serializer: Serializer): void { + // First serialize the type + serializer.serializeStr(Permission.type); + // Then serialize the specific permission data + this.serializeData(serializer); + } -export enum MoveVMPermissionType { - FungibleAsset = "0x1::fungible_asset::WithdrawPermission", - TransferPermission = "0x1::object::TransferPermission", + abstract serializeData(serializer: Serializer): void; + + static deserialize(deserializer: Deserializer): Permission { + const type = deserializer.deserializeStr(); + + switch (type) { + case FungibleAssetPermission.type: + return FungibleAssetPermission.deserializeData(deserializer); + case GasPermission.type: + return GasPermission.deserializeData(deserializer); + case NFTPermission.type: + return NFTPermission.deserializeData(deserializer); + default: + throw new Error(`Unknown permission type: ${type}`); + } + } +} + +export enum FungibleAssetPermissionCapability { + Withdraw = "0x1::fungible_asset::WithdrawPermission", } -export class FungibleAssetPermission extends Serializable { +export class FungibleAssetPermission extends Permission { + /** + * Static readonly type for the permission + */ + static readonly type = "fungible-asset-permission"; + + /** + * State + */ readonly asset: AccountAddress; readonly amount: bigint; + readonly capabilities: Record = { + [FungibleAssetPermissionCapability.Withdraw]: true, + }; + constructor({ asset, amount }: { asset: AccountAddress; amount: number | bigint }) { super(); this.asset = asset; @@ -37,21 +79,37 @@ export class FungibleAssetPermission extends Serializable { return new FungibleAssetPermission({ asset: args.asset, amount: 0n }); } - serialize(serializer: Serializer): void { + serializeData(serializer: Serializer): void { this.asset.serialize(serializer); serializer.serializeStr(this.amount.toString()); } - static deserialize(deserializer: Deserializer): FungibleAssetPermission { + static deserializeData(deserializer: Deserializer): FungibleAssetPermission { const asset = AccountAddress.deserialize(deserializer); const amount = BigInt(deserializer.deserializeStr()); return FungibleAssetPermission.from({ asset, amount }); } } -export class GasPermission extends Serializable { +export enum GasPermissionCapability { + Withdraw = "0x1::gas::WithdrawPermission", +} + +export class GasPermission extends Permission { + /** + * Static readonly type for the permission + */ + static readonly type = "gas-permission"; + + /** + * State + */ readonly amount: bigint; + readonly capabilities: Record = { + [GasPermissionCapability.Withdraw]: true, + }; + constructor({ amount }: { amount: number | bigint }) { super(); this.amount = BigInt(amount); @@ -63,54 +121,63 @@ export class GasPermission extends Serializable { return new GasPermission({ amount: 0n }); } - serialize(serializer: Serializer): void { + serializeData(serializer: Serializer): void { serializer.serializeStr(this.amount.toString()); } - static deserialize(deserializer: Deserializer): GasPermission { + static deserializeData(deserializer: Deserializer): GasPermission { const amount = BigInt(deserializer.deserializeStr()); return GasPermission.from({ amount }); } } -export enum NFTCapability { - transfer = "transfer", +export enum NFTPermissionCapability { + transfer = "0x1::object::TransferPermission", mutate = "mutate", } -export class NFTPermission extends Serializable { +export class NFTPermission extends Permission { + /** + * Static readonly type for the permission + */ + static readonly type = "nft-permission"; + + /** + * State + */ readonly assetAddress: AccountAddress; - readonly capabilities: Record; + readonly capabilities: Record; constructor({ assetAddress, capabilities, }: { assetAddress: AccountAddress; - capabilities: Record; + capabilities: Record; }) { super(); this.assetAddress = assetAddress; this.capabilities = capabilities; } - static from = (args: { assetAddress: AccountAddress; capabilities: Record }): NFTPermission => - new NFTPermission(args); + static from = (args: { + assetAddress: AccountAddress; + capabilities: Record; + }): NFTPermission => new NFTPermission(args); static revoke(args: { assetAddress: AccountAddress }): NFTPermission { return new NFTPermission({ assetAddress: args.assetAddress, capabilities: { - mutate: false, - transfer: false, + [NFTPermissionCapability.mutate]: false, + [NFTPermissionCapability.transfer]: false, }, }); } - serialize(serializer: Serializer): void { + serializeData(serializer: Serializer): void { this.assetAddress.serialize(serializer); - const [capabilityKeys, capabilityValues] = Object.entries(this.capabilities).reduce( ([keys, values], [key, value]) => [keys.concat(key), values.concat(value)], [[] as string[], [] as boolean[]], @@ -119,13 +186,13 @@ export class NFTPermission extends Serializable { serializer.serializeStr(JSON.stringify(capabilityValues)); } - static deserialize(deserializer: Deserializer): NFTPermission { + static deserializeData(deserializer: Deserializer): NFTPermission { const assetAddress = AccountAddress.deserialize(deserializer); const capabilityKeys = JSON.parse(deserializer.deserializeStr()) as string[]; const capabilityValues = JSON.parse(deserializer.deserializeStr()) as boolean[]; const capabilities = capabilityKeys.reduce( (acc, key, i) => ({ ...acc, [key]: capabilityValues[i] }), - {} as Record, + {} as Record, ); return NFTPermission.from({ assetAddress, capabilities }); } diff --git a/tests/e2e/transaction/permissionedSigner.test.ts b/tests/e2e/transaction/permissionedSigner.test.ts index c8841c40f..cda3a825f 100644 --- a/tests/e2e/transaction/permissionedSigner.test.ts +++ b/tests/e2e/transaction/permissionedSigner.test.ts @@ -15,7 +15,13 @@ import { import { longTestTimeout } from "../../unit/helper"; import { getAptosClient } from "../helper"; import { fundAccounts } from "./helper"; -import { FungibleAssetPermission, GasPermission, NFTPermission } from "../../../src/types/permissions"; +import { + FungibleAssetPermission, + GasPermission, + NFTPermission, + Permission, + NFTPermissionCapability, +} from "../../../src/types/permissions"; const { aptos } = getAptosClient(); @@ -140,7 +146,13 @@ describe("transaction submission", () => { primaryAccountAddress: primaryAccount.accountAddress, delegationPublicKey: permissionedAccount1.publicKey, permissions: [ - NFTPermission.from({ assetAddress: nftAddress, capabilities: { transfer: true, mutate: false } }), + NFTPermission.from({ + assetAddress: nftAddress, + capabilities: { + [NFTPermissionCapability.transfer]: true, + [NFTPermissionCapability.mutate]: false, + }, + }), ], }), }); @@ -274,7 +286,7 @@ describe("transaction submission", () => { expect(txn1.submittedTransaction.success).toBe(false); }); - test.only("Aaron's test case", async () => { + test("Aaron's test case", async () => { await signSubmitAndWait({ sender: primaryAccount, data: { @@ -317,7 +329,7 @@ describe("transaction submission", () => { expect(txn2.submittedTransaction.success).toBe(false); }); - test("Serializer", async () => { + describe.only("Serializer", () => { const APT_PERMISSION = FungibleAssetPermission.from({ asset: AccountAddress.A, amount: 10, @@ -327,19 +339,32 @@ describe("transaction submission", () => { }); const NFT_PERMISSION = NFTPermission.from({ assetAddress: AccountAddress.A, - capabilities: { transfer: true, mutate: false }, + capabilities: { + [NFTPermissionCapability.transfer]: true, + [NFTPermissionCapability.mutate]: false, + }, }); - const serializer = new Serializer(); - APT_PERMISSION.serialize(serializer); - GAS_PERMISSION.serialize(serializer); - NFT_PERMISSION.serialize(serializer); - const serialized = serializer.toUint8Array(); + test("Serialize permissions individually", () => { + const serializer = new Serializer(); + APT_PERMISSION.serialize(serializer); + GAS_PERMISSION.serialize(serializer); + NFT_PERMISSION.serialize(serializer); + const serialized = serializer.toUint8Array(); + const deserializer = new Deserializer(serialized); + expect(FungibleAssetPermission.deserialize(deserializer)).toEqual(APT_PERMISSION); + expect(GasPermission.deserialize(deserializer)).toEqual(GAS_PERMISSION); + expect(NFTPermission.deserialize(deserializer)).toEqual(NFT_PERMISSION); + }); - const deserializer = new Deserializer(serialized); - expect(FungibleAssetPermission.deserialize(deserializer)).toEqual(APT_PERMISSION); - expect(GasPermission.deserialize(deserializer)).toEqual(GAS_PERMISSION); - expect(NFTPermission.deserialize(deserializer)).toEqual(NFT_PERMISSION); + test("Serialize permissions as an array", () => { + const permissions: Permission[] = [APT_PERMISSION, GAS_PERMISSION, NFT_PERMISSION]; + const serializer = new Serializer(); + serializer.serializeVector(permissions); + const serialized = serializer.toUint8Array(); + const deserializer = new Deserializer(serialized); + expect(deserializer.deserializeVector(Permission)).toEqual(permissions); + }); }); }); From 1f2adba448045aaff81d420b3bf313eddd73a095 Mon Sep 17 00:00:00 2001 From: Rasa Welcher Date: Tue, 25 Feb 2025 17:30:49 -0500 Subject: [PATCH 3/5] Adds test flag --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index bfe5fde70..53b5c3fed 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "check-version": "scripts/checkVersion.sh", "update-version": "scripts/updateVersion.sh && pnpm doc", "spec": "pnpm build && pnpm _spec", - "_spec": "cucumber-js -p default" + "_spec": "cucumber-js -p default", + "test:permissions": "INITIALIZE_JWK_CONSENSUS=1 jest --collectCoverage=false tests/e2e/transaction/permissionedSigner.test.ts" }, "dependencies": { "@aptos-labs/aptos-cli": "^1.0.2", From 0e09fd9c05c6870786c0da35b6a059c4934b08ab Mon Sep 17 00:00:00 2001 From: Rasa Welcher Date: Tue, 25 Feb 2025 17:56:39 -0500 Subject: [PATCH 4/5] Moves serialization down to the child class --- src/types/permissions/index.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/types/permissions/index.ts b/src/types/permissions/index.ts index 58d061d40..63a9dc376 100644 --- a/src/types/permissions/index.ts +++ b/src/types/permissions/index.ts @@ -19,14 +19,7 @@ export abstract class Permission extends Serializable { abstract readonly capabilities: Record; - serialize(serializer: Serializer): void { - // First serialize the type - serializer.serializeStr(Permission.type); - // Then serialize the specific permission data - this.serializeData(serializer); - } - - abstract serializeData(serializer: Serializer): void; + abstract serialize(serializer: Serializer): void; static deserialize(deserializer: Deserializer): Permission { const type = deserializer.deserializeStr(); @@ -79,7 +72,8 @@ export class FungibleAssetPermission extends Permission { return new FungibleAssetPermission({ asset: args.asset, amount: 0n }); } - serializeData(serializer: Serializer): void { + serialize(serializer: Serializer): void { + serializer.serializeStr(FungibleAssetPermission.type); this.asset.serialize(serializer); serializer.serializeStr(this.amount.toString()); } @@ -121,7 +115,8 @@ export class GasPermission extends Permission { return new GasPermission({ amount: 0n }); } - serializeData(serializer: Serializer): void { + serialize(serializer: Serializer): void { + serializer.serializeStr(GasPermission.type); serializer.serializeStr(this.amount.toString()); } @@ -176,7 +171,8 @@ export class NFTPermission extends Permission { }); } - serializeData(serializer: Serializer): void { + serialize(serializer: Serializer): void { + serializer.serializeStr(NFTPermission.type); this.assetAddress.serialize(serializer); const [capabilityKeys, capabilityValues] = Object.entries(this.capabilities).reduce( ([keys, values], [key, value]) => [keys.concat(key), values.concat(value)], From 9fcb3082cc04f49bb49394d7241fbef49356daf8 Mon Sep 17 00:00:00 2001 From: Rasa Welcher Date: Tue, 25 Feb 2025 17:58:08 -0500 Subject: [PATCH 5/5] Highlights permissioned signer failure --- tests/e2e/transaction/permissionedSigner.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/transaction/permissionedSigner.test.ts b/tests/e2e/transaction/permissionedSigner.test.ts index cda3a825f..dfccd6b1f 100644 --- a/tests/e2e/transaction/permissionedSigner.test.ts +++ b/tests/e2e/transaction/permissionedSigner.test.ts @@ -286,7 +286,7 @@ describe("transaction submission", () => { expect(txn1.submittedTransaction.success).toBe(false); }); - test("Aaron's test case", async () => { + test.only("Basic test case", async () => { await signSubmitAndWait({ sender: primaryAccount, data: { @@ -329,7 +329,7 @@ describe("transaction submission", () => { expect(txn2.submittedTransaction.success).toBe(false); }); - describe.only("Serializer", () => { + describe("Serializer", () => { const APT_PERMISSION = FungibleAssetPermission.from({ asset: AccountAddress.A, amount: 10,