From 875dfa34caf113ab072000a1152541be59341c42 Mon Sep 17 00:00:00 2001 From: Hank Stoever Date: Wed, 10 Feb 2021 11:10:36 -0800 Subject: [PATCH] feat: new api endpoint to get inbound stx and send-many transfers with memos --- .env | 4 ++ .eslintrc.js | 11 ++-- .gitignore | 3 ++ client/.eslintrc.js | 11 ++-- client/package.json | 2 +- package.json | 2 +- src/api/init.ts | 2 +- src/api/routes/address.ts | 38 +++++++++++++- src/datastore/common.ts | 17 ++++++ src/datastore/memory-store.ts | 11 ++++ src/datastore/postgres-store.ts | 92 +++++++++++++++++++++++++++++++++ src/helpers.ts | 9 ++++ 12 files changed, 182 insertions(+), 20 deletions(-) diff --git a/.env b/.env index f5b9c37c..ec7b9885 100644 --- a/.env +++ b/.env @@ -29,3 +29,7 @@ BTC_RPC_PORT=18443 BTC_RPC_USER=btc BTC_RPC_PW=btc BTC_FAUCET_PK=29c028009a8331358adcc61bb6397377c995d327ac0343ed8e8f1d4d3ef85c27 + +# The contracts used to query for inbound transactions +TESTNET_SEND_MANY_CONTRACT_ID=STR8P3RD1EHA8AA37ERSSSZSWKS9T2GYQFGXNA4C.send-many-memo +MAINNET_SEND_MANY_CONTRACT_ID=SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.send-many-memo diff --git a/.eslintrc.js b/.eslintrc.js index e84c21cf..7cb9ba03 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { root: true, - extends: ['@blockstack/eslint-config'], + extends: ['@stacks/eslint-config'], parser: '@typescript-eslint/parser', plugins: ['eslint-plugin-tsdoc'], parserOptions: { @@ -9,17 +9,14 @@ module.exports = { ecmaVersion: 2019, sourceType: 'module', }, - ignorePatterns: [ - 'lib/*', - 'client/*' - ], + ignorePatterns: ['lib/*', 'client/*'], rules: { '@typescript-eslint/no-inferrable-types': 'off', '@typescript-eslint/camelcase': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-use-before-define': ['error', 'nofunc'], - '@typescript-eslint/no-floating-promises': ['error', {'ignoreVoid': true}], + '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], 'no-warning-comments': 'warn', 'tsdoc/syntax': 'error', - } + }, }; diff --git a/.gitignore b/.gitignore index 2a754e91..c0596fec 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,6 @@ src/schemas # OS specific .DS_Store openapitools.json + +.env.local +yarn.lock diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 73136e1e..f717f099 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { root: true, - extends: ['@blockstack/eslint-config'], + extends: ['@stacks/eslint-config'], parser: '@typescript-eslint/parser', parserOptions: { tsconfigRootDir: __dirname, @@ -8,11 +8,6 @@ module.exports = { ecmaVersion: 2017, sourceType: 'module', }, - ignorePatterns: [ - 'lib/*', - 'test/*', - '.eslintrc.js' - ], - rules: { - } + ignorePatterns: ['lib/*', 'test/*', '.eslintrc.js'], + rules: {}, }; diff --git a/client/package.json b/client/package.json index 80aef3fc..9281a458 100644 --- a/client/package.json +++ b/client/package.json @@ -44,7 +44,7 @@ }, "devDependencies": { "@apidevtools/swagger-cli": "^4.0.4", - "@blockstack/eslint-config": "^1.0.5", + "@stacks/eslint-config": "^1.0.7", "@blockstack/prettier-config": "0.0.6", "@openapitools/openapi-generator-cli": "^2.1.7", "@typescript-eslint/eslint-plugin": "^3.9.1", diff --git a/package.json b/package.json index 3d88b101..8d749d99 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "@actions/exec": "^1.0.4", "@actions/github": "^2.2.0", "@actions/io": "^1.0.2", - "@blockstack/eslint-config": "^1.0.5", + "@stacks/eslint-config": "^1.0.7", "@blockstack/prettier-config": "0.0.6", "@commitlint/cli": "^9.1.2", "@commitlint/config-conventional": "^10.0.0", diff --git a/src/api/init.ts b/src/api/init.ts index 65247e0d..9f7cbf86 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -110,7 +110,7 @@ export async function startApiServer(datastore: DataStore, chainId: ChainID): Pr router.use('/block', createBlockRouter(datastore)); router.use('/burnchain', createBurnchainRouter(datastore)); router.use('/contract', createContractRouter(datastore)); - router.use('/address', createAddressRouter(datastore)); + router.use('/address', createAddressRouter(datastore, chainId)); router.use('/search', createSearchRouter(datastore)); router.use('/info', createInfoRouter(datastore)); router.use('/debug', createDebugRouter(datastore)); diff --git a/src/api/routes/address.ts b/src/api/routes/address.ts index e18d3c83..897a0ac0 100644 --- a/src/api/routes/address.ts +++ b/src/api/routes/address.ts @@ -3,7 +3,7 @@ import { addAsync, RouterWithAsync } from '@awaitjs/express'; import * as Bluebird from 'bluebird'; import { DataStore } from '../../datastore/common'; import { parseLimitQuery, parsePagingQueryInput } from '../pagination'; -import { formatMapToObject, isValidPrincipal } from '../../helpers'; +import { formatMapToObject, getSendManyContract, isValidPrincipal, logger } from '../../helpers'; import { getTxFromDataStore, parseDbEvent } from '../controllers/db-controller'; import { TransactionResults, @@ -11,6 +11,7 @@ import { AddressBalanceResponse, AddressStxBalanceResponse, } from '@blockstack/stacks-blockchain-api-types'; +import { ChainID } from '@stacks/transactions'; const MAX_TX_PER_REQUEST = 50; const MAX_ASSETS_PER_REQUEST = 50; @@ -32,7 +33,7 @@ interface AddressAssetEvents { total: number; } -export function createAddressRouter(db: DataStore): RouterWithAsync { +export function createAddressRouter(db: DataStore, chainId: ChainID): RouterWithAsync { const router = addAsync(express.Router()); router.getAsync('/:stx_address/stx', async (req, res) => { @@ -149,5 +150,38 @@ export function createAddressRouter(db: DataStore): RouterWithAsync { res.json(response); }); + router.getAsync('/:stx_address/inbound', async (req, res) => { + // get recent inbound transfers + const stxAddress = req.params['stx_address']; + try { + const sendManyContractId = getSendManyContract(chainId); + if (!sendManyContractId || !isValidPrincipal(sendManyContractId)) { + logger.error('Send many contract ID not properly configured'); + return res.status(500).json({ error: 'Send many contract ID not properly configured' }); + } + if (!isValidPrincipal(stxAddress)) { + return res.status(400).json({ error: `invalid STX address "${stxAddress}"` }); + } + const limit = parseAssetsQueryLimit(req.query.limit ?? 20); + const offset = parsePagingQueryInput(req.query.offset ?? 0); + const { results: transfers, total } = await db.getInboundTransfers({ + stxAddress, + limit, + offset, + sendManyContractId, + }); + const response = { + results: transfers, + total: total, + limit, + offset, + }; + res.json(response); + } catch (error) { + logger.error(`Unable to get inbound transfers for ${stxAddress}`, error); + res.status(500).json({ error: 'Unexpected error occurred' }); + } + }); + return router; } diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 30272e5c..ddb15ba4 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -286,6 +286,16 @@ export interface DbStxBalance { burnchainUnlockHeight: number; } +export interface InboundTransfer { + sender: string; + amount: number; + memo: string; + block_height: number; + tx_id: string; + transfer_type: string; + tx_index: number; +} + export interface DataStore extends DataStoreEventEmitter { getBlock(blockHash: string): Promise>; getBlockByHeight(block_height: number): Promise>; @@ -369,6 +379,13 @@ export interface DataStore extends DataStoreEventEmitter { offset: number; }): Promise<{ results: DbEvent[]; total: number }>; + getInboundTransfers(args: { + stxAddress: string; + limit: number; + offset: number; + sendManyContractId: string; + }): Promise<{ results: InboundTransfer[]; total: number }>; + searchHash(args: { hash: string }): Promise>; searchPrincipal(args: { principal: string }): Promise>; diff --git a/src/datastore/memory-store.ts b/src/datastore/memory-store.ts index 39b2697e..60072f88 100644 --- a/src/datastore/memory-store.ts +++ b/src/datastore/memory-store.ts @@ -18,6 +18,7 @@ import { DbStxBalance, DbStxLockEvent, DbBurnchainReward, + InboundTransfer, } from './common'; import { logger, FoundOrNot } from '../helpers'; import { TransactionType } from '@blockstack/stacks-blockchain-api-types'; @@ -387,6 +388,16 @@ export class MemoryDataStore extends (EventEmitter as { new (): DataStoreEventEm throw new Error('not yet implemented'); } + getInboundTransfers({ + stxAddress, + }: { + stxAddress: string; + limit: number; + offset: number; + }): Promise<{ results: InboundTransfer[]; total: number }> { + throw new Error('not yet implemented'); + } + searchHash(args: { hash: string }): Promise> { throw new Error('not yet implemented'); } diff --git a/src/datastore/postgres-store.ts b/src/datastore/postgres-store.ts index 6889fcf3..2ece3e91 100644 --- a/src/datastore/postgres-store.ts +++ b/src/datastore/postgres-store.ts @@ -43,6 +43,7 @@ import { DbFtBalance, DbMinerReward, DbBurnchainReward, + InboundTransfer, } from './common'; import { TransactionType } from '@blockstack/stacks-blockchain-api-types'; import { getTxTypeId } from '../api/controllers/db-controller'; @@ -294,6 +295,16 @@ interface UpdatedEntities { }; } +interface TransferQueryResult { + sender: string; + memo: Buffer; + block_height: number; + tx_index: number; + tx_id: Buffer; + transfer_type: string; + amount: string; +} + // TODO: Disable this if/when sql leaks are found or ruled out. const SQL_QUERY_LEAK_DETECTION = true; @@ -2511,6 +2522,87 @@ export class PgDataStore extends (EventEmitter as { new (): DataStoreEventEmitte }); } + async getInboundTransfers({ + stxAddress, + limit, + offset, + sendManyContractId, + }: { + stxAddress: string; + limit: number; + offset: number; + sendManyContractId: string; + }): Promise<{ results: InboundTransfer[]; total: number }> { + return this.query(async client => { + const resultQuery = await client.query( + ` + SELECT + *, + ( + COUNT(*) OVER() + )::INTEGER AS COUNT + FROM + ( + SELECT + stx_events.amount AS amount, + contract_logs.value AS memo, + stx_events.sender AS sender, + stx_events.block_height AS block_height, + stx_events.tx_id, + stx_events.tx_index, + 'bulk-send' as transfer_type + FROM + contract_logs, + stx_events + WHERE + contract_logs.contract_identifier = $2 + AND contract_logs.tx_id = stx_events.tx_id + AND stx_events.recipient = $1 + AND contract_logs.event_index = (stx_events.event_index + 1) + AND stx_events.canonical = true + UNION ALL + SELECT + token_transfer_amount AS amount, + token_transfer_memo AS memo, + sender_address AS sender, + block_height, + tx_id, + tx_index, + 'stx-transfer' as transfer_type + FROM + txs + WHERE + canonical = TRUE + AND type_id = 0 + AND token_transfer_recipient_address = $1 + ) transfers + ORDER BY + block_height DESC, + tx_index DESC + LIMIT $3 + OFFSET $4 + `, + [stxAddress, sendManyContractId, limit, offset] + ); + const count = resultQuery.rowCount > 0 ? resultQuery.rows[0].count : 0; + const parsed: InboundTransfer[] = resultQuery.rows.map(r => { + return { + sender: r.sender, + memo: bufferToHexPrefixString(r.memo), + amount: Number(BigInt(r.amount)), + tx_id: bufferToHexPrefixString(r.tx_id), + tx_index: r.tx_index, + block_height: r.block_height, + transfer_type: r.transfer_type, + }; + }); + return { + results: parsed, + total: count, + }; + }); + } + async searchHash({ hash }: { hash: string }): Promise> { return this.query(async client => { const txQuery = await client.query( diff --git a/src/helpers.ts b/src/helpers.ts index a0772e7c..0e5f9b10 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -6,6 +6,7 @@ import * as winston from 'winston'; import * as c32check from 'c32check'; import * as btc from 'bitcoinjs-lib'; import * as BN from 'bn.js'; +import { ChainID } from '@stacks/transactions'; export const isDevEnv = process.env.NODE_ENV === 'development'; export const isTestEnv = process.env.NODE_ENV === 'test'; @@ -567,3 +568,11 @@ export function normalizeHashString(input: string): string | false { } return `0x${hashBuffer.toString('hex')}`; } + +export function getSendManyContract(chainId: ChainID) { + const contractId = + chainId === ChainID.Mainnet + ? process.env.MAINNET_SEND_MANY_CONTRACT_ID + : process.env.TESTNET_SEND_MANY_CONTRACT_ID; + return contractId; +}