diff --git a/.gitignore b/.gitignore index 8e91a2a0..278b6dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ lib/ -build/ \ No newline at end of file +build/ +notes.md diff --git a/src/abi.ts b/src/abi.ts new file mode 100644 index 00000000..37868e0f --- /dev/null +++ b/src/abi.ts @@ -0,0 +1,33 @@ +import {ABI, API, APIClient, Name} from '@greymass/eosio' +import {AbiProvider} from 'eosio-signing-request' + +/** + * Given an APIClient instance, this class provides an AbiProvider interface for retrieving and caching ABIs. + */ +export class ABICache implements AbiProvider { + public readonly cache: Map = new Map() + public readonly pending: Map> = new Map() + + constructor(public readonly client: APIClient) {} + + public async getAbi(account: Name): Promise { + const key = String(account) + let record = this.cache.get(key) + if (!record) { + let getAbi = this.pending.get(key) + if (!getAbi) { + getAbi = this.client.v1.chain.get_abi(account) + this.pending.set(key, getAbi) + } + const response = await getAbi + this.pending.delete(key) + if (response.abi) { + record = ABI.from(response.abi) + this.cache.set(key, record) + } else { + throw new Error(`ABI for ${key} could not be loaded.`) + } + } + return record + } +} diff --git a/src/index-module.ts b/src/index-module.ts index 72b1f9ea..c7989121 100644 --- a/src/index-module.ts +++ b/src/index-module.ts @@ -1,4 +1,6 @@ +export * from './abi' export * from './kit' export * from './plugins' export * from './session' +export * from './transact' export * from './types' diff --git a/src/kit.ts b/src/kit.ts index aaeba9a9..2576266c 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -8,18 +8,20 @@ import { PermissionLevelType, } from '@greymass/eosio' -import {ChainDefinition, ChainDefinitionType, Fetch} from './types' - import { - AbstractTransactPlugin, - BaseTransactPlugin, Session, SessionOptions, - TransactPlugin, - TransactPluginsOptions, WalletPlugin, + WalletPluginContext, WalletPluginLoginOptions, } from './session' +import { + AbstractTransactPlugin, + BaseTransactPlugin, + TransactPlugin, + TransactPluginsOptions, +} from './transact' +import {ChainDefinition, ChainDefinitionType, Fetch} from './types' export enum LoginHookTypes { beforeLogin = 'beforeLogin', @@ -96,6 +98,7 @@ export interface LoginOptions { export interface SessionKitOptions { appName: NameType chains: ChainDefinitionType[] + expireSeconds?: number fetch?: Fetch loginPlugins?: LoginPlugin[] transactPlugins?: TransactPlugin[] @@ -109,6 +112,7 @@ export interface SessionKitOptions { export class SessionKit { readonly appName: Name readonly chains: ChainDefinition[] + readonly expireSeconds: number = 120 readonly fetch?: Fetch readonly loginPlugins: AbstractLoginPlugin[] readonly transactPlugins: AbstractTransactPlugin[] @@ -119,6 +123,10 @@ export class SessionKit { // Store options passed on the kit this.appName = Name.from(options.appName) this.chains = options.chains.map((chain) => ChainDefinition.from(chain)) + // Override default expireSeconds for all sessions if specified + if (options.expireSeconds) { + this.expireSeconds = options.expireSeconds + } // Override fetch if provided if (options.fetch) { this.fetch = options.fetch @@ -168,6 +176,7 @@ export class SessionKit { const chain = this.chains[0] const context: SessionOptions = { chain, + expireSeconds: this.expireSeconds, fetch: this.fetch, permissionLevel: 'eosio@active', transactPlugins: options?.transactPlugins || this.transactPlugins, @@ -175,10 +184,15 @@ export class SessionKit { walletPlugin: this.walletPlugins[0], } + const walletContext: WalletPluginContext = { + chain, + permissionLevel: PermissionLevel.from('eosio@active'), + } + const walletOptions: WalletPluginLoginOptions = { appName: this.appName, chains: this.chains, - context, + context: walletContext, } // Allow overriding of the default wallet plugin by specifying one in the options diff --git a/src/session.ts b/src/session.ts index 9cb4fef1..09173a87 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,11 +1,8 @@ import { - ABIDef, - AnyAction, - AnyTransaction, APIClient, - Checksum256Type, FetchProvider, Name, + NameType, PermissionLevel, PermissionLevelType, Signature, @@ -13,49 +10,42 @@ import { } from '@greymass/eosio' import { AbiProvider, + RequestDataV2, + RequestDataV3, + RequestSignature, ResolvedSigningRequest, - ResolvedTransaction, SigningRequest, } from 'eosio-signing-request' import zlib from 'pako' +import {ABICache} from './abi' +import { + AbstractTransactPlugin, + BaseTransactPlugin, + TransactArgs, + TransactContext, + TransactOptions, + TransactPlugin, + TransactPluginsOptions, + TransactResult, + TransactRevisions, +} from './transact' import {ChainDefinition, ChainDefinitionType, Fetch} from './types' import {getFetch} from './utils' -export type TransactPluginsOptions = Record - -export enum TransactHookTypes { - beforeSign = 'beforeSign', - afterSign = 'afterSign', - beforeBroadcast = 'beforeBroadcast', - afterBroadcast = 'afterBroadcast', -} - -export type TransactHook = ( - request: SigningRequest, - context: TransactContext -) => Promise - -export interface TransactHooks { - afterSign: TransactHook[] - beforeSign: TransactHook[] - afterBroadcast: TransactHook[] - beforeBroadcast: TransactHook[] -} - -export interface TransactHookResponse { - request: SigningRequest - signatures?: Signature[] -} - export interface WalletPluginOptions { name?: string } +export interface WalletPluginContext { + chain: ChainDefinition + permissionLevel: PermissionLevelType | string +} + export interface WalletPluginLoginOptions { appName: Name chains: ChainDefinition[] - context: SessionOptions + context: WalletPluginContext } export interface WalletPluginLoginResponse { @@ -73,155 +63,33 @@ export abstract class AbstractWalletPlugin implements WalletPlugin { public abstract sign(chain: ChainDefinition, transaction: ResolvedSigningRequest): Signature } -/** - * Options for creating a new context for a [[Session.transact]] call. - */ -export interface TransactContextOptions { - client: APIClient - fetch: Fetch - session: PermissionLevel - transactPlugins?: AbstractTransactPlugin[] - transactPluginsOptions?: TransactPluginsOptions -} - -/** - * Temporary context created for the duration of a [[Session.transact]] call. - * - * This context is used to store the state of the transact request and - * provide a way for plugins to add hooks into the process. - */ -export class TransactContext { - readonly client: APIClient - readonly fetch: Fetch - readonly hooks: TransactHooks = { - afterBroadcast: [], - afterSign: [], - beforeBroadcast: [], - beforeSign: [], - } - readonly session: PermissionLevel - readonly transactPluginsOptions: TransactPluginsOptions - constructor(options: TransactContextOptions) { - this.client = options.client - this.fetch = options.fetch - this.session = options.session - this.transactPluginsOptions = options.transactPluginsOptions || {} - options.transactPlugins?.forEach((plugin: AbstractTransactPlugin) => { - plugin.register(this) - }) - } - addHook(t: TransactHookTypes, hook: TransactHook) { - this.hooks[t].push(hook) - } -} - -/** - * Payload accepted by the [[Session.transact]] method. - * Note that one of `action`, `actions` or `transaction` must be set. - */ -export interface TransactArgs { - /** Full transaction to sign. */ - transaction?: AnyTransaction - /** Action to sign. */ - action?: AnyAction - /** Actions to sign. */ - actions?: AnyAction[] - /** An ESR payload */ - request?: SigningRequest | string -} - -/** - * Options for the [[Session.transact]] method. - */ -export interface TransactOptions { - /** - * Whether to allow the signer to make modifications to the request - * (e.g. applying a cosigner action to pay for resources). - * - * Defaults to true if [[broadcast]] is true or unspecified; otherwise false. - */ - allowModify?: boolean - /** - * Whether to broadcast the transaction or just return the signature. - * Defaults to true. - */ - broadcast?: boolean - /** - * Chain to use when configured with multiple chains. - */ - chain?: Checksum256Type - /** - * Specific transact plugins to use for this transaction. - */ - transactPlugins?: AbstractTransactPlugin[] - /** - * Optional parameters passed in to the various transact plugins. - */ - transactPluginsOptions?: TransactPluginsOptions -} - -/** - * The response from a [[Session.transact]] call. - */ -export interface TransactResult { - /** The chain that was used. */ - chain: ChainDefinition - /** The SigningRequest representation of the transaction. */ - request: SigningRequest - /** The ResolvedSigningRequest of the transaction */ - resolved: ResolvedSigningRequest | undefined - /** The response from the API after sending the transaction, only present if transaction was broadcast. */ - response?: {[key: string]: any} - /** The transaction signatures. */ - signatures: Signature[] - /** The signer authority. */ - signer: PermissionLevel - /** The resulting transaction. */ - transaction: ResolvedTransaction | undefined -} - -/** - * Interface which a [[Session.transact]] plugin must implement. - */ -export interface TransactPlugin { - register: (context: TransactContext) => void -} - -/** - * Abstract class for [[Session.transact]] plugins to extend. - */ -export abstract class AbstractTransactPlugin implements TransactPlugin { - public abstract register(context: TransactContext): void -} - -export class BaseTransactPlugin extends AbstractTransactPlugin { - register() { - // console.log('Register hooks via context.addHook') - } -} - /** * Options for creating a new instance of a [[Session]]. */ export interface SessionOptions { + actor?: NameType allowModify?: boolean broadcast?: boolean chain: ChainDefinitionType + expireSeconds?: number fetch?: Fetch - permissionLevel: PermissionLevelType | string + permission?: NameType + permissionLevel?: PermissionLevelType | string transactPlugins?: AbstractTransactPlugin[] transactPluginsOptions?: TransactPluginsOptions walletPlugin: WalletPlugin } export class Session { + readonly abiCache = ABICache readonly allowModify: boolean = true readonly broadcast: boolean = true readonly chain: ChainDefinition + readonly expireSeconds: number = 120 readonly fetch: Fetch + readonly permissionLevel: PermissionLevel readonly transactPlugins: TransactPlugin[] readonly transactPluginsOptions: TransactPluginsOptions = {} - readonly permissionLevel: PermissionLevel readonly wallet: WalletPlugin constructor(options: SessionOptions) { @@ -232,6 +100,9 @@ export class Session { if (options.broadcast !== undefined) { this.broadcast = options.broadcast } + if (options.expireSeconds) { + this.expireSeconds = options.expireSeconds + } if (options.fetch) { this.fetch = options.fetch } else { @@ -245,11 +116,19 @@ export class Session { if (options.transactPluginsOptions) { this.transactPluginsOptions = options.transactPluginsOptions } - this.permissionLevel = PermissionLevel.from(options.permissionLevel) + if (options.permissionLevel) { + this.permissionLevel = PermissionLevel.from(options.permissionLevel) + } else if (options.actor && options.permission) { + this.permissionLevel = PermissionLevel.from(`${options.actor}@${options.permission}`) + } else { + throw new Error( + 'Either a permissionLevel or actor/permission must be provided when creating a new Session.' + ) + } this.wallet = options.walletPlugin } - get account(): Name { + get actor(): Name { return this.permissionLevel.actor } @@ -288,69 +167,134 @@ export class Session { return args } - async createRequest(args: TransactArgs): Promise { - const abiProvider: AbiProvider = { - getAbi: async (account: Name): Promise => { - const response = await this.client.v1.chain.get_abi(account) - if (!response.abi) { - /* istanbul ignore next */ - throw new Error('could not load abi') // TODO: Better coverage for this - } - return response.abi - }, + /** + * Lifted from @greymass/eosio-signing-request. + * + * TODO: Remove. This will no longer be needed once the `clone` functionality in ESR is updated + */ + private storageType(version: number): typeof RequestDataV3 | typeof RequestDataV2 { + return version === 2 ? RequestDataV2 : RequestDataV3 + } + + /** + * Create a clone of the given SigningRequest + * + * @param {SigningRequest} request + * @param {AbiProvider} abiProvider + * @returns Returns a cloned SigningRequest with updated abiProvider and zlib + */ + cloneRequest(request: SigningRequest, abiProvider: AbiProvider): SigningRequest { + // Lifted from @greymass/eosio-signing-request method `clone()` + // This was done to modify the zlib and abiProvider + // TODO: Modify ESR library to expose this `clone()` functionality + // TODO: This if statement should potentially just be: + // request = args.request.clone(abiProvider, zlib) + let signature: RequestSignature | undefined + if (request.signature) { + signature = RequestSignature.from(JSON.parse(JSON.stringify(request.signature))) } + const RequestData = this.storageType(request.version) + const data = RequestData.from(JSON.parse(JSON.stringify(request.data))) + return new SigningRequest(request.version, data, zlib, abiProvider, signature) + } + + /** + * Convert any provided form of TransactArgs to a SigningRequest + * + * @param {TransactArgs} args + * @param {AbiProvider} abiProvider + * @returns Returns a SigningRequest + */ + async createRequest(args: TransactArgs, abiProvider: AbiProvider): Promise { + let request: SigningRequest const options = { abiProvider, zlib, } if (args.request && args.request instanceof SigningRequest) { - return SigningRequest.from(String(args.request), options) + request = this.cloneRequest(args.request, abiProvider) } else if (args.request) { - return SigningRequest.from(args.request, options) + request = SigningRequest.from(args.request, options) } else { args = this.upgradeTransaction(args) - const request = await SigningRequest.create( + request = await SigningRequest.create( { ...args, chainId: this.chain.id, }, options ) - return request } + return request + } + + /** + * Update a SigningRequest, ensuring its old metadata is retained. + * + * @param {SigningRequest} previous + * @param {SigningRequest} modified + * @param abiProvider + * @returns + */ + async updateRequest( + previous: SigningRequest, + modified: SigningRequest, + abiProvider: AbiProvider + ): Promise { + const updatedRequest: SigningRequest = this.cloneRequest(modified, abiProvider) + const info = updatedRequest.getRawInfo() + // Take all the metadata from the previous and set it on the modified request. + // This will preserve the metadata as it is modified by various plugins. + previous.data.info.forEach((metadata) => { + if (info[metadata.key]) { + // eslint-disable-next-line no-console -- warn the developer since this may be unintentional + console.warn( + `During an updateRequest call, the previous request had already set the ` + + `metadata key of "${metadata.key}" which will not be overwritten.` + ) + } + updatedRequest.setRawInfoKey(metadata.key, metadata.value) + }) + return updatedRequest } /** * Perform a transaction using this session. * + * @param {TransactArgs} args + * @param {TransactOptions} options + * @returns {TransactResult} The status and data gathered during the operation. * @mermaid - Transaction sequence diagram * flowchart LR * A((Transact)) --> B{{"Hook(s): beforeSign"}} * B --> C[Wallet Plugin] * C --> D{{"Hook(s): afterSign"}} - * D --> E{{"Hook(s): beforeBroadcast"}} - * E --> F[Broadcast Plugin] - * F --> G{{"Hook(s): afterBroadcast"}} - * G --> H[TransactResult] + * D --> E[Broadcast Plugin] + * E --> F{{"Hook(s): afterBroadcast"}} + * F --> G[TransactResult] */ async transact(args: TransactArgs, options?: TransactOptions): Promise { + const abiCache = new ABICache(this.client) + // The context for this transaction const context = new TransactContext({ + abiCache, client: this.client, fetch: this.fetch, + permissionLevel: this.permissionLevel, transactPlugins: options?.transactPlugins || this.transactPlugins, transactPluginsOptions: options?.transactPluginsOptions || this.transactPluginsOptions, - session: this.permissionLevel, }) // Process TransactArgs and convert to a SigningRequest - const request: SigningRequest = await this.createRequest(args) + let request: SigningRequest = await this.createRequest(args, abiCache) // Create response template to this transact call const result: TransactResult = { chain: this.chain, request, resolved: undefined, + revisions: new TransactRevisions(request), signatures: [], signer: this.permissionLevel, transaction: undefined, @@ -362,16 +306,25 @@ export class Session { ? options.allowModify : this.allowModify + // The number of seconds before this transaction expires + const expireSeconds = + options && options.expireSeconds ? options.expireSeconds : this.expireSeconds + // Whether or not the request should be broadcast during the transact call const willBroadcast = options && typeof options.broadcast !== 'undefined' ? options.broadcast : this.broadcast // Run the `beforeSign` hooks for (const hook of context.hooks.beforeSign) { - const response = await hook(result.request.clone(), context) - // TODO: Verify we should be cloning the requests here, and write tests to verify they cannot be modified + // Get the response of the hook by passing a clonied request. + const response = await hook(request.clone(), context) + + // Save revision history for developers to debug modifications to requests. + result.revisions.addRevision(response, String(hook), allowModify) + + // If modification is allowed, change the current request. if (allowModify) { - result.request = response.request.clone() + request = await this.updateRequest(request, response.request, abiCache) } // If signatures were returned, append them if (response.signatures) { @@ -379,14 +332,9 @@ export class Session { } } - // Resolve SigningRequest with authority + tapos - const info = await context.client.v1.chain.get_info() - const expireSeconds = 120 // TODO: Needs to be configurable by parameters - const header = info.getTransactionHeader(expireSeconds) - const abis = await result.request.fetchAbis() // TODO: ABI Cache Implementation - - // Resolve the request and get the resolved transaction - result.resolved = await result.request.resolve(abis, this.permissionLevel, header) + // Resolve the SigningRequest and assign it to the TransactResult + result.request = request + result.resolved = await context.resolve(request, expireSeconds) result.transaction = result.resolved.resolvedTransaction // Sign transaction based on wallet plugin @@ -398,10 +346,6 @@ export class Session { // Broadcast transaction if requested if (willBroadcast) { - // Run the `beforeBroadcast` hooks - for (const hook of context.hooks.beforeBroadcast) - await hook(result.request.clone(), context) - // Assemble the signed transaction to broadcast const signed = SignedTransaction.from({ ...result.resolved.transaction, @@ -419,3 +363,4 @@ export class Session { return result } } +export {AbstractTransactPlugin} diff --git a/src/transact.ts b/src/transact.ts new file mode 100644 index 00000000..381ae17b --- /dev/null +++ b/src/transact.ts @@ -0,0 +1,263 @@ +import { + AnyAction, + AnyTransaction, + APIClient, + Checksum256Type, + Name, + PermissionLevel, + Serializer, + Signature, +} from '@greymass/eosio' +import { + ResolvedSigningRequest, + ResolvedTransaction, + SigningRequest, + SigningRequestEncodingOptions, +} from 'eosio-signing-request' +import zlib from 'pako' +import {ABICache} from './abi' + +import {ChainDefinition, Fetch} from './types' + +export type TransactPluginsOptions = Record + +export enum TransactHookTypes { + beforeSign = 'beforeSign', + afterSign = 'afterSign', + afterBroadcast = 'afterBroadcast', +} + +export type TransactHook = ( + request: SigningRequest, + context: TransactContext +) => Promise + +export interface TransactHooks { + afterSign: TransactHook[] + beforeSign: TransactHook[] + afterBroadcast: TransactHook[] +} + +export interface TransactHookResponse { + request: SigningRequest + signatures?: Signature[] +} + +/** + * Options for creating a new context for a [[Session.transact]] call. + */ +export interface TransactContextOptions { + abiCache?: ABICache + client: APIClient + fetch: Fetch + permissionLevel: PermissionLevel + transactPlugins?: AbstractTransactPlugin[] + transactPluginsOptions?: TransactPluginsOptions +} + +/** + * Temporary context created for the duration of a [[Session.transact]] call. + * + * This context is used to store the state of the transact request and + * provide a way for plugins to add hooks into the process. + */ +export class TransactContext { + readonly abiCache: ABICache + readonly client: APIClient + readonly fetch: Fetch + readonly hooks: TransactHooks = { + afterBroadcast: [], + afterSign: [], + beforeSign: [], + } + readonly permissionLevel: PermissionLevel + readonly transactPluginsOptions: TransactPluginsOptions + + constructor(options: TransactContextOptions) { + if (options.abiCache) { + this.abiCache = options.abiCache + } else { + this.abiCache = new ABICache(options.client) + } + this.client = options.client + this.fetch = options.fetch + this.permissionLevel = options.permissionLevel + this.transactPluginsOptions = options.transactPluginsOptions || {} + options.transactPlugins?.forEach((plugin: AbstractTransactPlugin) => { + plugin.register(this) + }) + } + + get accountName(): Name { + return this.permissionLevel.actor + } + + get permissionName(): Name { + return this.permissionLevel.permission + } + + get esrOptions(): SigningRequestEncodingOptions { + return { + abiProvider: new ABICache(this.client), + zlib, + } + } + + addHook(t: TransactHookTypes, hook: TransactHook) { + this.hooks[t].push(hook) + } + + async resolve(request: SigningRequest, expireSeconds = 120): Promise { + // TODO: Cache the info/header first time the context resolves? + // If multiple plugins resolve the same request and call get_info, tapos might change + const info = await this.client.v1.chain.get_info() + const header = info.getTransactionHeader(expireSeconds) + + // Load ABIs required to resolve this request + const abis = await request.fetchAbis(this.abiCache) + + // Resolve the request and return + return request.resolve(abis, this.permissionLevel, header) + } +} +/** + * Payload accepted by the [[Session.transact]] method. + * Note that one of `action`, `actions` or `transaction` must be set. + */ +export interface TransactArgs { + /** Full transaction to sign. */ + transaction?: AnyTransaction + /** Action to sign. */ + action?: AnyAction + /** Actions to sign. */ + actions?: AnyAction[] + /** An ESR payload */ + request?: SigningRequest | string +} + +/** + * Options for the [[Session.transact]] method. + */ +export interface TransactOptions { + /** + * Whether to allow the signer to make modifications to the request + * (e.g. applying a cosigner action to pay for resources). + * + * Defaults to true if [[broadcast]] is true or unspecified; otherwise false. + */ + allowModify?: boolean + /** + * Whether to broadcast the transaction or just return the signature. + * Defaults to true. + */ + broadcast?: boolean + /** + * Chain to use when configured with multiple chains. + */ + chain?: Checksum256Type + /** + * The number of seconds in the future this transaction will expire. + */ + expireSeconds?: number + /** + * Specific transact plugins to use for this transaction. + */ + transactPlugins?: AbstractTransactPlugin[] + /** + * Optional parameters passed in to the various transact plugins. + */ + transactPluginsOptions?: TransactPluginsOptions + /** + * Optional parameter to control whether signatures returned from plugins are validated. + */ + validatePluginSignatures?: boolean +} + +export interface TransactRevision { + /** + * Whether or not the context allowed any modification to take effect. + */ + allowModify: boolean + /** + * The string representation of the code executed. + */ + code: string + /** + * If the request was modified by this code. + */ + modified: boolean + /** + * The response from the code that was executed. + */ + response: { + request: string + signatures: string[] + } +} + +export class TransactRevisions { + readonly revisions: TransactRevision[] = [] + constructor(request: SigningRequest) { + this.addRevision({request, signatures: []}, 'original', true) + } + public addRevision(response: TransactHookResponse, code: string, allowModify: boolean) { + // Determine if the new response modifies the request + let modified = false + const previous = this.revisions[this.revisions.length - 1] + if (previous) { + modified = previous.response.request !== String(response.request) + } + // Push this revision in to the stack + this.revisions.push({ + allowModify, + code: String(code), + modified, + response: { + request: String(response.request), + signatures: response.signatures ? Serializer.objectify(response.signatures) : [], + }, + }) + } +} + +/** + * The response from a [[Session.transact]] call. + */ +export interface TransactResult { + /** The chain that was used. */ + chain: ChainDefinition + /** The SigningRequest representation of the transaction. */ + request: SigningRequest + /** The ResolvedSigningRequest of the transaction */ + resolved: ResolvedSigningRequest | undefined + /** The response from the API after sending the transaction, only present if transaction was broadcast. */ + response?: {[key: string]: any} + /** An array containing revisions of the transaction as modified by plugins as ESR payloads */ + revisions: TransactRevisions + /** The transaction signatures. */ + signatures: Signature[] + /** The signer authority. */ + signer: PermissionLevel + /** The resulting transaction. */ + transaction: ResolvedTransaction | undefined +} + +/** + * Interface which a [[Session.transact]] plugin must implement. + */ +export interface TransactPlugin { + register: (context: TransactContext) => void +} + +/** + * Abstract class for [[Session.transact]] plugins to extend. + */ +export abstract class AbstractTransactPlugin implements TransactPlugin { + public abstract register(context: TransactContext): void +} + +export class BaseTransactPlugin extends AbstractTransactPlugin { + register() { + // console.log('Register hooks via context.addHook') + } +} diff --git a/src/utils.ts b/src/utils.ts index 1776c30d..c318ae92 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ -import {FetchProviderOptions} from '@greymass/eosio' +import {Action, AnyAction, FetchProviderOptions, Transaction} from '@greymass/eosio' +import {SigningRequest} from 'eosio-signing-request' import {Fetch} from './types' export function getFetch(options?: FetchProviderOptions): Fetch { @@ -13,3 +14,39 @@ export function getFetch(options?: FetchProviderOptions): Fetch { } throw new Error('Missing fetch') } + +export function appendAction(request: SigningRequest, action: AnyAction): SigningRequest { + const newAction = Action.from(action) + const cloned = request.clone() + if (cloned.data.req.value instanceof Action) { + // Overwrite the data + cloned.data.req.value = [cloned.data.req.value, newAction] + // This needs to be done to indicate it's an `Action[]` + cloned.data.req.variantIdx = 1 + } else if (cloned.data.req.value instanceof Array) { + // Prepend the action to the existing array + cloned.data.req.value.push(newAction) + } else if (cloned.data.req.value instanceof Transaction) { + // Prepend the action to the existing array of the transaction + cloned.data.req.value.actions.push(newAction) + } + return cloned +} + +export function prependAction(request: SigningRequest, action: AnyAction): SigningRequest { + const newAction = Action.from(action) + const cloned = request.clone() + if (cloned.data.req.value instanceof Action) { + // Overwrite the data + cloned.data.req.value = [newAction, cloned.data.req.value] + // This needs to be done to indicate it's an `Action[]` + cloned.data.req.variantIdx = 1 + } else if (cloned.data.req.value instanceof Array) { + // Prepend the action to the existing array + cloned.data.req.value.unshift(newAction) + } else if (cloned.data.req.value instanceof Transaction) { + // Prepend the action to the existing array of the transaction + cloned.data.req.value.actions.unshift(newAction) + } + return cloned +} diff --git a/test/rollup.config.js b/test/rollup.config.js index eb0791c1..2c85b73b 100644 --- a/test/rollup.config.js +++ b/test/rollup.config.js @@ -90,7 +90,10 @@ export default [ alias({ entries: [ {find: '$lib', replacement: path.join(__dirname, '..', 'lib/session.m.js')}, - {find: './utils/mock-provider', replacement: './utils/browser-provider.ts'}, + { + find: '$test/utils/mock-fetch', + replacement: './test/utils/browser-fetch.ts', + }, ], }), typescript({target: 'es6', module: 'esnext', tsconfig: './test/tsconfig.json'}), diff --git a/test/tests/context.ts b/test/tests/context.ts index f5e67c14..d9a6076c 100644 --- a/test/tests/context.ts +++ b/test/tests/context.ts @@ -1,9 +1,63 @@ -import {makeClient} from '$test/utils/mock-client' +import {assert} from 'chai' -const client = makeClient() +import {ABI, Checksum256, Name, PermissionLevel, Transaction} from '@greymass/eosio' +import zlib from 'pako' + +import {SigningRequest} from '$lib' +import {makeMockAction} from '$test/utils/mock-transfer' + +import {makeContext} from '$test/utils/mock-context' + +const context = makeContext() suite('context', function () { - suite('pre-sign', function () { - test('prepend action on `action`', async function () {}) + suite('abiProvider', function () { + test('has default', function () { + assert.isDefined(context.abiCache) + }) + test('fetches ABIs', async function () { + const result = await context.abiCache.getAbi(Name.from('eosio.token')) + const abi = ABI.from(result) + assert.instanceOf(result, ABI) + assert.equal(abi.version, 'eosio::abi/1.2') + }) + test('caches ABIs', async function () { + const result = await context.abiCache.getAbi(Name.from('eosio.token')) + const abi = ABI.from(result) + assert.instanceOf(result, ABI) + assert.equal(abi.version, 'eosio::abi/1.2') + }) + }) + suite('esrOptions', function () { + test('has abiProvider', function () { + assert.isDefined(context.esrOptions.abiProvider) + assert.isFunction(context.esrOptions.abiProvider?.getAbi) + }) + test('has zlib', function () { + assert.isDefined(context.esrOptions.zlib) + assert.instanceOf(context.esrOptions.zlib, Object) + }) + }) + suite('resolve', function () { + test('request', async function () { + const request = await SigningRequest.create( + { + action: makeMockAction(), + chainId: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + }, + {zlib} + ) + const resolved = await context.resolve(request) + assert.isTrue( + resolved.chainId.equals( + Checksum256.from( + '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d' + ) + ) + ) + assert.instanceOf(resolved.request, SigningRequest) + assert.instanceOf(resolved.signer, PermissionLevel) + assert.instanceOf(resolved.transaction, Transaction) + }) }) }) diff --git a/test/tests/hooks.ts b/test/tests/hooks.ts index 22a6edc8..1ce9fd6b 100644 --- a/test/tests/hooks.ts +++ b/test/tests/hooks.ts @@ -1,6 +1,5 @@ import {beforeSignHooks} from './plugins/hooks/beforeSign' import {afterSignHooks} from './plugins/hooks/afterSign' -import {beforeBroadcastHooks} from './plugins/hooks/beforeBroadcast' import {afterBroadcastHooks} from './plugins/hooks/afterBroadcast' import {beforeLoginHooks} from './plugins/hooks/beforeLogin' import {afterLoginHooks} from './plugins/hooks/afterLogin' @@ -10,7 +9,6 @@ suite('hooks', function () { suite('transactHooks', function () { beforeSignHooks() afterSignHooks() - beforeBroadcastHooks() afterBroadcastHooks() }) // Perform login hook tests diff --git a/test/tests/kit.ts b/test/tests/kit.ts index f8fb1a92..94990fbf 100644 --- a/test/tests/kit.ts +++ b/test/tests/kit.ts @@ -1,13 +1,16 @@ import {assert} from 'chai' import {BaseTransactPlugin, Session, SessionKit, SessionKitOptions} from '$lib' -import {PermissionLevel} from '@greymass/eosio' +import {PermissionLevel, TimePointSec} from '@greymass/eosio' import {makeWallet} from '$test/utils/mock-wallet' import {MockTransactPlugin} from '$test/utils/mock-hook' +import {makeMockAction} from '$test/utils/mock-transfer' import {mockFetch} from '$test/utils/mock-fetch' import {mockPermissionLevel} from '$test/utils/mock-config' +const action = makeMockAction() + const defaultSessionKitOptions: SessionKitOptions = { appName: 'demo.app', chains: [ @@ -27,6 +30,37 @@ suite('kit', function () { assert.instanceOf(sessionKit, SessionKit) }) suite('options', function () { + suite('expireSeconds', function () { + test('default: 120', async function () { + const sessionKit = new SessionKit(defaultSessionKitOptions) + const session = await sessionKit.login() + const result = await session.transact({action}, {broadcast: false}) + // Get the chain info to get the current head block time from test cache + const {head_block_time} = await session.client.v1.chain.get_info() + const expectedExpiration = head_block_time.toMilliseconds() + 120 * 1000 + assert.equal( + String(result.transaction?.expiration), + String(TimePointSec.fromMilliseconds(expectedExpiration)) + ) + }) + test('override: 60', async function () { + const sessionKit = new SessionKit({ + ...defaultSessionKitOptions, + expireSeconds: 60, + }) + const session = await sessionKit.login() + const expireSeconds = 60 + const result = await session.transact({action}, {broadcast: false}) + // Get the chain info to get the current head block time from test cache + const {head_block_time} = await session.client.v1.chain.get_info() + const expectedExpiration = + head_block_time.toMilliseconds() + expireSeconds * 1000 + assert.equal( + String(result.transaction?.expiration), + String(TimePointSec.fromMilliseconds(expectedExpiration)) + ) + }) + }) suite('transactPlugins', function () { test('default', async function () { const sessionKit = new SessionKit(defaultSessionKitOptions) diff --git a/test/tests/plugins/hooks/beforeBroadcast.ts b/test/tests/plugins/hooks/beforeBroadcast.ts deleted file mode 100644 index 0e5a813f..00000000 --- a/test/tests/plugins/hooks/beforeBroadcast.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {assert} from 'chai' - -export const beforeBroadcastHooks = () => { - suite('beforeBroadcast', function () { - test('TODO', async function () {}) - }) -} diff --git a/test/tests/plugins/transact/resource-provider.ts b/test/tests/plugins/transact/resource-provider.ts index 2576284b..df5b7834 100644 --- a/test/tests/plugins/transact/resource-provider.ts +++ b/test/tests/plugins/transact/resource-provider.ts @@ -76,7 +76,7 @@ export class MockTransactResourceProviderPlugin extends AbstractTransactPlugin { body: JSON.stringify({ ref: 'unittest', request, - signer: context.session, + signer: context.permissionLevel, }), }) @@ -125,25 +125,10 @@ export class MockTransactResourceProviderPlugin extends AbstractTransactPlugin { response: ResourceProviderResponse, context: TransactContext ): Promise { - // Establish an AbiProvider based on the session context. - const abiProvider: AbiProvider = { - getAbi: async (account: Name): Promise => { - const response = await context.client.v1.chain.get_abi(account) - if (!response.abi) { - /* istanbul ignore next */ - throw new Error('could not load abi') // TODO: Better coverage for this - } - return response.abi - }, - } - // Create a new signing request based on the response to return to the session's transact flow. const request = await SigningRequest.create( {transaction: response.data.request[1]}, - { - abiProvider, - zlib, - } + context.esrOptions ) // Set the required fee onto the request itself for wallets to process. @@ -167,7 +152,7 @@ export class MockTransactResourceProviderPlugin extends AbstractTransactPlugin { // Retrieve first authorizer and ensure it matches session context. const firstAction = request.getRawActions()[0] const firstAuthorizer = firstAction.authorization[0] - if (!firstAuthorizer.actor.equals(context.session.actor)) { + if (!firstAuthorizer.actor.equals(context.permissionLevel.actor)) { throw new Error('The first authorizer of the transaction does not match this session.') } } diff --git a/test/tests/session.ts b/test/tests/session.ts index 0bab01f8..5210e779 100644 --- a/test/tests/session.ts +++ b/test/tests/session.ts @@ -1,7 +1,7 @@ import {assert} from 'chai' import SessionKit, {BaseTransactPlugin, ChainDefinition, Session, SessionOptions} from '$lib' -import {PermissionLevel} from '@greymass/eosio' +import {Name, PermissionLevel, TimePointSec} from '@greymass/eosio' import {mockFetch} from '$test/utils/mock-fetch' import {MockTransactPlugin, MockTransactResourceProviderPlugin} from '$test/utils/mock-hook' @@ -105,6 +105,119 @@ suite('session', function () { assert.isUndefined(result.response) }) }) + suite('expireSeconds', function () { + test('default: 120', async function () { + const session = new Session({ + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permissionLevel: PermissionLevel.from(mockPermissionLevel), + walletPlugin: wallet, + }) + const result = await session.transact({action}, {broadcast: false}) + // Get the chain info to get the current head block time from test cache + const {head_block_time} = await session.client.v1.chain.get_info() + const expectedExpiration = head_block_time.toMilliseconds() + 120 * 1000 + assert.equal( + String(result.transaction?.expiration), + String(TimePointSec.fromMilliseconds(expectedExpiration)) + ) + }) + test('override: 60', async function () { + const session = new Session({ + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + expireSeconds: 60, + fetch: mockFetch, // Required for unit tests + permissionLevel: PermissionLevel.from(mockPermissionLevel), + walletPlugin: wallet, + }) + const expireSeconds = 60 + const result = await session.transact({action}, {broadcast: false}) + // Get the chain info to get the current head block time from test cache + const {head_block_time} = await session.client.v1.chain.get_info() + const expectedExpiration = + head_block_time.toMilliseconds() + expireSeconds * 1000 + assert.equal( + String(result.transaction?.expiration), + String(TimePointSec.fromMilliseconds(expectedExpiration)) + ) + }) + }) + suite('authority', function () { + suite('actor + permission', function () { + test('typed values', async function () { + const testSession = new Session({ + actor: Name.from('account'), + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permission: Name.from('permission'), + walletPlugin: wallet, + }) + assert.instanceOf(testSession, Session) + }) + test('untyped values', async function () { + const testSession = new Session({ + actor: 'account', + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permission: 'permission', + walletPlugin: wallet, + }) + assert.instanceOf(testSession, Session) + }) + }) + suite('permissionLevel', function () { + test('typed values', async function () { + const testSession = new Session({ + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permissionLevel: PermissionLevel.from('account@permission'), + walletPlugin: wallet, + }) + assert.instanceOf(testSession, Session) + }) + test('untyped values', async function () { + const testSession = new Session({ + actor: 'account', + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permissionLevel: 'account@permission', + walletPlugin: wallet, + }) + assert.instanceOf(testSession, Session) + }) + }) + test('undefined', function () { + assert.throws(() => { + new Session({ + actor: 'account', + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + walletPlugin: wallet, + }) + }) + }) + }) suite('passed as', function () { test('typed values', async function () { const testSession = new Session({ @@ -190,13 +303,9 @@ suite('session', function () { }) }) test('getters', function () { - assert.equal( - session.account, - PermissionLevel.from(mockSessionOptions.permissionLevel).actor - ) - assert.equal( - session.permission, - PermissionLevel.from(mockSessionOptions.permissionLevel).permission - ) + const expectedPermission = PermissionLevel.from(mockPermissionLevel) + // Ensure transaction authority was templated + assert.isTrue(session.actor.equals(expectedPermission.actor)) + assert.isTrue(session.permission.equals(expectedPermission.permission)) }) }) diff --git a/test/tests/transact.ts b/test/tests/transact.ts index 0352915c..fff8df0e 100644 --- a/test/tests/transact.ts +++ b/test/tests/transact.ts @@ -1,10 +1,11 @@ import {assert} from 'chai' import zlib from 'pako' -import {PermissionLevel, Serializer, Signature} from '@greymass/eosio' +import {Action, Name, PermissionLevel, Serializer, Signature, TimePointSec} from '@greymass/eosio' import {ResolvedSigningRequest, SigningRequest} from 'eosio-signing-request' import SessionKit, { + ABICache, ChainDefinition, Session, SessionOptions, @@ -14,10 +15,16 @@ import SessionKit, { import {makeClient} from '$test/utils/mock-client' import {mockFetch} from '$test/utils/mock-fetch' -import {MockTransactPlugin, MockTransactResourceProviderPlugin} from '$test/utils/mock-hook' +import { + mockMetadataFooWriterPlugin, + mockTransactActionPrependerPlugin, + MockTransactPlugin, + MockTransactResourceProviderPlugin, +} from '$test/utils/mock-hook' import {makeMockAction, makeMockActions, makeMockTransaction} from '$test/utils/mock-transfer' import {makeWallet} from '$test/utils/mock-wallet' import {mockPermissionLevel} from '$test/utils/mock-config' +import {Transfer} from '$test/utils/setup/structs' const client = makeClient() const wallet = makeWallet() @@ -134,6 +141,20 @@ suite('transact', function () { }) assetValidTransactResponse(result) }) + test('string maintains payload metadata', async function () { + const {session} = await mockData() + const result = await session.transact( + { + request: + 'esr://gmNgZGBY1mTC_MoglIGBIVzX5uxZRgEnjpsHS30fM4DAhI2nLGACDRsnxsWq9Z6yZAVLMbC4-geDaPHyjMSitOzMEoXMYoWSjFSFpNTiEgUbY0YGRua0_HzmpMQiAA', + }, + { + broadcast: false, + transactPlugins: [], + } + ) + assert.equal(result.request.getInfoKey('foo'), 'bar') + }) test('object', async function () { const {session} = await mockData() const result = await session.transact({ @@ -144,6 +165,27 @@ suite('transact', function () { }) assetValidTransactResponse(result) }) + test('object maintains payload metadata', async function () { + const {action, session} = await mockData() + const abiCache = new ABICache(this.client) + const request = await SigningRequest.create( + {action}, + { + abiProvider: abiCache, + zlib, + } + ) + request.setInfoKey('foo', 'bar') + assert.equal(request.getInfoKey('foo'), 'bar') + const result = await session.transact( + {request}, + { + broadcast: false, + transactPlugins: [], + } + ) + assert.equal(result.request.getInfoKey('foo'), 'bar') + }) }) suite('invalid', function () { test('no abi for contract', async function () { @@ -236,6 +278,49 @@ suite('transact', function () { assetValidTransactResponse(result) }) }) + suite('expireSeconds', function () { + test('default: 120', async function () { + const {action} = await mockData() + const session = new Session({ + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permissionLevel: PermissionLevel.from(mockPermissionLevel), + walletPlugin: wallet, + }) + const result = await session.transact({action}, {broadcast: false}) + // Get the chain info to get the current head block time from test cache + const {head_block_time} = await session.client.v1.chain.get_info() + const expectedExpiration = head_block_time.toMilliseconds() + 120 * 1000 + assert.equal( + String(result.transaction?.expiration), + String(TimePointSec.fromMilliseconds(expectedExpiration)) + ) + }) + test('override: 60', async function () { + const {action} = await mockData() + const session = new Session({ + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permissionLevel: PermissionLevel.from(mockPermissionLevel), + walletPlugin: wallet, + }) + const expireSeconds = 60 + const result = await session.transact({action}, {broadcast: false, expireSeconds}) + // Get the chain info to get the current head block time from test cache + const {head_block_time} = await session.client.v1.chain.get_info() + const expectedExpiration = head_block_time.toMilliseconds() + expireSeconds * 1000 + assert.equal( + String(result.transaction?.expiration), + String(TimePointSec.fromMilliseconds(expectedExpiration)) + ) + }) + }) suite('transactPlugins', function () { test('inherit', async function () { const {action} = await mockData() @@ -284,7 +369,6 @@ suite('transact', function () { register(context) { context.addHook(TransactHookTypes.beforeSign, debugHook) context.addHook(TransactHookTypes.afterSign, debugHook) - context.addHook(TransactHookTypes.beforeBroadcast, debugHook) context.addHook(TransactHookTypes.afterBroadcast, debugHook) }, } @@ -303,14 +387,6 @@ suite('transact', function () { assert.fail('Transaction with actions was not returned in result.') } }) - test('triggers', async function () { - const {action, session} = await mockData() - const result = await session.transact( - {action}, - {transactPlugins: [new MockTransactPlugin()]} - ) - assetValidTransactResponse(result) - }) }) suite('transactPluginsOptions', function () { test('transact', async function () { @@ -406,6 +482,71 @@ suite('transact', function () { }) }) }) + suite('plugins', function () { + test('trigger', async function () { + const {action, session} = await mockData() + const result = await session.transact( + {action}, + {transactPlugins: [new MockTransactPlugin()]} + ) + assetValidTransactResponse(result) + }) + test('multiple modifications', async function () { + const {action, session} = await mockData() + const result = await session.transact( + {action}, + { + transactPlugins: [ + mockTransactActionPrependerPlugin, + mockTransactActionPrependerPlugin, + ], + } + ) + assetValidTransactResponse(result) + if (result && result.transaction && result.transaction.actions) { + assert.lengthOf(result.transaction.actions, 3) + assert.isTrue(result.transaction.actions[0].account.equals('greymassnoop')) + assert.isTrue(result.transaction.actions[1].account.equals('greymassnoop')) + assert.isTrue(result.transaction.actions[2].account.equals('eosio.token')) + // Ensure these two authorizations are random and not the same + assert.isTrue( + !result.transaction.actions[0].authorization[0].actor.equals( + result.transaction.actions[1].authorization[0].actor + ) + ) + } else { + assert.fail('Transaction with actions was not returned in result.') + } + }) + test('metadata persists through mutation', async function () { + const {session} = await mockData() + const result = await session.transact( + { + request: + 'esr://gmNgZGBY1mTC_MoglIGBIVzX5uxZRgEnjpsHS30fM4DAhI2nLGACDRsnxsWq9Z6yZAVLMbC4-geDaPHyjMSitOzMEoXMYoWSjFSFpNTiEgUbY0YGRua0_HzmpMQiAA', + }, + { + broadcast: false, + transactPlugins: [mockTransactActionPrependerPlugin], + } + ) + assert.equal(result.request.getInfoKey('foo'), 'bar') + }) + test('metadata preservation from original', async function () { + const {session} = await mockData() + const result = await session.transact( + { + request: + 'esr://gmNgZGBY1mTC_MoglIGBIVzX5uxZRgEnjpsHS30fM4DAhI2nLGACDRsnxsWq9Z6yZAVLMbC4-geDaPHyjMSitOzMEoXMYoWSjFSFpNTiEgUbY0YGRua0_HzmpMQiAA', + }, + { + broadcast: false, + transactPlugins: [mockMetadataFooWriterPlugin], + } + ) + assert.equal(result.request.getInfoKey('foo'), 'bar') + }) + }) suite('response', function () { test('type check', async function () { const {session, transaction} = await mockData() @@ -422,20 +563,14 @@ suite('transact', function () { }) assert.exists(result.transaction) if (result.transaction) { + const resolvedPermission = result.transaction.actions[0].authorization[0] + const resolvedData = Transfer.from(result.transaction.actions[0].data) + const expectedPermission = PermissionLevel.from(mockPermissionLevel) // Ensure transaction authority was templated - assert.equal( - result.transaction.actions[0].authorization[0].actor, - PermissionLevel.from(mockSessionOptions.permissionLevel).actor - ) - assert.equal( - result.transaction.actions[0].authorization[0].permission, - PermissionLevel.from(mockSessionOptions.permissionLevel).permission - ) + assert.isTrue(resolvedPermission.actor.equals(expectedPermission.actor)) + assert.isTrue(resolvedPermission.permission.equals(expectedPermission.permission)) // Ensure transaction data was templated - assert.equal( - result.transaction.actions[0].data.from, - PermissionLevel.from(mockSessionOptions.permissionLevel).actor - ) + assert.isTrue(resolvedData.from.equals(expectedPermission.actor)) } else { assert.fail('Decoded transaction was not returned in result.') } @@ -451,14 +586,12 @@ suite('transact', function () { assert.exists(result.resolved) const {resolved} = result // Ensure it returns resolved request with authority templated - assert.equal( - resolved?.transaction.actions[0].authorization[0].actor, - PermissionLevel.from(mockSessionOptions.permissionLevel).actor - ) - assert.equal( - resolved?.transaction.actions[0].authorization[0].permission, - PermissionLevel.from(mockSessionOptions.permissionLevel).permission - ) + if (resolved) { + const resolvedPermission = resolved.transaction.actions[0].authorization[0] + const expectedPermission = PermissionLevel.from(mockPermissionLevel) + assert.isTrue(resolvedPermission.actor.equals(expectedPermission.actor)) + assert.isTrue(resolvedPermission.permission.equals(expectedPermission.permission)) + } }) test('valid signatures', async function () { const {action, session} = await mockData() diff --git a/test/tests/utils.ts b/test/tests/utils.ts new file mode 100644 index 00000000..a5eb2df2 --- /dev/null +++ b/test/tests/utils.ts @@ -0,0 +1,150 @@ +import {assert} from 'chai' + +import zlib from 'pako' + +import {SigningRequest, Transaction} from '$lib' +import {makeMockAction} from '$test/utils/mock-transfer' + +import {appendAction, prependAction} from 'src/utils' +import {mockData} from '$test/utils/mock-data' + +const newAction = makeMockAction('new action') + +function commonAsserts( + original: Transaction, + modified: Transaction, + oldActions = 1, + newActions = 2 +) { + // Ensure no data besides the actions has changed + original.context_free_actions.forEach((action, index) => { + assert.isTrue(action.equals(modified.context_free_actions[index])) + }) + modified.context_free_actions.forEach((action, index) => { + assert.isTrue(action.equals(original.context_free_actions[index])) + }) + assert.isTrue(original.delay_sec.equals(modified.delay_sec)) + assert.isTrue(original.expiration.equals(modified.expiration)) + assert.isTrue(!original.id.equals(modified.id)) + assert.isTrue(original.max_cpu_usage_ms.equals(modified.max_cpu_usage_ms)) + assert.isTrue(original.max_net_usage_words.equals(modified.max_net_usage_words)) + assert.isTrue(original.ref_block_num.equals(modified.ref_block_num)) + assert.isTrue(original.ref_block_prefix.equals(modified.ref_block_prefix)) + original.transaction_extensions.forEach((extension, index) => { + assert.isTrue(extension.equals(modified.transaction_extensions[index])) + }) + modified.transaction_extensions.forEach((extension, index) => { + assert.isTrue(extension.equals(original.transaction_extensions[index])) + }) + + // Ensure the original transaction remains + assert.equal(original.actions.length, oldActions) + + // Ensure the modified transaction updated correctly + assert.equal(modified.actions.length, newActions) +} + +suite('utils', function () { + suite('appendAction', function () { + test('payload w/ action', async function () { + const {action} = await mockData('old action') + const request = await SigningRequest.create( + { + action, + chainId: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + }, + {zlib} + ) + const originalTransaction = request.getRawTransaction() + const modifiedRequest = appendAction(request, newAction) + const modifiedTransaction = modifiedRequest.getRawTransaction() + commonAsserts(originalTransaction, modifiedTransaction) + assert.isTrue(originalTransaction.actions[0].equals(modifiedTransaction.actions[0])) + assert.isTrue(newAction.equals(modifiedTransaction.actions[1])) + }) + test('payload w/ actions', async function () { + const {action} = await mockData('old action') + const request = await SigningRequest.create( + { + actions: [action, action], + chainId: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + }, + {zlib} + ) + const originalTransaction = request.getRawTransaction() + const modifiedRequest = appendAction(request, newAction) + const modifiedTransaction = modifiedRequest.getRawTransaction() + commonAsserts(originalTransaction, modifiedTransaction, 2, 3) + assert.isTrue(originalTransaction.actions[0].equals(modifiedTransaction.actions[0])) + assert.isTrue(originalTransaction.actions[1].equals(modifiedTransaction.actions[1])) + assert.isTrue(newAction.equals(modifiedTransaction.actions[2])) + }) + test('payload w/ transaction', async function () { + const {transaction} = await mockData('old action') + const request = await SigningRequest.create( + { + transaction, + chainId: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + }, + {zlib} + ) + const originalTransaction = request.getRawTransaction() + const modifiedRequest = appendAction(request, newAction) + const modifiedTransaction = modifiedRequest.getRawTransaction() + commonAsserts(originalTransaction, modifiedTransaction) + assert.isTrue(originalTransaction.actions[0].equals(modifiedTransaction.actions[0])) + assert.isTrue(newAction.equals(modifiedTransaction.actions[1])) + }) + }) + suite('prependAction', function () { + test('payload w/ action', async function () { + const {action} = await mockData('old action') + const request = await SigningRequest.create( + { + action, + chainId: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + }, + {zlib} + ) + const originalTransaction = request.getRawTransaction() + const modifiedRequest = prependAction(request, newAction) + const modifiedTransaction = modifiedRequest.getRawTransaction() + commonAsserts(originalTransaction, modifiedTransaction) + assert.isTrue(newAction.equals(modifiedTransaction.actions[0])) + assert.isTrue(originalTransaction.actions[0].equals(modifiedTransaction.actions[1])) + }) + test('payload w/ actions', async function () { + const {action} = await mockData('old action') + const request = await SigningRequest.create( + { + actions: [action, action], + chainId: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + }, + {zlib} + ) + const originalTransaction = request.getRawTransaction() + const modifiedRequest = prependAction(request, newAction) + const modifiedTransaction = modifiedRequest.getRawTransaction() + commonAsserts(originalTransaction, modifiedTransaction, 2, 3) + assert.isTrue(newAction.equals(modifiedTransaction.actions[0])) + assert.isTrue(originalTransaction.actions[0].equals(modifiedTransaction.actions[1])) + assert.isTrue(originalTransaction.actions[1].equals(modifiedTransaction.actions[2])) + }) + test('payload w/ transaction', async function () { + const {transaction} = await mockData('old action') + const request = await SigningRequest.create( + { + transaction, + chainId: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + }, + {zlib} + ) + const originalTransaction = request.getRawTransaction() + const modifiedRequest = prependAction(request, newAction) + const modifiedTransaction = modifiedRequest.getRawTransaction() + commonAsserts(originalTransaction, modifiedTransaction) + assert.isTrue(newAction.equals(modifiedTransaction.actions[0])) + assert.isTrue(originalTransaction.actions[0].equals(modifiedTransaction.actions[1])) + }) + }) +}) diff --git a/test/utils/browser-fetch.ts b/test/utils/browser-fetch.ts new file mode 100644 index 00000000..76fa9ecf --- /dev/null +++ b/test/utils/browser-fetch.ts @@ -0,0 +1,26 @@ +import {Bytes, Checksum160} from '@greymass/eosio' + +const data = global.MOCK_DATA + +export function getFilename(path, params) { + const digest = Checksum160.hash( + Bytes.from(path + (params ? JSON.stringify(params) : ''), 'utf8') + ).hexString + return digest + '.json' +} + +async function getExisting(filename) { + return data[filename] +} + +export async function mockFetch(path, params) { + const filename = getFilename(path, params) + const existing = await getExisting(filename) + if (existing) { + return new Response(existing.text, { + status: existing.status, + headers: existing.headers, + }) + } + throw new Error(`No data for ${path}`) +} diff --git a/test/utils/browser-provider.ts b/test/utils/browser-provider.ts deleted file mode 100644 index 33a910ae..00000000 --- a/test/utils/browser-provider.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {APIProvider, Bytes, Checksum160} from '@greymass/eosio' - -const data = global.MOCK_DATA - -export class MockProvider implements APIProvider { - constructor(private api: string = 'https://jungle4.greymass.com') {} - - getFilename(path: string, params?: unknown) { - const digest = Checksum160.hash( - Bytes.from(this.api + path + (params ? JSON.stringify(params) : ''), 'utf8') - ).hexString - return digest + '.json' - } - - async getExisting(filename: string) { - return data[filename] - } - - async call(path: string, params?: unknown) { - const filename = this.getFilename(path, params) - const existing = await this.getExisting(filename) - if (existing) { - return existing - } - throw new Error(`No data for ${path}`) - } -} diff --git a/test/utils/mock-client.ts b/test/utils/mock-client.ts index 2c2d8a72..fa10c144 100644 --- a/test/utils/mock-client.ts +++ b/test/utils/mock-client.ts @@ -1,7 +1,7 @@ import {APIClient, FetchProvider} from '@greymass/eosio' import {mockUrl} from './mock-config' -import {mockFetch} from './mock-fetch' +import {mockFetch} from '$test/utils/mock-fetch' export function makeClient(url?: string) { return new APIClient({ diff --git a/test/utils/mock-context.ts b/test/utils/mock-context.ts index ef23c245..32bde04e 100644 --- a/test/utils/mock-context.ts +++ b/test/utils/mock-context.ts @@ -2,7 +2,7 @@ import {TransactContext} from '$lib' import {APIClient, FetchProvider, PermissionLevel} from '@greymass/eosio' import {mockUrl} from './mock-config' -import {mockFetch} from './mock-fetch' +import {mockFetch} from '$test/utils/mock-fetch' export function makeContext(): TransactContext { return new TransactContext({ @@ -10,6 +10,6 @@ export function makeContext(): TransactContext { provider: new FetchProvider(mockUrl, {fetch: mockFetch}), }), fetch: mockFetch, - session: PermissionLevel.from('wharfkit1125@test'), + permissionLevel: PermissionLevel.from('wharfkit1125@test'), }) } diff --git a/test/utils/mock-data.ts b/test/utils/mock-data.ts new file mode 100644 index 00000000..4ba81b75 --- /dev/null +++ b/test/utils/mock-data.ts @@ -0,0 +1,22 @@ +import {Session} from '$lib' + +import {makeClient} from '$test/utils/mock-client' +import {mockSessionOptions} from './mock-session' +import {makeMockAction, makeMockActions, makeMockTransaction} from '$test/utils/mock-transfer' + +const client = makeClient() + +export async function mockData(memo?: string) { + const info = await client.v1.chain.get_info() + const action = await makeMockAction(memo) + const actions = await makeMockActions(memo) + const transaction = await makeMockTransaction(info, memo) + const session = new Session(mockSessionOptions) + return { + action, + actions, + info, + session, + transaction, + } +} diff --git a/test/utils/mock-fetch.ts b/test/utils/mock-fetch.ts index 76c4ae0a..bde22c83 100644 --- a/test/utils/mock-fetch.ts +++ b/test/utils/mock-fetch.ts @@ -26,6 +26,9 @@ async function getExisting(filename: string) { } export async function mockFetch(path, params) { + if (process.env['LOGHTTP']) { + console.log('HTTP Request', {path, params}) + } const filename = getFilename(path, params) if (process.env['MOCK'] !== 'overwrite') { const existing = await getExisting(filename) diff --git a/test/utils/mock-hook.ts b/test/utils/mock-hook.ts index 89a9dc81..4830278a 100644 --- a/test/utils/mock-hook.ts +++ b/test/utils/mock-hook.ts @@ -10,6 +10,7 @@ import { TransactHookTypes, Transaction, } from '$lib' +import {prependAction} from 'src/utils' export async function mockLoginHook(context: SessionOptions) { // Mock hook that does nothing @@ -27,11 +28,16 @@ export class MockTransactPlugin extends AbstractTransactPlugin { register(context: TransactContext): void { context.addHook(TransactHookTypes.beforeSign, mockTransactHook) context.addHook(TransactHookTypes.afterSign, mockTransactHook) - context.addHook(TransactHookTypes.beforeBroadcast, mockTransactHook) context.addHook(TransactHookTypes.afterBroadcast, mockTransactHook) } } +// Needed to load the ABI and work with an `Action` object +class noop extends Struct { + static abiName = 'noop' + static abiFields = [] +} + export async function mockTransactResourceProviderPresignHook( request: SigningRequest, context: TransactContext @@ -46,13 +52,6 @@ export async function mockTransactResourceProviderPresignHook( signatures: [], } } - // Clone the request for modification - const cloned = request.clone() - // Needed to load the ABI and work with an `Action` object - class noop extends Struct { - static abiName = 'noop' - static abiFields = [] - } const newAction = Action.from({ account: 'greymassnoop', name: 'noop', @@ -64,25 +63,10 @@ export async function mockTransactResourceProviderPresignHook( ], data: noop.from({}), }) - // TODO: Couldn't work with normal objects here - // Needs to do a bunch of conditional logic - shoulnd't be required for a hook - if (cloned.data.req.value instanceof Action) { - // Overwrite the data - cloned.data.req.value = [newAction, cloned.data.req.value] - // This needs to be done to indicate it's an `Action[]` - cloned.data.req.variantIdx = 1 - } else if (cloned.data.req.value instanceof Array) { - // Prepend the action to the existing array - cloned.data.req.value.unshift(newAction) - } else if (cloned.data.req.value instanceof Transaction) { - // Prepend the action to the existing array of the transaction - cloned.data.req.value.actions.unshift(newAction) - } else { - throw new Error('Unrecognized data type in request.') - } + const modified = prependAction(request, newAction) // Return the request return { - request: cloned, + request: modified, signatures: [], } } @@ -92,3 +76,40 @@ export class MockTransactResourceProviderPlugin extends AbstractTransactPlugin { context.addHook(TransactHookTypes.beforeSign, mockTransactResourceProviderPresignHook) } } + +export const mockTransactActionPrependerPlugin = { + register: (context) => + context.addHook(TransactHookTypes.beforeSign, async (request, context) => ({ + request: await SigningRequest.create( + { + actions: [ + { + account: 'greymassnoop', + name: 'noop', + authorization: [ + { + actor: [...Array(12)] + .map(() => Math.random().toString(36)[2]) + .join(''), + permission: 'test', + }, + ], + data: {}, + }, + ...request.getRawActions(), + ], + }, + context.esrOptions + ), + })), +} + +export const mockMetadataFooWriterPlugin = { + register: (context) => + context.addHook(TransactHookTypes.beforeSign, async (request) => { + request.setInfoKey('foo', 'baz') + return { + request, + } + }), +} diff --git a/test/utils/mock-session.ts b/test/utils/mock-session.ts new file mode 100644 index 00000000..925307ba --- /dev/null +++ b/test/utils/mock-session.ts @@ -0,0 +1,20 @@ +import {PermissionLevel} from '@greymass/eosio' + +import {ChainDefinition, SessionOptions} from '$lib' + +import {mockPermissionLevel} from '$test/utils/mock-config' +import {mockFetch} from '$test/utils/mock-fetch' +import {makeWallet} from '$test/utils/mock-wallet' + +const wallet = makeWallet() + +export const mockSessionOptions: SessionOptions = { + broadcast: false, // Disable broadcasting by default for tests, enable when required. + chain: ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }), + fetch: mockFetch, // Required for unit tests + permissionLevel: PermissionLevel.from(mockPermissionLevel), + walletPlugin: wallet, +} diff --git a/test/utils/mock-transfer.ts b/test/utils/mock-transfer.ts index 4e690a63..9bf6cd18 100644 --- a/test/utils/mock-transfer.ts +++ b/test/utils/mock-transfer.ts @@ -1,4 +1,5 @@ import {Action, API, Asset, Name, Struct, Transaction} from '@greymass/eosio' + import {mockAccountName, mockPermissionName} from './mock-config' @Struct.type('transfer') diff --git a/test/utils/setup/accounts.md b/test/utils/setup/accounts.md index 6215779f..08f716d3 100644 --- a/test/utils/setup/accounts.md +++ b/test/utils/setup/accounts.md @@ -1,16 +1,17 @@ ## Testing accounts -| account@permission | tokens | cpu | net | ram | -| ------------------ | ------ | --- | --- | --- | -| wharfkit1111@test | ✅ | ✅ | ✅ | ✅ | -| wharfkit1112@test | ✅ | ❌ | ✅ | ✅ | -| wharfkit1113@test | ✅ | ✅ | ❌ | ✅ | -| wharfkit1114@test | ✅ | ✅ | ✅ | ❌ | -| wharfkit1115@test | ✅ | ❌ | ❌ | ❌ | -| wharfkit1121@test | ❌ | ✅ | ✅ | ✅ | -| wharfkit1122@test | ❌ | ❌ | ✅ | ✅ | -| wharfkit1123@test | ❌ | ✅ | ❌ | ✅ | -| wharfkit1124@test | ❌ | ✅ | ✅ | ❌ | -| wharfkit1125@test | ❌ | ❌ | ❌ | ❌ | -| wharfkit1131@test | ✅ | ❌ | ❌ | ✅ | -| wharfkit1132@test | ❌ | ❌ | ❌ | ✅ | +| account@permission | tokens | cpu | net | ram | +| ------------------- | ------ | --- | --- | --- | +| wharfkit1111@test | ✅ | ✅ | ✅ | ✅ | +| wharfkit1112@test | ✅ | ❌ | ✅ | ✅ | +| wharfkit1113@test | ✅ | ✅ | ❌ | ✅ | +| wharfkit1114@test | ✅ | ✅ | ✅ | ❌ | +| wharfkit1115@test | ✅ | ❌ | ❌ | ❌ | +| wharfkit1121@test | ❌ | ✅ | ✅ | ✅ | +| wharfkit1122@test | ❌ | ❌ | ✅ | ✅ | +| wharfkit1123@test | ❌ | ✅ | ❌ | ✅ | +| wharfkit1124@test | ❌ | ✅ | ✅ | ❌ | +| wharfkit1125@test | ❌ | ❌ | ❌ | ❌ | +| wharfkit1131@test | ✅ | ❌ | ❌ | ✅ | +| wharfkit1132@test | ❌ | ❌ | ❌ | ✅ | +| wharfkitnoop@cosign | ✅ | ✅ | ✅ | ✅ | diff --git a/test/utils/setup/accounts.ts b/test/utils/setup/accounts.ts index 75ee0af2..743198db 100644 --- a/test/utils/setup/accounts.ts +++ b/test/utils/setup/accounts.ts @@ -12,7 +12,7 @@ import { import {Buyrambytes, Delegatebw, Linkauth, Newaccount, Transfer, Updateauth} from './structs' // Mock of Fetch for debugging/testing -// import {mockFetch} from '../mock-fetch' +// import {mockFetch} from '$test/utils/mock-fetch' /** * THIS INFORMATION NEEDS TO BE POPULATED @@ -31,6 +31,9 @@ const controlKey = 'EOS6XXTaRpWhPwnb7CTV9zVsCBrvCpYMMPSk8E8hsJxhf6VFW9DYN' // Test permission key to set on all the accounts const testKey = 'EOS6RMS3nvoN9StPzZizve6WdovaDkE5KkEcCDXW7LbepyAioMiK6' +// Cosigner Key for wharfkitnoop +const noopKey = 'EOS8WUgppBZ1NjnGASYeLwQ3PkNLvdnfnchumsSpo6ApCAzbETczm' + // Minimum RAM bytes to create an account const requiredRamBytes = 1598 @@ -132,6 +135,13 @@ const accounts: AccountDefinition[] = [ netStake: undefined, ramBytes: 10000, }, + { + name: 'wharfkitnoop', + balance: '5.0000 EOS', + cpuStake: '1.0000 EOS', + netStake: '1.0000 EOS', + ramBytes: 10000, + }, ] async function createAccount( @@ -312,6 +322,56 @@ async function createTestPermission(account: AccountDefinition): Promise