UNPKG

anchor-link

Version:

Library for authenticating and signing transactions using the Anchor Link protocol

838 lines (801 loc) 29.6 kB
import zlib from 'pako' import { ABIDef, ABISerializable, AnyAction, AnyTransaction, API, APIClient, Bytes, Name, NameType, PermissionLevel, PermissionLevelType, PrivateKey, Serializer, Signature, SignedTransaction, Transaction, } from '@wharfkit/antelope' import { AbiProvider, CallbackPayload, ChainId, ChainIdType, IdentityProof, PlaceholderName, PlaceholderPermission, ResolvedSigningRequest, ResolvedTransaction, SigningRequest, SigningRequestCreateArguments, } from '@wharfkit/signing-request' import {CancelError, IdentityError} from './errors' import {LinkOptions} from './link-options' import {LinkChannelSession, LinkFallbackSession, LinkSession} from './link-session' import {LinkStorage} from './link-storage' import {LinkTransport} from './link-transport' import {LinkCreate} from './link-types' import {BuoyCallbackService, LinkCallback, LinkCallbackService} from './link-callback' import {sessionMetadata} from './utils' /** * Payload accepted by the [[Link.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[] } /** * Options for the [[Link.transact]] method. */ export interface TransactOptions { /** * Whether to broadcast the transaction or just return the signature. * Defaults to true. */ broadcast?: boolean /** * Chain to use when configured with multiple chains. */ chain?: LinkChainType /** * Whether the signer can make modifications to the request * (e.g. applying a cosigner action to pay for resources). * * Defaults to false if [[broadcast]] is true or unspecified; otherwise true. */ noModify?: boolean } /** * The result of a [[Link.transact]] call. */ export interface TransactResult { /** The resolved signing request. */ resolved: ResolvedSigningRequest /** The chain that was used. */ chain: LinkChain /** The transaction signatures. */ signatures: Signature[] /** The callback payload. */ payload: CallbackPayload /** The signer authority. */ signer: PermissionLevel /** The resulting transaction. */ transaction: Transaction /** Resolved version of transaction, with the action data decoded. */ resolvedTransaction: ResolvedTransaction /** Push transaction response from api node, only present if transaction was broadcast. */ processed?: {[key: string]: any} } /** * The result of a [[Link.identify]] call. */ export interface IdentifyResult extends TransactResult { /** The identified account, not present unless [[LinkOptions.verifyProofs]] is set to true. */ account?: API.v1.AccountObject /** The identity proof. */ proof: IdentityProof } /** * The result of a [[Link.login]] call. */ export interface LoginResult extends IdentifyResult { /** The session created by the login. */ session: LinkSession } /** * Link chain, can be a [[LinkChain]] instance, a chain id or a index in [[Link.chains]]. * @internal */ export type LinkChainType = LinkChain | ChainIdType | number /** * Class representing a EOSIO chain. */ export class LinkChain implements AbiProvider { /** EOSIO ChainID for which requests are valid. */ public chainId: ChainId /** API client instance used to communicate with the chain. */ public client: APIClient private abiCache = new Map<string, ABIDef>() private pendingAbis = new Map<string, Promise<API.v1.GetAbiResponse>>() /** @internal */ constructor(chainId: ChainIdType, clientOrUrl: APIClient | string) { this.chainId = ChainId.from(chainId) this.client = typeof clientOrUrl === 'string' ? new APIClient({url: clientOrUrl}) : clientOrUrl } /** * Fetch the ABI for given account, cached. * @internal */ public async getAbi(account: Name) { const key = String(account) let rv = this.abiCache.get(key) if (!rv) { let getAbi = this.pendingAbis.get(key) if (!getAbi) { getAbi = this.client.v1.chain.get_abi(account) this.pendingAbis.set(key, getAbi) } rv = (await getAbi).abi this.pendingAbis.delete(key) if (rv) { this.abiCache.set(key, rv) } } return rv as ABIDef } } /** * Anchor Link main class. * * @example * * ```ts * import AnchorLink from 'anchor-link' * import ConsoleTransport from 'anchor-link-console-transport' * * const link = new AnchorLink({ * transport: new ConsoleTransport(), * chains: [ * { * chainId: 'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906', * nodeUrl: 'https://eos.greymass.com', * }, * ], * }) * * const result = await link.transact({actions: myActions}) * ``` */ export class Link { /** Package version. */ static version: string = '__ver' // eslint-disable-line @typescript-eslint/no-inferrable-types /** Chains this instance is configured with. */ public readonly chains: LinkChain[] /** Transport used to deliver requests to the users wallet. */ public readonly transport: LinkTransport /** Storage adapter used to persist sessions. */ public readonly storage?: LinkStorage private callbackService: LinkCallbackService private verifyProofs: boolean private encodeChainIds: boolean /** Create a new link instance. */ constructor(options: LinkOptions) { if (typeof options !== 'object') { throw new TypeError('Missing options object') } if (!options.transport) { throw new TypeError('options.transport is required') } let chains = options.chains || [] if (options.chainId && options.client) { if (options.chains.length > 0) { throw new TypeError( 'options.chainId and options.client are deprecated and cannot be used together with options.chains' ) } chains = [{chainId: options.chainId, nodeUrl: options.client}] } if (chains.length === 0) { throw new TypeError('options.chains is required') } this.chains = chains.map((chain) => { if (chain instanceof LinkChain) { return chain } if (!chain.chainId) { throw new TypeError('options.chains[].chainId is required') } if (!chain.nodeUrl) { throw new TypeError('options.chains[].nodeUrl is required') } return new LinkChain(chain.chainId, chain.nodeUrl) }) if (options.service === undefined || typeof options.service === 'string') { this.callbackService = new BuoyCallbackService( options.service || LinkOptions.defaults.service ) } else { this.callbackService = options.service } this.transport = options.transport if (options.storage !== null) { this.storage = options.storage || this.transport.storage } this.verifyProofs = options.verifyProofs !== undefined ? options.verifyProofs : LinkOptions.defaults.verifyProofs this.encodeChainIds = options.encodeChainIds !== undefined ? options.encodeChainIds : LinkOptions.defaults.encodeChainIds } /** * The APIClient instance for communicating with the node. * @note This returns the first APIClient when link is configured with multiple chains. */ public get client() { return this.chains[0].client } /** * Return a [[LinkChain]] object for given chainId or chain reference. * @throws If this link instance has no configured chain for given reference. * @internal */ public getChain(chain: LinkChainType) { if (chain instanceof LinkChain) { return chain } if (typeof chain === 'number') { const rv = this.chains[chain] if (!rv) { throw new Error(`Invalid chain index: ${chain}`) } return rv } const id = ChainId.from(chain) const rv = this.chains.find((c) => c.chainId.equals(id)) if (!rv) { throw new Error(`Unsupported chain: ${id}`) } return rv } /** * Create a SigningRequest instance configured for this link. * @internal */ public async createRequest( args: SigningRequestCreateArguments, chain?: LinkChain, transport?: LinkTransport ) { const t = transport || this.transport let request: SigningRequest if (chain || this.chains.length === 1) { const c = chain || this.chains[0] request = await SigningRequest.create( { ...args, chainId: c.chainId, broadcast: false, }, {abiProvider: c, zlib} ) } else { // multi-chain request request = await SigningRequest.create( { ...args, chainId: null, chainIds: this.encodeChainIds ? this.chains.map((c) => c.chainId) : undefined, broadcast: false, }, // abi's will be pulled from the first chain and assumed to be identical on all chains {abiProvider: this.chains[0], zlib} ) } if (t.prepare) { request = await t.prepare(request) } const callback = this.callbackService.create() request.setCallback(callback.url, true) return {request, callback} } /** * Send a SigningRequest instance using this link. * @internal */ public async sendRequest( request: SigningRequest, callback: LinkCallback, chain?: LinkChain, transport?: LinkTransport, broadcast = false ) { const t = transport || this.transport try { const linkUrl = request.data.callback if (linkUrl !== callback.url) { throw new Error('Invalid request callback') } if (request.data.flags.broadcast === true || request.data.flags.background === false) { throw new Error('Invalid request flags') } // wait for callback or user cancel let done = false const cancel = new Promise<never>((resolve, reject) => { t.onRequest(request, (reason) => { if (done) { // ignore any cancel calls once callbackResponse below has resolved return } const error = typeof reason === 'string' ? new CancelError(reason) : reason if (t.recoverError && t.recoverError(error, request) === true) { // transport was able to recover from the error return } callback.cancel() reject(error) }) }) const callbackResponse = await Promise.race([callback.wait(), cancel]) done = true if (typeof callbackResponse.rejected === 'string') { throw new CancelError(callbackResponse.rejected) } const payload = callbackResponse as CallbackPayload const signer = PermissionLevel.from({ actor: payload.sa, permission: payload.sp, }) const signatures: Signature[] = Object.keys(payload) .filter((key) => key.startsWith('sig') && key !== 'sig0') .map((key) => Signature.from(payload[key]!)) let c: LinkChain if (!chain && this.chains.length > 1) { if (!payload.cid) { throw new Error( 'Multi chain response payload must specify resolved chain id (cid)' ) } c = this.getChain(payload.cid) } else { c = chain || this.getChain(0) if (payload.cid && !c.chainId.equals(payload.cid)) { throw new Error('Got response for wrong chain id') } } // recreate transaction from request response const resolved = await ResolvedSigningRequest.fromPayload(payload, { zlib, abiProvider: c, }) // prepend cosigner signature if present const cosignerSig = resolved.request.getInfoKey('cosig', { type: Signature, array: true, }) as Signature[] | undefined if (cosignerSig) { signatures.unshift(...cosignerSig) } const result: TransactResult = { resolved, chain: c, transaction: resolved.transaction, resolvedTransaction: resolved.resolvedTransaction, signatures, payload, signer, } if (broadcast) { const signedTx = SignedTransaction.from({ ...resolved.transaction, signatures, }) const res = await c.client.v1.chain.push_transaction(signedTx) result.processed = res.processed } if (t.onSuccess) { t.onSuccess(request, result) } return result } catch (error) { if (t.onFailure) { t.onFailure(request, error) } throw error } } /** * Sign and optionally broadcast a EOSIO transaction, action or actions. * * Example: * * ```ts * let result = await myLink.transact({transaction: myTx}) * ``` * * @param args The action, actions or transaction to use. * @param options Options for this transact call. * @param transport Transport override, for internal use. */ public async transact( args: TransactArgs, options?: TransactOptions, transport?: LinkTransport ): Promise<TransactResult> { const o = options || {} const t = transport || this.transport const c = o.chain !== undefined ? this.getChain(o.chain) : undefined const broadcast = o.broadcast !== false const noModify = o.noModify !== undefined ? o.noModify : !broadcast // Initialize the loading state of the transport if (t && t.showLoading) { t.showLoading() } // eosjs transact compat: upgrade to transaction if args have any header fields const anyArgs = args as any if ( args.actions && (anyArgs.expiration || anyArgs.ref_block_num || anyArgs.ref_block_prefix || anyArgs.max_net_usage_words || anyArgs.max_cpu_usage_ms || anyArgs.delay_sec) ) { args = { transaction: { expiration: '1970-01-01T00:00:00', ref_block_num: 0, ref_block_prefix: 0, max_net_usage_words: 0, max_cpu_usage_ms: 0, delay_sec: 0, ...anyArgs, }, } } const {request, callback} = await this.createRequest(args, c, t) if (noModify) { request.setInfoKey('no_modify', true, 'bool') } const result = await this.sendRequest(request, callback, c, t, broadcast) return result } /** * Send an identity request and verify the identity proof if [[LinkOptions.verifyProofs]] is true. * @param args.scope The scope of the identity request. * @param args.requestPermission Optional request permission if the request is for a specific account or permission. * @param args.info Metadata to add to the request. * @note This is for advanced use-cases, you probably want to use [[Link.login]] instead. */ public async identify(args: { scope: NameType requestPermission?: PermissionLevelType info?: {[key: string]: ABISerializable | Bytes} }): Promise<IdentifyResult> { const {request, callback} = await this.createRequest({ identity: {permission: args.requestPermission, scope: args.scope}, info: args.info, }) const res = await this.sendRequest(request, callback) if (!res.resolved.request.isIdentity()) { throw new IdentityError('Unexpected response') } let account: API.v1.AccountObject | undefined const proof = res.resolved.getIdentityProof(res.signatures[0]) if (this.verifyProofs) { account = await res.chain.client.v1.chain.get_account(res.signer.actor) if (!account) { throw new IdentityError(`Signature from unknown account: ${proof.signer.actor}`) } const accountPermission = account.permissions.find(({perm_name}) => proof.signer.permission.equals(perm_name) ) if (!accountPermission) { throw new IdentityError( `${proof.signer.actor} signed for unknown permission: ${proof.signer.permission}` ) } const proofValid = proof.verify( accountPermission.required_auth, account.head_block_time ) if (!proofValid) { throw new IdentityError(`Invalid identify proof for: ${proof.signer}`) } } if (args.requestPermission) { const perm = PermissionLevel.from(args.requestPermission) if ( (!perm.actor.equals(PlaceholderName) && !perm.actor.equals(proof.signer.actor)) || (!perm.permission.equals(PlaceholderPermission) && !perm.permission.equals(proof.signer.permission)) ) { throw new IdentityError( `Identity proof singed by ${proof.signer}, expected: ${formatAuth(perm)} ` ) } } return { ...res, account, proof, } } /** * Login and create a persistent session. * @param identifier The session identifier, an EOSIO name (`[a-z1-5]{1,12}`). * Should be set to the contract account if applicable. */ public async login(identifier: NameType): Promise<LoginResult> { const privateKey = PrivateKey.generate('K1') const requestKey = privateKey.toPublic() const createInfo = LinkCreate.from({ session_name: identifier, request_key: requestKey, user_agent: this.getUserAgent(), }) const res = await this.identify({ scope: identifier, info: { link: createInfo, scope: identifier, }, }) const metadata = sessionMetadata(res.payload, res.resolved.request) const signerKey = res.proof.recover() let session: LinkSession if (res.payload.link_ch && res.payload.link_key && res.payload.link_name) { session = new LinkChannelSession( this, { identifier, chainId: res.chain.chainId, auth: res.signer, publicKey: signerKey, channel: { url: res.payload.link_ch, key: res.payload.link_key, name: res.payload.link_name, }, requestKey: privateKey, }, metadata ) } else { session = new LinkFallbackSession( this, { identifier, chainId: res.chain.chainId, auth: res.signer, publicKey: signerKey, }, metadata ) } await this.storeSession(session) return { ...res, session, } } /** * Restore previous session, use [[login]] to create a new session. * @param identifier The session identifier, must be same as what was used when creating the session with [[login]]. * @param auth A specific session auth to restore, if omitted the most recently used session will be restored. * @param chainId If given function will only consider that specific chain when restoring session. * @returns A [[LinkSession]] instance or null if no session can be found. * @throws If no [[LinkStorage]] adapter is configured or there was an error retrieving the session data. **/ public async restoreSession( identifier: NameType, auth?: PermissionLevelType, chainId?: ChainIdType ) { if (!this.storage) { throw new Error('Unable to restore session: No storage adapter configured') } let key: string if (auth && chainId) { // both auth and chain id given, we can look up on specific key key = this.sessionKey( identifier, formatAuth(PermissionLevel.from(auth)), String(ChainId.from(chainId)) ) } else { // otherwise we use the session list to filter down to most recently used matching given params let list = await this.listSessions(identifier) if (auth) { list = list.filter((item) => item.auth.equals(auth)) } if (chainId) { const id = ChainId.from(chainId) list = list.filter((item) => item.chainId.equals(id)) } const latest = list[0] if (!latest) { return null } key = this.sessionKey(identifier, formatAuth(latest.auth), String(latest.chainId)) } const data = await this.storage.read(key) if (!data) { return null } let sessionData: any try { sessionData = JSON.parse(data) } catch (error) { throw new Error( `Unable to restore session: Stored JSON invalid (${error.message || String(error)})` ) } const session = LinkSession.restore(this, sessionData) if (auth || chainId) { // update latest used await this.touchSession(identifier, session.auth, session.chainId) } return session } /** * List stored session auths for given identifier. * The most recently used session is at the top (index 0). * @throws If no [[LinkStorage]] adapter is configured or there was an error retrieving the session list. **/ public async listSessions(identifier: NameType) { if (!this.storage) { throw new Error('Unable to list sessions: No storage adapter configured') } const key = this.sessionKey(identifier, 'list') let list: {auth: PermissionLevelType; chainId: ChainIdType}[] try { list = JSON.parse((await this.storage.read(key)) || '[]') } catch (error) { throw new Error(`Unable to list sessions: ${error.message || String(error)}`) } return list.map(({auth, chainId}) => ({ auth: PermissionLevel.from(auth), chainId: ChainId.from(chainId), })) } /** * Remove stored session for given identifier and auth. * @throws If no [[LinkStorage]] adapter is configured or there was an error removing the session data. */ public async removeSession(identifier: NameType, auth: PermissionLevel, chainId: ChainId) { if (!this.storage) { throw new Error('Unable to remove session: No storage adapter configured') } const key = this.sessionKey(identifier, formatAuth(auth), String(chainId)) await this.storage.remove(key) await this.touchSession(identifier, auth, chainId, true) } /** * Remove all stored sessions for given identifier. * @throws If no [[LinkStorage]] adapter is configured or there was an error removing the session data. */ public async clearSessions(identifier: string) { if (!this.storage) { throw new Error('Unable to clear sessions: No storage adapter configured') } for (const {auth, chainId} of await this.listSessions(identifier)) { await this.removeSession(identifier, auth, chainId) } } /** * Create an eosjs compatible signature provider using this link. * @param availableKeys Keys the created provider will claim to be able to sign for. * @param chain Chain to use when configured with multiple chains. * @param transport (internal) Transport override for this call. * @note We don't know what keys are available so those have to be provided, * to avoid this use [[LinkSession.makeSignatureProvider]] instead. Sessions can be created with [[Link.login]]. */ public makeSignatureProvider( availableKeys: string[], chain?: LinkChainType, transport?: LinkTransport ): any { return { getAvailableKeys: async () => availableKeys, sign: async (args) => { const t = transport || this.transport const c = chain ? this.getChain(chain) : this.chains[0] let request = SigningRequest.fromTransaction( args.chainId, args.serializedTransaction, {abiProvider: c, zlib} ) const callback = this.callbackService.create() request.setCallback(callback.url, true) request.setBroadcast(false) if (t.prepare) { request = await t.prepare(request) } const {transaction, signatures} = await this.sendRequest(request, callback, c, t) const serializedTransaction = Serializer.encode({object: transaction}) return { ...args, serializedTransaction, signatures, } }, } } /** Makes sure session is in storage list of sessions and moves it to top (most recently used). */ private async touchSession( identifier: NameType, auth: PermissionLevel, chainId: ChainId, remove = false ) { const list = await this.listSessions(identifier) const existing = list.findIndex( (item) => item.auth.equals(auth) && item.chainId.equals(chainId) ) if (existing >= 0) { list.splice(existing, 1) } if (remove === false) { list.unshift({auth, chainId}) } const key = this.sessionKey(identifier, 'list') await this.storage!.write(key, JSON.stringify(list)) } /** * Makes sure session is in storage list of sessions and moves it to top (most recently used). * @internal */ async storeSession(session: LinkSession) { if (this.storage) { const key = this.sessionKey( session.identifier, formatAuth(session.auth), String(session.chainId) ) const data = JSON.stringify(session.serialize()) await this.storage.write(key, data) await this.touchSession(session.identifier, session.auth, session.chainId) } } /** Session storage key for identifier and suffix. */ private sessionKey(identifier: NameType, ...suffix: string[]) { return [String(Name.from(identifier)), ...suffix].join('-') } /** * Return user agent of this link. * @internal */ getUserAgent() { let rv = `AnchorLink/${Link.version}` if (this.transport.userAgent) { rv += ' ' + this.transport.userAgent() } return rv } } /** * Format a EOSIO permission level in the format `actor@permission` taking placeholders into consideration. * @internal */ function formatAuth(auth: PermissionLevelType): string { const a = PermissionLevel.from(auth) const actor = a.actor.equals(PlaceholderName) ? '<any>' : String(a.actor) let permission: string if (a.permission.equals(PlaceholderName) || a.permission.equals(PlaceholderPermission)) { permission = '<any>' } else { permission = String(a.permission) } return `${actor}@${permission}` }