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/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", 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..0fb2b31e1 --- /dev/null +++ b/src/internal/permissions.ts @@ -0,0 +1,347 @@ +// 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 { + Permission, + FungibleAssetPermission, + NFTPermission, + GasPermission, + NFTPermissionCapability, + FungibleAssetPermissionCapability, + GasPermissionCapability, +} 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. + * With the current refactor to a unified class, this function is broken. + */ +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?.reduce((acc, d) => { + const address = AccountAddress.fromString(d.key.data); + + switch (d.key.type_name) { + 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) as unknown as T[]) : (permissions as unknown 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[NFTPermissionCapability.transfer]) { + expanded.push( + NFTPermission.from({ + assetAddress: permission.assetAddress, + capabilities: { + [NFTPermissionCapability.transfer]: true, + [NFTPermissionCapability.mutate]: false, + }, + }), + ); + } + if (permission.capabilities.mutate) { + expanded.push( + NFTPermission.from({ + assetAddress: permission.assetAddress, + capabilities: { + [NFTPermissionCapability.transfer]: false, + [NFTPermissionCapability.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[NFTPermissionCapability.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..63a9dc376 --- /dev/null +++ b/src/types/permissions/index.ts @@ -0,0 +1,195 @@ +/** + * 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"; + + +export type Permissions = FungibleAssetPermission | GasPermission | NFTPermission; + +/** + * Core permission type definitions + */ +export abstract class Permission extends Serializable { + static readonly type: string; + + abstract readonly capabilities: Record; + + abstract serialize(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 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; + 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 { + serializer.serializeStr(FungibleAssetPermission.type); + this.asset.serialize(serializer); + serializer.serializeStr(this.amount.toString()); + } + + static deserializeData(deserializer: Deserializer): FungibleAssetPermission { + const asset = AccountAddress.deserialize(deserializer); + const amount = BigInt(deserializer.deserializeStr()); + return FungibleAssetPermission.from({ asset, amount }); + } +} + +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); + } + + static from = (args: { amount: number | bigint }): GasPermission => new GasPermission(args); + + static revoke(): GasPermission { + return new GasPermission({ amount: 0n }); + } + + serialize(serializer: Serializer): void { + serializer.serializeStr(GasPermission.type); + serializer.serializeStr(this.amount.toString()); + } + + static deserializeData(deserializer: Deserializer): GasPermission { + const amount = BigInt(deserializer.deserializeStr()); + return GasPermission.from({ amount }); + } +} + +export enum NFTPermissionCapability { + transfer = "0x1::object::TransferPermission", + mutate = "mutate", +} + +export class NFTPermission extends Permission { + /** + * Static readonly type for the permission + */ + static readonly type = "nft-permission"; + + /** + * State + */ + 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: { + [NFTPermissionCapability.mutate]: false, + [NFTPermissionCapability.transfer]: false, + }, + }); + } + + 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)], + [[] as string[], [] as boolean[]], + ); + serializer.serializeStr(JSON.stringify(capabilityKeys)); + serializer.serializeStr(JSON.stringify(capabilityValues)); + } + + 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, + ); + 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..dfccd6b1f --- /dev/null +++ b/tests/e2e/transaction/permissionedSigner.test.ts @@ -0,0 +1,449 @@ +// 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, + Permission, + NFTPermissionCapability, +} 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: { + [NFTPermissionCapability.transfer]: true, + [NFTPermissionCapability.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("Basic 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); + }); + + describe("Serializer", () => { + 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: { + [NFTPermissionCapability.transfer]: true, + [NFTPermissionCapability.mutate]: false, + }, + }); + + 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); + }); + + 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); + }); + }); +}); + +// ==================================================================== +// 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, + }; +}