fix: add memos to send-many-memo rosetta STX transfer operations (#1389)

* fix: add memo to send-many-memo txs

* style: comments and nits

* fix: use send many helper function

* ci: persist-credentials false

* chore: rebuild docker images

* ci: change to min standalone cache

* ci: remove standalone cache

* fix: rosetta dockerfile api branch name

* fix: ci build-args syntax

* fix: ci github head ref

* fix: clone standalone repo with full depth

* fix: reinstate standalone docker caches

* fix: memo operation indices

* fix: remove standalone cache, branch pulls won't invalidate it
This commit is contained in:
Rafael Cárdenas
2022-11-01 08:20:45 -06:00
committed by GitHub
parent 444f008fe2
commit 0a552b8d8c
10 changed files with 327 additions and 30 deletions

View File

@@ -497,6 +497,7 @@ jobs:
with:
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
fetch-depth: 0
persist-credentials: false
- name: Semantic Release
uses: cycjimmy/semantic-release-action@v2.7.0
@@ -562,11 +563,11 @@ jobs:
uses: docker/build-push-action@v2
with:
context: .
build-args: |
STACKS_API_VERSION=${{ github.head_ref || github.ref_name }}
file: docker/stx-rosetta.Dockerfile
tags: ${{ steps.meta_standalone.outputs.tags }}
labels: ${{ steps.meta_standalone.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Only push if (there's a new release on main branch, or if building a non-main branch) and (Only run on non-PR events or only PRs that aren't from forks)
push: ${{ (github.ref != 'refs/heads/master' || steps.semantic.outputs.new_release_version != '') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}

View File

@@ -1,8 +1,8 @@
ARG STACKS_API_VERSION=v0.71.2
ARG STACKS_API_VERSION
ARG STACKS_NODE_VERSION=2.05.0.4.0
ARG STACKS_API_REPO=blockstack/stacks-blockchain-api
ARG STACKS_NODE_REPO=blockstack/stacks-blockchain
ARG PG_VERSION=12
ARG STACKS_API_REPO=hirosystems/stacks-blockchain-api
ARG STACKS_NODE_REPO=stacks-network/stacks-blockchain
ARG PG_VERSION=14
ARG STACKS_NETWORK=mainnet
ARG STACKS_LOG_DIR=/var/log/stacks-node
ARG STACKS_SVC_DIR=/etc/service
@@ -30,7 +30,7 @@ RUN apt-get update -y \
jq \
openjdk-11-jre-headless \
cmake \
&& git clone -b ${STACKS_API_VERSION} --depth 1 https://github.com/${STACKS_API_REPO} . \
&& git clone -b ${STACKS_API_VERSION} https://github.com/${STACKS_API_REPO} . \
&& echo "GIT_TAG=$(git tag --points-at HEAD)" >> .env \
&& npm config set unsafe-perm true \
&& npm ci \

View File

@@ -1,5 +1,6 @@
import {
abiFunctionToString,
ChainID,
ClarityAbi,
ClarityAbiFunction,
getTypeString,
@@ -288,12 +289,14 @@ export function parseDbEvent(dbEvent: DbEvent): TransactionEvent {
* If neither argument is present, the most recent block is returned.
* @param db -- datastore
* @param fetchTransactions -- return block transactions
* @param chainId -- chain ID
* @param blockHash -- hexadecimal hash string
* @param blockHeight -- number
*/
export async function getRosettaBlockFromDataStore(
db: PgStore,
fetchTransactions: boolean,
chainId: ChainID,
blockHash?: string,
blockHeight?: number
): Promise<FoundOrNot<RosettaBlock>> {
@@ -318,6 +321,7 @@ export async function getRosettaBlockFromDataStore(
blockHash: dbBlock.block_hash,
indexBlockHash: dbBlock.index_block_hash,
db,
chainId,
});
}
@@ -493,6 +497,7 @@ async function parseRosettaTxDetail(opts: {
db: PgStore;
minerRewards: DbMinerReward[];
unlockingEvents: StxUnlockEvent[];
chainId: ChainID;
}): Promise<RosettaTransaction> {
let events: DbEvent[] = [];
if (opts.block_height > 1) {
@@ -508,6 +513,7 @@ async function parseRosettaTxDetail(opts: {
const operations = await getOperations(
opts.tx,
opts.db,
opts.chainId,
opts.minerRewards,
events,
opts.unlockingEvents
@@ -529,6 +535,7 @@ async function getRosettaBlockTxFromDataStore(opts: {
tx: DbTx;
block: DbBlock;
db: PgStore;
chainId: ChainID;
}): Promise<FoundOrNot<RosettaTransaction>> {
let minerRewards: DbMinerReward[] = [],
unlockingEvents: StxUnlockEvent[] = [];
@@ -545,6 +552,7 @@ async function getRosettaBlockTxFromDataStore(opts: {
indexBlockHash: opts.tx.index_block_hash,
tx: opts.tx,
db: opts.db,
chainId: opts.chainId,
minerRewards,
unlockingEvents,
});
@@ -555,6 +563,7 @@ async function getRosettaBlockTransactionsFromDataStore(opts: {
blockHash: string;
indexBlockHash: string;
db: PgStore;
chainId: ChainID;
}): Promise<FoundOrNot<RosettaTransaction[]>> {
const blockQuery = await opts.db.getBlock({ hash: opts.blockHash });
if (!blockQuery.found) {
@@ -580,6 +589,7 @@ async function getRosettaBlockTransactionsFromDataStore(opts: {
indexBlockHash: opts.indexBlockHash,
tx,
db: opts.db,
chainId: opts.chainId,
minerRewards,
unlockingEvents,
});
@@ -591,7 +601,8 @@ async function getRosettaBlockTransactionsFromDataStore(opts: {
export async function getRosettaTransactionFromDataStore(
txId: string,
db: PgStore
db: PgStore,
chainId: ChainID
): Promise<FoundOrNot<RosettaTransaction>> {
const txQuery = await db.getTx({ txId, includeUnanchored: false });
if (!txQuery.found) {
@@ -609,6 +620,7 @@ export async function getRosettaTransactionFromDataStore(
tx: txQuery.result,
block: blockQuery.result,
db,
chainId,
});
if (!rosettaTx.found) {

View File

@@ -24,13 +24,13 @@ export function createRosettaBlockRouter(db: PgStore, chainId: ChainID): express
return;
}
let block_hash = req.body.block_identifier?.hash;
const index = req.body.block_identifier?.index;
let block_hash = req.body.block_identifier?.hash as string | undefined;
const index = req.body.block_identifier?.index as number | undefined;
if (block_hash && !has0xPrefix(block_hash)) {
block_hash = '0x' + block_hash;
}
const block = await getRosettaBlockFromDataStore(db, true, block_hash, index);
const block = await getRosettaBlockFromDataStore(db, true, chainId, block_hash, index);
if (!block.found) {
res.status(500).json(RosettaErrors[RosettaErrorsTypes.blockNotFound]);
@@ -57,7 +57,7 @@ export function createRosettaBlockRouter(db: PgStore, chainId: ChainID): express
tx_hash = '0x' + tx_hash;
}
const transaction = await getRosettaTransactionFromDataStore(tx_hash, db);
const transaction = await getRosettaTransactionFromDataStore(tx_hash, db, chainId);
if (!transaction.found) {
res.status(500).json(RosettaErrors[RosettaErrorsTypes.transactionNotFound]);
return;

View File

@@ -514,7 +514,7 @@ export function createRosettaConstructionRouter(db: PgStore, chainId: ChainID):
}
try {
const baseTx = rawTxToBaseTx(inputTx);
const operations = await getOperations(baseTx, db);
const operations = await getOperations(baseTx, db, chainId);
const txMemo = parseTransactionMemo(baseTx);
let response: RosettaConstructionParseResponse;
if (signed) {

View File

@@ -69,7 +69,7 @@ export function createRosettaMempoolRouter(db: PgStore, chainId: ChainID): expre
return;
}
const operations = await getOperations(mempoolTxQuery.result, db);
const operations = await getOperations(mempoolTxQuery.result, db, chainId);
const txMemo = parseTransactionMemo(mempoolTxQuery.result);
const transaction: RosettaTransaction = {
transaction_identifier: { hash: tx_id },

View File

@@ -48,13 +48,13 @@ export function createRosettaNetworkRouter(db: PgStore, chainId: ChainID): expre
return;
}
const block = await getRosettaBlockFromDataStore(db, false);
const block = await getRosettaBlockFromDataStore(db, false, chainId);
if (!block.found) {
res.status(500).json(RosettaErrors[RosettaErrorsTypes.blockNotFound]);
return;
}
const genesis = await getRosettaBlockFromDataStore(db, false, undefined, 1);
const genesis = await getRosettaBlockFromDataStore(db, false, chainId, undefined, 1);
if (!genesis.found) {
res.status(500).json(RosettaErrors[RosettaErrorsTypes.blockNotFound]);
return;

View File

@@ -9,10 +9,12 @@ import {
import {
addressToString,
AuthType,
BufferCV,
BufferReader,
ChainID,
deserializeTransaction,
emptyMessageSignature,
hexToCV,
isSingleSig,
makeSigHashPreSign,
MessageSignature,
@@ -54,10 +56,10 @@ import {
StxUnlockEvent,
} from './datastore/common';
import { getTxSenderAddress, getTxSponsorAddress } from './event-stream/reader';
import { unwrapOptional, bufferToHexPrefixString, hexToBuffer, logger } from './helpers';
import { unwrapOptional, hexToBuffer, logger, getSendManyContract } from './helpers';
import { getCoreNodeEndpoint } from './core-rpc/client';
import { getBTCAddress, poxAddressToBtcAddress } from '@stacks/stacking';
import { poxAddressToBtcAddress } from '@stacks/stacking';
import { TokenMetadataErrorMode } from './token-metadata/tokens-contract-handler';
import {
ClarityTypeID,
@@ -71,8 +73,10 @@ import {
ClarityValueTuple,
ClarityValueUInt,
PrincipalTypeID,
TxPayloadTokenTransfer,
TxPayloadTypeID,
decodeClarityValueList,
ClarityValue,
ClarityValueList,
} from 'stacks-encoding-native-js';
import { PgStore } from './datastore/pg-store';
import { isFtMetadataEnabled, tokenMetadataErrorMode } from './token-metadata/helpers';
@@ -124,6 +128,7 @@ export function parseTransactionMemo(tx: BaseTx): string | null {
export async function getOperations(
tx: DbTx | DbMempoolTx | BaseTx,
db: PgStore,
chainID: ChainID,
minerRewards?: DbMinerReward[],
events?: DbEvent[],
stxUnlockEvents?: StxUnlockEvent[]
@@ -161,7 +166,7 @@ export async function getOperations(
}
if (events !== undefined) {
await processEvents(db, events, tx, operations);
await processEvents(db, events, tx, operations, chainID);
}
return operations;
@@ -173,12 +178,54 @@ function processUnlockingEvents(events: StxUnlockEvent[], operations: RosettaOpe
});
}
/**
* If `tx` is a contract call to the `send-many-memo` contract, return an array of `memo` values for
* all STX transfers sorted by event index.
* @param tx - Base transaction
* @returns Array of `memo` values
*/
function decodeSendManyContractCallMemos(tx: BaseTx, chainID: ChainID): string[] | undefined {
if (
getTxTypeString(tx.type_id) === 'contract_call' &&
tx.contract_call_contract_id === getSendManyContract(chainID) &&
tx.contract_call_function_name &&
['send-many', 'send-stx-with-memo'].includes(tx.contract_call_function_name) &&
tx.contract_call_function_args
) {
const decodeMemo = (memo?: ClarityValue): string => {
return memo && memo.type_id === ClarityTypeID.Buffer
? (hexToCV(memo.hex) as BufferCV).buffer.toString('utf8')
: '';
};
try {
const argList = decodeClarityValueList(tx.contract_call_function_args, true);
if (tx.contract_call_function_name === 'send-many') {
const list = argList[0] as ClarityValueList<ClarityValue>;
return (list.list as ClarityValueTuple[]).map(item => decodeMemo(item.data.memo));
} else if (tx.contract_call_function_name === 'send-stx-with-memo') {
return [decodeMemo(argList[2])];
}
} catch (error) {
logger.warn(`Could not decode send-many-memo arguments: ${error}`);
return;
}
}
}
async function processEvents(
db: PgStore,
events: DbEvent[],
baseTx: BaseTx,
operations: RosettaOperation[]
operations: RosettaOperation[],
chainID: ChainID
) {
// Is this a `send-many-memo` contract call transaction? If so, we must include the provided
// `memo` values inside STX operation metadata entries. STX transfer events inside
// `send-many-memo` contract calls come in the same order as the provided args, therefore we can
// match them by index.
const sendManyMemos = decodeSendManyContractCallMemos(baseTx, chainID);
let sendManyStxTransferEventIndex = 0;
for (const event of events) {
const txEventType = event.event_type;
switch (txEventType) {
@@ -205,8 +252,16 @@ async function processEvents(
stxAssetEvent.amount,
() => 'Unexpected nullish amount'
);
operations.push(makeSenderOperation(tx, operations.length));
operations.push(makeReceiverOperation(tx, operations.length));
let index = operations.length;
const sender = makeSenderOperation(tx, index++);
const receiver = makeReceiverOperation(tx, index++);
if (sendManyMemos) {
sender.metadata = receiver.metadata = {
memo: sendManyMemos[sendManyStxTransferEventIndex++],
};
}
operations.push(sender);
operations.push(receiver);
break;
case DbAssetEventTypeId.Burn:
operations.push(makeBurnOperation(stxAssetEvent, baseTx, operations.length));
@@ -1009,7 +1064,7 @@ export function rawTxToBaseTx(raw_tx: string): BaseTx {
transactionType = DbTxTypeId.PoisonMicroblock;
break;
}
const dbtx: BaseTx = {
const dbTx: BaseTx = {
token_transfer_recipient_address: recipientAddr,
tx_id: txId,
anchor_mode: 3,
@@ -1025,10 +1080,10 @@ export function rawTxToBaseTx(raw_tx: string): BaseTx {
const txPayload = transaction.payload;
if (txPayload.type_id === TxPayloadTypeID.TokenTransfer) {
dbtx.token_transfer_memo = txPayload.memo_hex;
dbTx.token_transfer_memo = txPayload.memo_hex;
}
return dbtx;
return dbTx;
}
export async function getValidatedFtMetadata(

View File

@@ -394,6 +394,9 @@ interface TestSmartContractLogEventArgs {
contract_identifier?: string;
event_index?: number;
tx_index?: number;
canonical?: boolean;
topic?: string;
value?: string;
}
/**
@@ -407,11 +410,11 @@ function testSmartContractLogEvent(args?: TestSmartContractLogEventArgs): DbSmar
tx_id: args?.tx_id ?? TX_ID,
tx_index: args?.tx_index ?? 0,
block_height: args?.block_height ?? BLOCK_HEIGHT,
canonical: true,
canonical: args?.canonical ?? true,
event_type: DbEventTypeId.SmartContractLog,
contract_identifier: args?.contract_identifier ?? CONTRACT_ID,
topic: 'some-topic',
value: bufferToHexPrefixString(serializeCV(bufferCVFromString('some val'))),
topic: args?.topic ?? 'some-topic',
value: args?.value ?? bufferToHexPrefixString(serializeCV(bufferCVFromString('some val'))),
};
}

View File

@@ -1,5 +1,5 @@
import * as supertest from 'supertest';
import { ChainID, stringAsciiCV, uintCV } from '@stacks/transactions';
import { bufferCV, ChainID, cvToHex, listCV, stringAsciiCV, tupleCV, uintCV } from '@stacks/transactions';
import { ApiServer, startApiServer } from '../api/init';
import { TestBlockBuilder } from '../test-utils/test-builders';
import { DbAssetEventTypeId, DbFungibleTokenMetadata, DbTxTypeId } from '../datastore/common';
@@ -7,6 +7,7 @@ import { createClarityValueArray } from '../stacks-encoding-helpers';
import { PgWriteStore } from '../datastore/pg-write-store';
import { cycleMigrations, runMigrations } from '../datastore/migrations';
import { bufferToHexPrefixString } from '../helpers';
import { principalCV } from '@stacks/transactions/dist/clarity/types/principalCV';
describe('/block tests', () => {
let db: PgWriteStore;
@@ -374,6 +375,231 @@ describe('/block tests', () => {
expect(result5.operations[1]).toEqual(undefined);
});
test('block/transaction - send-many-memo includes memo metadata', async () => {
const sendManyAddr = 'STR8P3RD1EHA8AA37ERSSSZSWKS9T2GYQFGXNA4C.send-many-memo';
const sendManyAbi = {
"maps": [],
"functions": [
{
"args": [
{
"name": "result",
"type": { "response": { "ok": "bool", "error": "uint128" } }
},
{
"name": "prior",
"type": { "response": { "ok": "bool", "error": "uint128" } }
}
],
"name": "check-err",
"access": "private",
"outputs": {
"type": { "response": { "ok": "bool", "error": "uint128" } }
}
},
{
"args": [
{
"name": "recipient",
"type": {
"tuple": [
{ "name": "memo", "type": { "buffer": { "length": 34 } } },
{ "name": "to", "type": "principal" },
{ "name": "ustx", "type": "uint128" }
]
}
}
],
"name": "send-stx",
"access": "private",
"outputs": {
"type": { "response": { "ok": "bool", "error": "uint128" } }
}
},
{
"args": [
{
"name": "recipients",
"type": {
"list": {
"type": {
"tuple": [
{ "name": "memo", "type": { "buffer": { "length": 34 } } },
{ "name": "to", "type": "principal" },
{ "name": "ustx", "type": "uint128" }
]
},
"length": 200
}
}
}
],
"name": "send-many",
"access": "public",
"outputs": {
"type": { "response": { "ok": "bool", "error": "uint128" } }
}
},
{
"args": [
{ "name": "ustx", "type": "uint128" },
{ "name": "to", "type": "principal" },
{ "name": "memo", "type": { "buffer": { "length": 34 } } }
],
"name": "send-stx-with-memo",
"access": "public",
"outputs": {
"type": { "response": { "ok": "bool", "error": "uint128" } }
}
}
],
"variables": [],
"fungible_tokens": [],
"non_fungible_tokens": []
};
// Deploy
const block1 = new TestBlockBuilder({
block_height: 1,
index_block_hash: '0x01',
})
.addTx({ tx_id: '0x1111' })
.addTxSmartContract({ contract_id: sendManyAddr, abi: JSON.stringify(sendManyAbi) })
.build();
await db.update(block1);
// send-many
const block2 = new TestBlockBuilder({
block_height: 2,
index_block_hash: '0x02',
parent_index_block_hash: '0x01',
})
.addTx({
tx_id: '0x1112',
type_id: DbTxTypeId.ContractCall,
sender_address: sendManyAddr,
contract_call_contract_id: sendManyAddr,
contract_call_function_name: 'send-many',
contract_call_function_args: bufferToHexPrefixString(
createClarityValueArray(
listCV([
tupleCV({
memo: bufferCV(Buffer.from('memo-1')),
to: principalCV('SPG7RD94XW8HN5NS7V68YDJAY4PJVZ2KNY79Z518'),
ustx: uintCV(2000)
}),
tupleCV({
memo: bufferCV(Buffer.from('memo-2')),
to: principalCV('SP2XN3N1C3HM1YFHKBE07EW7AFZBZPXDTHSP92HAX'),
ustx: uintCV(2500)
}),
tupleCV({
to: principalCV('SP2PDY84DFFJS0PQN3P0WBYZFW1EZSAC67N08BWH0'),
ustx: uintCV(50000)
}),
])
)
),
abi: JSON.stringify(sendManyAbi),
})
// Simulate events as they would come in the contract call (in order)
.addTxStxEvent({
sender: sendManyAddr,
recipient: 'SPG7RD94XW8HN5NS7V68YDJAY4PJVZ2KNY79Z518',
amount: 2000n
})
.addTxContractLogEvent({
contract_identifier: sendManyAddr,
topic: 'print',
value: cvToHex(bufferCV(Buffer.from('memo-1')))
})
.addTxStxEvent({
sender: sendManyAddr,
recipient: 'SP2XN3N1C3HM1YFHKBE07EW7AFZBZPXDTHSP92HAX',
amount: 2500n
})
.addTxContractLogEvent({
contract_identifier: sendManyAddr,
topic: 'print',
value: cvToHex(bufferCV(Buffer.from('memo-2')))
})
.addTxStxEvent({
sender: sendManyAddr,
recipient: 'SP2PDY84DFFJS0PQN3P0WBYZFW1EZSAC67N08BWH0',
amount: 50000n
})
.build();
await db.update(block2);
const query1 = await supertest(api.server)
.post(`/rosetta/v1/block/transaction`)
.send({
network_identifier: { blockchain: 'stacks', network: 'testnet' },
block_identifier: { index: 2 },
transaction_identifier: { hash: '0x1112' },
});
expect(query1.status).toBe(200);
expect(query1.type).toBe('application/json');
const result = JSON.parse(query1.text);
expect(result.transaction_identifier.hash).toEqual('0x1112');
expect(result.operations[2].metadata).toEqual({ memo: 'memo-1' });
expect(result.operations[2].operation_identifier.index).toEqual(2);
expect(result.operations[3].metadata).toEqual({ memo: 'memo-1' });
expect(result.operations[3].operation_identifier.index).toEqual(3);
expect(result.operations[4].metadata).toEqual({ memo: 'memo-2' });
expect(result.operations[4].operation_identifier.index).toEqual(4);
expect(result.operations[5].metadata).toEqual({ memo: 'memo-2' });
expect(result.operations[5].operation_identifier.index).toEqual(5);
expect(result.operations[6].metadata).toEqual({ memo: '' });
expect(result.operations[6].operation_identifier.index).toEqual(6);
expect(result.operations[7].metadata).toEqual({ memo: '' });
expect(result.operations[7].operation_identifier.index).toEqual(7);
// send-stx-with-memo
const block3 = new TestBlockBuilder({
block_height: 3,
index_block_hash: '0x03',
parent_index_block_hash: '0x02',
})
.addTx({
tx_id: '0x1113',
type_id: DbTxTypeId.ContractCall,
sender_address: sendManyAddr,
contract_call_contract_id: sendManyAddr,
contract_call_function_name: 'send-stx-with-memo',
contract_call_function_args: bufferToHexPrefixString(
createClarityValueArray(
uintCV(2000),
principalCV('SPG7RD94XW8HN5NS7V68YDJAY4PJVZ2KNY79Z518'),
bufferCV(Buffer.from('memo-1')),
)
),
abi: JSON.stringify(sendManyAbi),
})
// Simulate events as they would come in the contract call (in order)
.addTxStxEvent({
sender: sendManyAddr,
recipient: 'SPG7RD94XW8HN5NS7V68YDJAY4PJVZ2KNY79Z518',
amount: 2000n
})
.build();
await db.update(block3);
const query2 = await supertest(api.server)
.post(`/rosetta/v1/block/transaction`)
.send({
network_identifier: { blockchain: 'stacks', network: 'testnet' },
block_identifier: { index: 3 },
transaction_identifier: { hash: '0x1113' },
});
expect(query2.status).toBe(200);
expect(query2.type).toBe('application/json');
const result2 = JSON.parse(query2.text);
expect(result2.transaction_identifier.hash).toEqual('0x1113');
expect(result2.operations[2].metadata).toEqual({ memo: 'memo-1' });
expect(result2.operations[3].metadata).toEqual({ memo: 'memo-1' });
});
afterEach(async () => {
await api.terminate();
await db?.close();