diff --git a/package-lock.json b/package-lock.json index eed8a80..ae1f3ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1488,6 +1488,11 @@ "tweetnacl": "^0.14.3" } }, + "bignumber.js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", + "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==" + }, "binary-extensions": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", diff --git a/package.json b/package.json index 854b68e..1d9e7d8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "homepage": "https://github.com/Emurgo/yoroi-ergo-backend#readme", "dependencies": { "@coinbarn/ergo-ts": "^0.3.0", + "bignumber.js": "9.0.0", "bunyan": "^1.8.14", "config": "^3.3.1", "node-fetch": "^2.6.1", diff --git a/src/api.js b/src/api.js index d7e9618..c4faa7b 100644 --- a/src/api.js +++ b/src/api.js @@ -2,6 +2,7 @@ const config = require('config'); const fetch = require('node-fetch'); const utils = require('./utils'); +const BigNumber = require('bignumber.js'); import type { UtxoForAddressesInput, @@ -21,6 +22,15 @@ import type { UtilEither, UtilOK, } from './types/utils'; +import type { + getApiV0BlocksP1SuccessResponse, + getApiV0AddressesP1TransactionsSuccessResponse, + getApiV0AddressesP1TransactionsItem, + getApiV0BlocksSuccessResponse, + postApiV0TransactionsSendSuccessResponse, + getApiV0TransactionsP1SuccessResponse, + getApiV0AddressesP1SuccessResponse, +} from './types/explorer'; const addressesRequestLimit = 50; const apiResponseLimit = 50; @@ -29,11 +39,11 @@ const askBlockNum = async (blockHash: string, txHash?: string): Promise number = (item) => { + // recall: Ergo requires at least one output per transaction + return item.outputs[0].creationHeight; +} + const askTransactionHistory = async ( limit: number , addresses: string[] , afterNum: number , afterTxHash: ?string , untilNum: number - ) : Promise> => { + ) : Promise>> => { - let output: any = []; + let output: Array = []; - const addressesPromises = addresses.map((address) => ( + const responses = await Promise.all(addresses.map((address) => ( fetch(`${config.backend.explorer}/api/v0/addresses/${address}/transactions`) - )) + ))); - const responses = await Promise.all(addressesPromises) - const responsesJson = []; + const unfilteredResponses: Array = []; for (const response of responses) { if (response.status !== 200) return {kind:'error', errMsg: `error querying transactions for address`}; - responsesJson.push(await response.json()); + const json: getApiV0AddressesP1TransactionsSuccessResponse = await response.json(); + unfilteredResponses.push(...json.items); } - if (responsesJson.length == 0) return output; + if (unfilteredResponses.length == 0) return { + kind: 'ok', + value: [], + }; - for(const response of responsesJson[0].items) { + for(const response of unfilteredResponses) { // filter by limit after and until - if (response.creationHeight <= afterNum) { + const creationHeight = getCreationHeight(response); + if (creationHeight <= afterNum) { continue; } - if (response.creationHeight > untilNum) { + if (creationHeight > untilNum) { continue; } output.push(response); @@ -84,7 +103,7 @@ const askTransactionHistory = async ( if (afterTxHash != undefined) { const index = output - .findIndex((tx) => (tx.id === afterTxHash)) + .findIndex((tx) => (tx.id === afterTxHash)) if (index != undefined) { output = output.slice(index + 1) } @@ -98,10 +117,13 @@ const askTransactionHistory = async ( const bestBlock: HandlerFunction = async function (req, _res) { const resp = await fetch( - `${config.backend.explorer}/api/v0/blocks` + `${config.backend.explorer}/api/v0/blocks` ); - const r = await resp.json(); + if (resp.status !== 200) { + return {status: 400, body: `error getting bestBlock`}; + } + const r: getApiV0BlocksSuccessResponse = await resp.json(); const output = { epoch: 0, slot: r.items[0].height, @@ -127,18 +149,22 @@ const signed: HandlerFunction = async function (req, _res) { body: JSON.stringify(signedTx) }) - const r = await resp.json(); + if (resp.status !== 200) { + return { status: 400, body: `error sending transaction`}; + } + const r: postApiV0TransactionsSendSuccessResponse = await resp.json(); return { status: 200, body: r }; }; -async function getUtxoForAddress(address: string): Promise { +async function getUtxoForAddress(address: string): Promise> { const resp = await fetch( `${config.backend.explorer}/api/v0/addresses/${address}/transactions` ); - const r = await resp.json(); + if (resp.status !== 200) return {kind:'error', errMsg: `error querying utxos for address`}; + const r: getApiV0AddressesP1TransactionsSuccessResponse = await resp.json(); // Get all outputs whose `address` matches input address and `spentTransactionId` is `null`. - return r.items.map(({ outputs }) => ( + const result = r.items.map(({ outputs }) => ( outputs .map((output, index) => ({ output, index })) .filter(({ output, index }) => @@ -153,76 +179,130 @@ async function getUtxoForAddress(address: string): Promise { receiver: address, amount: String(output.value) })); + + return { + kind: 'ok', + value: result, + }; } const utxoForAddresses: HandlerFunction = async function (req, _res) { const input: UtxoForAddressesInput = req.body; - const output: UtxoForAddressesOutput = (await Promise.all( + const outputsForAddresses: Array> = (await Promise.all( input.addresses.map(getUtxoForAddress) - )).flat(); - return { status: 200, body: output }; + )); + + const result: UtxoForAddressesOutput = []; + for (const outputsForAddress of outputsForAddresses) { + if (outputsForAddress.kind === 'error') { + return { status: 400, body: outputsForAddress.errMsg} + } + result.push(...outputsForAddress.value); + } + return { status: 200, body: result }; } -async function getBalanceForAddress(address: string): Promise { +async function getBalanceForAddress(address: string): Promise> { const resp = await fetch( `${config.backend.explorer}/api/v0/addresses/${address}` ); - const r = await resp.json(); + if (resp.status !== 200) return {kind:'error', errMsg: `error querying utxos for address`}; + const r: getApiV0AddressesP1SuccessResponse = await resp.json(); if (r.transactions && typeof r.transactions.confirmedBalance === 'number') { - return r.transactions.confirmedBalance; + return { + kind: 'ok', + value: r.transactions.confirmedBalance, + }; } - return 0; + return { + kind: 'ok', + value: 0, + }; } const utxoSumForAddresses: HandlerFunction = async function (req, _res) { const input: UtxoSumForAddressesInput = req.body; - const sum = (await Promise.all( + const balances = await Promise.all( input.addresses.map(getBalanceForAddress) - )).reduce(((a, b) => a + b), 0); - const output: UtxoSumForAddressesOutput = { sum: String(sum) }; + ); + + let sum = new BigNumber(0); + for (const balance of balances) { + if (balance.kind === 'error') { + return {status: 400, body: balance.errMsg}; + } + sum = sum.plus(balance.value); + } + const output: UtxoSumForAddressesOutput = { sum: sum.toString() }; return { status: 200, body: output }; } -async function isUsed(address: string): Promise<{| used: boolean, address: string |}> { +async function isUsed(address: string): Promise> { const resp = await fetch( `${config.backend.explorer}/api/v0/addresses/${address}` ); - const r = await resp.json(); + if (resp.status !== 200) return {kind:'error', errMsg: `error querying address information`}; + + const r: getApiV0AddressesP1SuccessResponse = await resp.json(); return { - used: r.transactions && r.transactions.totalReceived !== 0, - address, + kind: 'ok', + value: { + used: r.transactions && r.transactions.totalReceived !== 0, + address, + }, }; } const filterUsed: HandlerFunction = async function (req, _res) { const input: FilterUsedInput = req.body; - const output: FilterUsedOutput = (await Promise.all( + const usedStatuses = await Promise.all( input.addresses.map(isUsed) - )).filter(({ used }) => used).map(({ address }) => address); - - return { status: 200, body: output }; + ); + const result: FilterUsedOutput = []; + for (const status of usedStatuses) { + if (status.kind === 'error') { + return {status: 400, body: status.errMsg}; + } + if (status.value.used) { + result.push(status.value.address); + } + } + return { status: 200, body: result }; } -async function getTxBody(txHash: string): Promise<[string, string]> { +async function getTxBody(txHash: string): Promise> { const resp = await fetch( `${config.backend.explorer}/api/v0/transactions/${txHash}` ); + if (resp.status !== 200) { + return { kind: 'error', errMsg: `error sending transaction`}; + } - const txBody = await resp.json(); - return [ txHash, JSON.stringify(txBody) ]; + const txBody: getApiV0TransactionsP1SuccessResponse = await resp.json(); + return { + kind: 'ok', + value: [ txHash, txBody ], + }; } const txBodies: HandlerFunction = async function (req, _res) { const input: TxBodiesInput = req.body; - const output: TxBodiesOutput = Object.fromEntries(await Promise.all( + const txBodyEntries = await Promise.all( input.txHashes.map(getTxBody) - )); + ); + const result: {| [key: string]: getApiV0TransactionsP1SuccessResponse |} = {}; + for (const entry of txBodyEntries) { + if (entry.kind === 'error') { + return {status: 400, body: entry.errMsg}; + } + result[entry.value[0]] = entry.value[1]; + } - return { status: 200, body: output }; + return { status: 200, body: result }; } const history: HandlerFunction = async function (req, _res) { @@ -238,7 +318,7 @@ const history: HandlerFunction = async function (req, _res) { switch (verifiedBody.kind) { case "ok": const body = verifiedBody.value; - const limit = body.limit || apiResponseLimit; + const limit = apiResponseLimit; const [referenceTx, referenceBlock] = (body.after && [body.after.tx, body.after.block]) || []; const referenceBestBlock = body.untilBlock; @@ -257,17 +337,18 @@ const history: HandlerFunction = async function (req, _res) { return { status: 400, body: unformattedTxs.errMsg} } const txs = unformattedTxs.value.map((tx) => { + const creationHeight = getCreationHeight(tx); const iso8601date = new Date(tx.timestamp).toISOString() return { hash: tx.id, is_reference: tx.id === referenceTx, - tx_state: 'Successful', // graphql doesn't handle pending/failed txs + tx_state: 'Successful', // explorer doesn't handle pending transactions last_update: iso8601date, - block_num: tx.creationHeight, + block_num: creationHeight, block_hash: tx.headerId, // don't have it time: iso8601date, - epoch: 0, - slot: tx.creationHeight, + epoch: 0, // TODO + slot: 0, // TODO inputs: tx.inputs, outputs: tx.outputs } diff --git a/src/types/explorer.js b/src/types/explorer.js new file mode 100644 index 0000000..1df08e7 --- /dev/null +++ b/src/types/explorer.js @@ -0,0 +1,264 @@ +// @flow + +// Types from https://explorer.ergoplatform.com/en/api + +type FailResponse = {| + status: number, + reason: string, +|}; + +// api/v0/blocks/${blockHash} +export type getApiV0BlocksP1SuccessResponse = { + "block": { + "header": { + "id": string, + "parentId": string, + "version": number, + "height": number, + "difficulty": string, + "adProofsRoot": string, + "stateRoot": string, + "transactionsRoot": string, + "timestamp": number, + "nBits": number, + "size": number, + "extensionHash": string, + "powSolutions": { + "pk": string, + "w": string, + "n": string, + "d": string, + ..., + }, + "votes": { + "_1": number, + "_2": number, + "_3": number, + ..., + }, + ..., + }, + "blockTransactions": Array<{ + "id": string, + "headerId": string, + "timestamp": number, + "confirmationsCount": number, + "inputs": Array<{ + "id": string, + "spendingProof": string, + "value": number, + "transactionId": string, + "outputTransactionId": string, + "address": string, + ..., + }>, + "dataInputs": Array<{ + "id": string, + "value": number, + "transactionId": string, + "outputTransactionId": string, + "address": string, + ..., + }>, + "outputs": Array<{ + "id": string, + "value": number, + "creationHeight": number, + "ergoTree": string, + "address": string, + "assets": Array<{ + "tokenId": string, + "amount": number, + ..., + }>, + "additionalRegisters": {...}, + "spentTransactionId": string, + "mainChain": boolean, + ..., + }>, + }>, + "extension": { + "headerId": string, + "digest": string, + "fields": {...}, + ..., + }, + "adProofs": string, + ..., + }, + "references": { + "previousId": string, + "nextId": string, + ..., + }, + ..., +}; + +export type getApiV0BlocksP1Response = getApiV0BlocksP1SuccessResponse | FailResponse; + +// api/v0/addresses/${address}/transactions +export type getApiV0AddressesP1TransactionsItem = { + "id": string, + "headerId": string, + "timestamp": number, + "confirmationsCount": number, + "inputs": Array<{ + "id": string, + "spendingProof": string, + "value": number, + "transactionId": string, + "outputTransactionId": string, + "address": string, + ..., + }>, + "dataInputs": Array<{ + "id": string, + "value": number, + "transactionId": string, + "outputTransactionId": string, + "address": string, + ..., + }>, + "outputs": Array<{ + "id": string, + "value": number, + "creationHeight": number, + "ergoTree": string, + "address": string, + "assets": Array<{ + "tokenId": string, + "amount": number, + ..., + }>, + "additionalRegisters": {...}, + "spentTransactionId": string, + "mainChain": boolean, + ..., + }> +}; +export type getApiV0AddressesP1TransactionsSuccessResponse = { + "items": Array, + "total": number, + ..., +}; + +export type getApiV0AddressesP1TransactionsResponse = getApiV0AddressesP1TransactionsSuccessResponse | FailResponse; + +// api/v0/blocks +export type getApiV0BlocksSuccessResponse = { + "items": Array<{ + "id": string, + "height": number, + "timestamp": number, + "transactionsCount": number, + "miner": { + "address": string, + "name": string, + ..., + }, + "size": number, + "difficulty": number, + "minerReward": number, + ..., + }>, + "total": number, + ..., +}; +export type getApiV0BlocksResponse = getApiV0BlocksSuccessResponse | FailResponse; + +// api/v0/transactions/send +export type postApiV0TransactionsSendSuccessResponse = { + "id": string, + ..., +}; +export type postApiV0TransactionsSendResponse = postApiV0TransactionsSendSuccessResponse | FailResponse; + +// api/v0/addresses/${address} +export type getApiV0AddressesP1SuccessResponse = { + "summary": { + "id": string, + ..., + }, + "transactions": { + "confirmed": number, + "totalReceived": string, + "confirmedBalance": number, + "totalBalance": number, + "confirmedTokensBalance": Array<{ + "tokenId": string, + "amount": number, + ..., + }>, + "totalTokensBalance": Array<{ + "tokenId": string, + "amount": number, + ..., + }>, + ..., + }, + ..., +}; +export type getApiV0AddressesP1Response = getApiV0AddressesP1SuccessResponse | FailResponse; + +// api/v0/transactions/${txHash} +export type getApiV0TransactionsP1SuccessResponse = { + "id": string, + "miniBlockInfo": { + "id": string, + "height": number, + ..., + }, + "timestamp": number, + "confirmationsCount": number, + "inputs": Array<{ + "id": string, + "spendingProof": string, + "value": number, + "transactionId": string, + "outputTransactionId": string, + "address": string, + ..., + }>, + "dataInputs": Array<{ + "id": string, + "value": number, + "transactionId": string, + "outputTransactionId": string, + "address": string, + ..., + }>, + "outputs": Array<{ + "id": string, + "value": number, + "creationHeight": number, + "ergoTree": string, + "address": string, + "assets": Array<{ + "tokenId": string, + "amount": number, + ..., + }>, + "additionalRegisters": {...}, + "spentTransactionId": string, + "mainChain": boolean, + ..., + }>, + "size": number, + "ioSummary": { + "totalCoinsTransferred": number, + "totalFee": number, + "feePerByte": number, + ..., + }, + ..., +}; +export type getApiV0TransactionsP1Response = getApiV0TransactionsP1SuccessResponse | FailResponse; + +export type getApiV0InfoSuccessResponse = { + "version": string, + "supply": number, + "transactionAverage": number, + "hashRate": number, + ..., +}; +export type getApiV0InfoResponse = getApiV0InfoSuccessResponse | FailResponse; diff --git a/src/types/utils.js b/src/types/utils.js index 0da23e0..dc55a58 100644 --- a/src/types/utils.js +++ b/src/types/utils.js @@ -6,6 +6,7 @@ export type HandlerReturn = {| body: Object, |}; +/** All functions exposed through an endpoint must be of type HandlerFunction */ export type HandlerFunction = (request: Request, response: Response) => Promise; diff --git a/src/utils.js b/src/utils.js index 698d529..eeb24a8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,6 +1,11 @@ // @flow import type { UtilEither } from './types/utils'; +import type { HistoryInput } from './types/wrapperApi'; + +function assertNever(x: any): any { + throw new Error ("this should never happen" + x); +} /** * This method validates addresses request body @@ -16,31 +21,15 @@ const validateAddressesReq = (addressRequestLimit: number, addresses: string[]): return { kind: "ok", value: addresses }; }; -// type TxBlockData = { -// // TODO -// ..., -// }; -type HistoryRequest = { - addresses: string[], - limit?: number, - after?: TxBlockData, - untilBlock: string, - ..., -} - -const validateHistoryReq = (addressRequestLimit:number, apiResponseLimit:number, data: any): UtilEither => { +const validateHistoryReq = (addressRequestLimit:number, apiResponseLimit:number, data: HistoryInput): UtilEither => { if(!('addresses' in data)) return {kind:"error", errMsg: "body.addresses does not exist."}; if(!('untilBlock' in data)) return {kind:"error", errMsg: "body.untilBlock does not exist."}; - if(('after' in data) && !('tx' in data.after)) + if((data.after != null) && !('tx' in data.after)) return {kind:"error", errMsg: "body.after exists but body.after.tx does not"}; - if(('after' in data) && !('block' in data.after)) + if((data.after != null) && !('block' in data.after)) return {kind:"error", errMsg: "body.after exists but body.after.block does not"}; - if(('limit' in data) && typeof data.limit !== "number") - return {kind:"error", errMsg: " body.limit must be a number"}; - if(('limit' in data) && data.limit > apiResponseLimit) - return {kind:"error", errMsg: `body.limit parameter exceeds api limit: ${apiResponseLimit}`}; const validatedAddresses = validateAddressesReq(addressRequestLimit, data.addresses); switch(validatedAddresses.kind){ @@ -53,6 +42,7 @@ const validateHistoryReq = (addressRequestLimit:number, apiResponseLimit:number, }; module.exports = { + assertNever, validateHistoryReq, validateAddressesReq }