@fioprotocol/fiosdk
Version:
The Foundation for Interwallet Operability (FIO) is a consortium of leading blockchain wallets, exchanges and payments providers that seeks to accelerate blockchain adoption by reducing the risk, complexity, and inconvenience of sending and receiving cryp
543 lines (485 loc) • 17 kB
text/typescript
import {Api as FioJsApi} from '@fioprotocol/fiojs'
import {
AbiProvider,
AuthorityProvider,
AuthorityProviderArgs,
BinaryAbi,
} from '@fioprotocol/fiojs/dist/chain-api-interfaces'
import {JsSignatureProvider} from '@fioprotocol/fiojs/dist/chain-jssig'
import {arrayToHex, base64ToBinary} from '@fioprotocol/fiojs/dist/chain-numeric'
import {GetBlockResult, PushTransactionArgs} from '@fioprotocol/fiojs/dist/chain-rpc-interfaces'
import { PropertyDefinition } from 'validate'
import {AbortSignal} from 'abort-controller'
import {TextDecoder, TextEncoder} from 'text-encoding'
import {
AbiResponse,
Account,
Action,
ContentType,
EndPoint, ExecuteCallError,
FioError,
FioInfoResponse,
FioLogger,
RawRequest,
ValidationError,
} from '../entities'
import {API_ERROR_CODES, defaultExpirationOffset} from '../utils/constants'
import {
asyncWaterfall,
createAuthorization,
createRawAction,
createRawRequest,
defaultTextDecoder,
defaultTextEncoder,
getCipherContent,
getUnCipherContent,
} from '../utils/utils'
import {validate} from '../utils/validation'
type FetchJson = (uri: string, opts?: object) => any
interface SignedTxArgs {
compression: number,
packed_context_free_data: string,
packed_trx: string,
signatures: string[],
}
export const signAllAuthorityProvider: AuthorityProvider = {
async getRequiredKeys(authorityProviderArgs: AuthorityProviderArgs) {
const {availableKeys} = authorityProviderArgs
return availableKeys
},
}
export const fioApiErrorCodes = [API_ERROR_CODES.BAD_REQUEST, API_ERROR_CODES.FORBIDDEN, API_ERROR_CODES.NOT_FOUND, API_ERROR_CODES.CONFLICT]
export const FIO_CHAIN_INFO_ERROR_CODE = 800
export const FIO_BLOCK_NUMBER_ERROR_CODE = 801
export type ApiMap = Map<string, AbiResponse>
// TODO use fiojs type in future
export type RequestConfig = {
fioProvider: FioProvider;
fetchJson: FetchJson;
baseUrls: string[];
logger?: FioLogger
}
export interface FioProvider {
prepareTransaction(param: {
abiMap: ApiMap,
chainId: string,
privateKeys: string[],
textDecoder?: TextDecoder,
textEncoder?: TextEncoder,
transaction: RawRequest,
}): Promise<any>
accountHash(pubKey: string): string
}
export class Transactions {
public static abiMap: ApiMap = new Map()
protected publicKey: string = ''
protected privateKey: string = ''
protected validationData: object = {}
protected validationRules: Record<string, PropertyDefinition> | null = null
protected expirationOffset: number = defaultExpirationOffset
protected authPermission: string | undefined
protected signingAccount: string | undefined
constructor(protected config: RequestConfig) {}
public getActor(publicKey: string = ''): string {
return this.config.fioProvider.accountHash((publicKey === '' || !publicKey) ? this.publicKey : publicKey)
}
public async getChainInfo(): Promise<FioInfoResponse> {
const options = {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
method: 'GET',
}
return await this.multicastServers({endpoint: `chain/${EndPoint.getInfo}`, fetchOptions: options})
}
public async getBlock(chain: FioInfoResponse): Promise<GetBlockResult> {
if (chain === undefined || !chain) {
throw new Error('chain undefined')
}
if (chain.last_irreversible_block_num === undefined) {
throw new Error('chain.last_irreversible_block_num undefined')
}
return await this.multicastServers({
endpoint: `chain/${EndPoint.getBlock}`, fetchOptions: {
body: JSON.stringify({
block_num_or_id: chain.last_irreversible_block_num,
}),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
method: 'POST',
},
})
}
public async getChainDataForTx(): Promise<{
chain_id: string,
ref_block_num: number,
ref_block_prefix: number,
expiration: string,
}> {
let chain: FioInfoResponse
let block: GetBlockResult
try {
chain = await this.getChainInfo()
} catch (error) {
if ((error as Error).name === 'ValidationError') {
throw error
}
// tslint:disable-next-line:no-console
console.error('chain:: ' + error)
const e: Error & { errorCode?: number } = new Error(`Error while fetching chain info`)
e.errorCode = FIO_CHAIN_INFO_ERROR_CODE
throw e
}
try {
block = await this.getBlock(chain)
} catch (error) {
// tslint:disable-next-line:no-console
console.error('block: ' + error)
const e: Error & { errorCode?: number } = new Error(`Error while fetching block`)
e.errorCode = FIO_BLOCK_NUMBER_ERROR_CODE
throw e
}
const expiration = new Date(chain.head_block_time + 'Z')
expiration.setSeconds(expiration.getSeconds() + this.expirationOffset)
const expirationStr = expiration.toISOString()
return {
chain_id: chain.chain_id,
expiration: expirationStr.substring(0, expirationStr.length - 1),
// tslint:disable-next-line:no-bitwise
ref_block_num: block.block_num & 0xFFFF,
ref_block_prefix: block.ref_block_prefix,
}
}
public setRawRequestExp(
rawRequest: RawRequest,
chainData: {
ref_block_num: number,
ref_block_prefix: number,
expiration: string,
},
): void {
rawRequest.ref_block_num = chainData.ref_block_num
rawRequest.ref_block_prefix = chainData.ref_block_prefix
rawRequest.expiration = chainData.expiration
}
public generateApiProvider(
abiMap: Map<string, any>,
): AbiProvider {
return {
async getRawAbi(accountName: string) {
const rawAbi = abiMap.get(accountName)
if (!rawAbi) {
throw new Error(`Missing ABI for account ${accountName}`)
}
const abi = base64ToBinary(rawAbi.abi)
const binaryAbi: BinaryAbi = {accountName: rawAbi.account_name, abi}
return binaryAbi
},
}
}
public initFioJsApi(
{
chainId,
abiMap,
textDecoder = defaultTextDecoder,
textEncoder = defaultTextEncoder,
privateKeys,
}: {
chainId: string,
abiMap: Map<string, any>,
privateKeys: string[],
textDecoder?: TextDecoder,
textEncoder?: TextEncoder,
},
): FioJsApi {
return new FioJsApi({
abiProvider: this.generateApiProvider(abiMap),
authorityProvider: signAllAuthorityProvider,
chainId,
signatureProvider: new JsSignatureProvider(privateKeys),
textDecoder,
textEncoder,
})
}
public async createRawTransaction(
{account, action, authPermission, data, publicKey, chainData, signingAccount}: {
account: Account;
action: Action;
authPermission?: string;
data: any;
publicKey?: string;
chainData?: {
ref_block_num: number,
ref_block_prefix: number,
expiration: string,
};
signingAccount?: string;
},
): Promise<RawRequest> {
const actor = this.getActor(publicKey)
if (!data.actor) {
data.actor = actor
}
const rawTransaction = createRawRequest({
actions: [
createRawAction({
account,
actor: signingAccount,
authorization: [createAuthorization(data.actor, authPermission)],
data,
name: action,
}),
],
})
if (chainData && chainData.ref_block_num) {
this.setRawRequestExp(rawTransaction, chainData)
}
return rawTransaction
}
public async serialize(
{
chainId,
abiMap = Transactions.abiMap,
transaction,
textDecoder = defaultTextDecoder,
textEncoder = defaultTextEncoder,
}: {
transaction: RawRequest,
chainId: string,
abiMap?: Map<string, any>,
textDecoder?: TextDecoder,
textEncoder?: TextEncoder,
},
): Promise<PushTransactionArgs> {
const api = this.initFioJsApi({
abiMap,
chainId,
privateKeys: [],
textDecoder,
textEncoder,
})
return await api.transact(transaction, {sign: false})
}
public async deserialize(
{
chainId,
abiMap = Transactions.abiMap,
serializedTransaction,
textDecoder = defaultTextDecoder,
textEncoder = defaultTextEncoder,
}: {
serializedTransaction: Uint8Array,
chainId: string,
abiMap?: Map<string, any>,
textDecoder?: TextDecoder,
textEncoder?: TextEncoder,
},
): Promise<RawRequest> {
const api = this.initFioJsApi({
abiMap,
chainId,
privateKeys: [],
textDecoder,
textEncoder,
})
return await api.deserializeTransactionWithActions(serializedTransaction)
}
public async sign(
{
abiMap = Transactions.abiMap,
chainId,
privateKeys,
transaction,
serializedTransaction,
serializedContextFreeData,
}: {
abiMap?: Map<string, any>,
chainId: string,
privateKeys: string[],
transaction: RawRequest,
serializedTransaction: any,
serializedContextFreeData: any,
},
): Promise<SignedTxArgs> {
const signatureProvider = new JsSignatureProvider(privateKeys)
const availableKeys = await signatureProvider.getAvailableKeys()
const requiredKeys = await signAllAuthorityProvider.getRequiredKeys({transaction, availableKeys})
const api = this.initFioJsApi({
abiMap,
chainId,
privateKeys,
})
const abis: BinaryAbi[] = await api.getTransactionAbis(transaction)
const signedTx = await signatureProvider.sign({
abis,
chainId,
requiredKeys,
serializedContextFreeData,
serializedTransaction,
})
return {
compression: 0,
packed_context_free_data: arrayToHex(signedTx.serializedContextFreeData || new Uint8Array(0)),
packed_trx: arrayToHex(signedTx.serializedTransaction),
signatures: signedTx.signatures,
}
}
public async pushToServer(transaction: RawRequest, endpoint: string, dryRun: boolean): Promise<any> {
const privateKeys: string[] = []
privateKeys.push(this.privateKey)
const chainData = await this.getChainDataForTx()
this.setRawRequestExp(transaction, chainData)
const signedTransaction = await this.config.fioProvider.prepareTransaction({
abiMap: Transactions.abiMap,
chainId: chainData.chain_id,
privateKeys,
textDecoder: new TextDecoder(),
textEncoder: new TextEncoder(),
transaction,
})
if (dryRun) {
return signedTransaction
}
return this.multicastServers({endpoint, body: JSON.stringify(signedTransaction)})
}
public async executeCall({
baseUrl,
endPoint,
body,
fetchOptions,
signal,
}: {
baseUrl: string,
endPoint: string,
body?: string | null,
fetchOptions?: any,
signal: AbortSignal,
}): Promise<any> {
let options: any
this.validate()
if (fetchOptions != null) {
options = fetchOptions
if (body != null) {
options.body = body
}
} else {
options = {
body,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
method: 'POST',
}
}
options.signal = signal
try {
const res = await this.config.fetchJson(baseUrl + endPoint, options)
if (res === undefined) {
const error = new Error(`Error: Can't reach the site ${baseUrl}${endPoint}. Possible wrong url.`)
return {
data: {
code: 500,
message: error.message,
},
isError: true,
}
}
if (!res.ok) {
const error = new ExecuteCallError(
`Error ${res.status} while fetching ${baseUrl + endPoint}`,
res.status,
)
try {
error.json = await res.json()
if (fioApiErrorCodes.indexOf(res.status) > -1) {
if (
error.json &&
error.json.fields &&
error.json.fields[0] &&
error.json.fields[0].error
) {
error.message = error.json.fields[0].error
}
return {
data: {
code: error.errorCode || res.status,
json: error.json,
message: error.message,
},
isError: true,
}
}
} catch (e) {
error.json = {}
this.config.logger?.({
context: {
endpoint: endPoint,
error,
},
type: 'execute',
})
}
throw error
}
return res.json()
} catch (e) {
// @ts-ignore
e.requestParams = {baseUrl, endPoint, body, fetchOptions}
throw e
}
}
public async multicastServers(req: {
endpoint: string,
body?: string | null,
fetchOptions?: any,
requestTimeout?: number,
}): Promise<any> {
const {endpoint, body, fetchOptions, requestTimeout} = req
const res = await asyncWaterfall({
asyncFunctions: this.config.baseUrls.map((apiUrl) => (signal: AbortSignal) =>
this.executeCall({baseUrl: apiUrl, endPoint: endpoint, body, fetchOptions, signal}),
),
requestTimeout,
baseUrls: this.config.baseUrls,
})
// TODO asyncWaterfall can throw errors and error interface can be different
if (res?.isError) {
const error = new FioError(res.errorMessage || res.data.message)
error.json = res.data.json
error.list = res.data.list
error.errorCode = res.data.code
this.config.logger?.({ type: 'request', context: {...req, error} })
throw error
}
this.config.logger?.({ type: 'request', context: {...req, res} })
return res
}
public getCipherContent(
contentType: ContentType,
content: any,
privateKey: string,
publicKey: string,
) {
return getCipherContent(contentType, content, privateKey, publicKey)
}
public getUnCipherContent<T = any>(
contentType: ContentType,
content: string,
privateKey: string,
publicKey: string,
) {
return getUnCipherContent<T>(contentType, content, privateKey, publicKey)
}
public validate() {
if (this.validationRules) {
const validation = validate(this.validationData, this.validationRules)
if (!validation.isValid) {
throw new ValidationError(validation.errors, `Validation error`)
}
}
}
}