Skip to content

Commit 0a552b8

Browse files
authored
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
1 parent 444f008 commit 0a552b8

File tree

10 files changed

+327
-30
lines changed

10 files changed

+327
-30
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ jobs:
497497
with:
498498
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
499499
fetch-depth: 0
500+
persist-credentials: false
500501

501502
- name: Semantic Release
502503
uses: cycjimmy/[email protected]
@@ -562,11 +563,11 @@ jobs:
562563
uses: docker/build-push-action@v2
563564
with:
564565
context: .
566+
build-args: |
567+
STACKS_API_VERSION=${{ github.head_ref || github.ref_name }}
565568
file: docker/stx-rosetta.Dockerfile
566569
tags: ${{ steps.meta_standalone.outputs.tags }}
567570
labels: ${{ steps.meta_standalone.outputs.labels }}
568-
cache-from: type=gha
569-
cache-to: type=gha,mode=max
570571
# 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)
571572
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) }}
572573

docker/stx-rosetta.Dockerfile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
ARG STACKS_API_VERSION=v0.71.2
1+
ARG STACKS_API_VERSION
22
ARG STACKS_NODE_VERSION=2.05.0.4.0
3-
ARG STACKS_API_REPO=blockstack/stacks-blockchain-api
4-
ARG STACKS_NODE_REPO=blockstack/stacks-blockchain
5-
ARG PG_VERSION=12
3+
ARG STACKS_API_REPO=hirosystems/stacks-blockchain-api
4+
ARG STACKS_NODE_REPO=stacks-network/stacks-blockchain
5+
ARG PG_VERSION=14
66
ARG STACKS_NETWORK=mainnet
77
ARG STACKS_LOG_DIR=/var/log/stacks-node
88
ARG STACKS_SVC_DIR=/etc/service
@@ -30,7 +30,7 @@ RUN apt-get update -y \
3030
jq \
3131
openjdk-11-jre-headless \
3232
cmake \
33-
&& git clone -b ${STACKS_API_VERSION} --depth 1 https://github.com/${STACKS_API_REPO} . \
33+
&& git clone -b ${STACKS_API_VERSION} https://github.com/${STACKS_API_REPO} . \
3434
&& echo "GIT_TAG=$(git tag --points-at HEAD)" >> .env \
3535
&& npm config set unsafe-perm true \
3636
&& npm ci \

src/api/controllers/db-controller.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
abiFunctionToString,
3+
ChainID,
34
ClarityAbi,
45
ClarityAbiFunction,
56
getTypeString,
@@ -288,12 +289,14 @@ export function parseDbEvent(dbEvent: DbEvent): TransactionEvent {
288289
* If neither argument is present, the most recent block is returned.
289290
* @param db -- datastore
290291
* @param fetchTransactions -- return block transactions
292+
* @param chainId -- chain ID
291293
* @param blockHash -- hexadecimal hash string
292294
* @param blockHeight -- number
293295
*/
294296
export async function getRosettaBlockFromDataStore(
295297
db: PgStore,
296298
fetchTransactions: boolean,
299+
chainId: ChainID,
297300
blockHash?: string,
298301
blockHeight?: number
299302
): Promise<FoundOrNot<RosettaBlock>> {
@@ -318,6 +321,7 @@ export async function getRosettaBlockFromDataStore(
318321
blockHash: dbBlock.block_hash,
319322
indexBlockHash: dbBlock.index_block_hash,
320323
db,
324+
chainId,
321325
});
322326
}
323327

@@ -493,6 +497,7 @@ async function parseRosettaTxDetail(opts: {
493497
db: PgStore;
494498
minerRewards: DbMinerReward[];
495499
unlockingEvents: StxUnlockEvent[];
500+
chainId: ChainID;
496501
}): Promise<RosettaTransaction> {
497502
let events: DbEvent[] = [];
498503
if (opts.block_height > 1) {
@@ -508,6 +513,7 @@ async function parseRosettaTxDetail(opts: {
508513
const operations = await getOperations(
509514
opts.tx,
510515
opts.db,
516+
opts.chainId,
511517
opts.minerRewards,
512518
events,
513519
opts.unlockingEvents
@@ -529,6 +535,7 @@ async function getRosettaBlockTxFromDataStore(opts: {
529535
tx: DbTx;
530536
block: DbBlock;
531537
db: PgStore;
538+
chainId: ChainID;
532539
}): Promise<FoundOrNot<RosettaTransaction>> {
533540
let minerRewards: DbMinerReward[] = [],
534541
unlockingEvents: StxUnlockEvent[] = [];
@@ -545,6 +552,7 @@ async function getRosettaBlockTxFromDataStore(opts: {
545552
indexBlockHash: opts.tx.index_block_hash,
546553
tx: opts.tx,
547554
db: opts.db,
555+
chainId: opts.chainId,
548556
minerRewards,
549557
unlockingEvents,
550558
});
@@ -555,6 +563,7 @@ async function getRosettaBlockTransactionsFromDataStore(opts: {
555563
blockHash: string;
556564
indexBlockHash: string;
557565
db: PgStore;
566+
chainId: ChainID;
558567
}): Promise<FoundOrNot<RosettaTransaction[]>> {
559568
const blockQuery = await opts.db.getBlock({ hash: opts.blockHash });
560569
if (!blockQuery.found) {
@@ -580,6 +589,7 @@ async function getRosettaBlockTransactionsFromDataStore(opts: {
580589
indexBlockHash: opts.indexBlockHash,
581590
tx,
582591
db: opts.db,
592+
chainId: opts.chainId,
583593
minerRewards,
584594
unlockingEvents,
585595
});
@@ -591,7 +601,8 @@ async function getRosettaBlockTransactionsFromDataStore(opts: {
591601

592602
export async function getRosettaTransactionFromDataStore(
593603
txId: string,
594-
db: PgStore
604+
db: PgStore,
605+
chainId: ChainID
595606
): Promise<FoundOrNot<RosettaTransaction>> {
596607
const txQuery = await db.getTx({ txId, includeUnanchored: false });
597608
if (!txQuery.found) {
@@ -609,6 +620,7 @@ export async function getRosettaTransactionFromDataStore(
609620
tx: txQuery.result,
610621
block: blockQuery.result,
611622
db,
623+
chainId,
612624
});
613625

614626
if (!rosettaTx.found) {

src/api/routes/rosetta/block.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ export function createRosettaBlockRouter(db: PgStore, chainId: ChainID): express
2424
return;
2525
}
2626

27-
let block_hash = req.body.block_identifier?.hash;
28-
const index = req.body.block_identifier?.index;
27+
let block_hash = req.body.block_identifier?.hash as string | undefined;
28+
const index = req.body.block_identifier?.index as number | undefined;
2929
if (block_hash && !has0xPrefix(block_hash)) {
3030
block_hash = '0x' + block_hash;
3131
}
3232

33-
const block = await getRosettaBlockFromDataStore(db, true, block_hash, index);
33+
const block = await getRosettaBlockFromDataStore(db, true, chainId, block_hash, index);
3434

3535
if (!block.found) {
3636
res.status(500).json(RosettaErrors[RosettaErrorsTypes.blockNotFound]);
@@ -57,7 +57,7 @@ export function createRosettaBlockRouter(db: PgStore, chainId: ChainID): express
5757
tx_hash = '0x' + tx_hash;
5858
}
5959

60-
const transaction = await getRosettaTransactionFromDataStore(tx_hash, db);
60+
const transaction = await getRosettaTransactionFromDataStore(tx_hash, db, chainId);
6161
if (!transaction.found) {
6262
res.status(500).json(RosettaErrors[RosettaErrorsTypes.transactionNotFound]);
6363
return;

src/api/routes/rosetta/construction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ export function createRosettaConstructionRouter(db: PgStore, chainId: ChainID):
514514
}
515515
try {
516516
const baseTx = rawTxToBaseTx(inputTx);
517-
const operations = await getOperations(baseTx, db);
517+
const operations = await getOperations(baseTx, db, chainId);
518518
const txMemo = parseTransactionMemo(baseTx);
519519
let response: RosettaConstructionParseResponse;
520520
if (signed) {

src/api/routes/rosetta/mempool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function createRosettaMempoolRouter(db: PgStore, chainId: ChainID): expre
6969
return;
7070
}
7171

72-
const operations = await getOperations(mempoolTxQuery.result, db);
72+
const operations = await getOperations(mempoolTxQuery.result, db, chainId);
7373
const txMemo = parseTransactionMemo(mempoolTxQuery.result);
7474
const transaction: RosettaTransaction = {
7575
transaction_identifier: { hash: tx_id },

src/api/routes/rosetta/network.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ export function createRosettaNetworkRouter(db: PgStore, chainId: ChainID): expre
4848
return;
4949
}
5050

51-
const block = await getRosettaBlockFromDataStore(db, false);
51+
const block = await getRosettaBlockFromDataStore(db, false, chainId);
5252
if (!block.found) {
5353
res.status(500).json(RosettaErrors[RosettaErrorsTypes.blockNotFound]);
5454
return;
5555
}
5656

57-
const genesis = await getRosettaBlockFromDataStore(db, false, undefined, 1);
57+
const genesis = await getRosettaBlockFromDataStore(db, false, chainId, undefined, 1);
5858
if (!genesis.found) {
5959
res.status(500).json(RosettaErrors[RosettaErrorsTypes.blockNotFound]);
6060
return;

src/rosetta-helpers.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
import {
1010
addressToString,
1111
AuthType,
12+
BufferCV,
1213
BufferReader,
1314
ChainID,
1415
deserializeTransaction,
1516
emptyMessageSignature,
17+
hexToCV,
1618
isSingleSig,
1719
makeSigHashPreSign,
1820
MessageSignature,
@@ -54,10 +56,10 @@ import {
5456
StxUnlockEvent,
5557
} from './datastore/common';
5658
import { getTxSenderAddress, getTxSponsorAddress } from './event-stream/reader';
57-
import { unwrapOptional, bufferToHexPrefixString, hexToBuffer, logger } from './helpers';
59+
import { unwrapOptional, hexToBuffer, logger, getSendManyContract } from './helpers';
5860

5961
import { getCoreNodeEndpoint } from './core-rpc/client';
60-
import { getBTCAddress, poxAddressToBtcAddress } from '@stacks/stacking';
62+
import { poxAddressToBtcAddress } from '@stacks/stacking';
6163
import { TokenMetadataErrorMode } from './token-metadata/tokens-contract-handler';
6264
import {
6365
ClarityTypeID,
@@ -71,8 +73,10 @@ import {
7173
ClarityValueTuple,
7274
ClarityValueUInt,
7375
PrincipalTypeID,
74-
TxPayloadTokenTransfer,
7576
TxPayloadTypeID,
77+
decodeClarityValueList,
78+
ClarityValue,
79+
ClarityValueList,
7680
} from 'stacks-encoding-native-js';
7781
import { PgStore } from './datastore/pg-store';
7882
import { isFtMetadataEnabled, tokenMetadataErrorMode } from './token-metadata/helpers';
@@ -124,6 +128,7 @@ export function parseTransactionMemo(tx: BaseTx): string | null {
124128
export async function getOperations(
125129
tx: DbTx | DbMempoolTx | BaseTx,
126130
db: PgStore,
131+
chainID: ChainID,
127132
minerRewards?: DbMinerReward[],
128133
events?: DbEvent[],
129134
stxUnlockEvents?: StxUnlockEvent[]
@@ -161,7 +166,7 @@ export async function getOperations(
161166
}
162167

163168
if (events !== undefined) {
164-
await processEvents(db, events, tx, operations);
169+
await processEvents(db, events, tx, operations, chainID);
165170
}
166171

167172
return operations;
@@ -173,12 +178,54 @@ function processUnlockingEvents(events: StxUnlockEvent[], operations: RosettaOpe
173178
});
174179
}
175180

181+
/**
182+
* If `tx` is a contract call to the `send-many-memo` contract, return an array of `memo` values for
183+
* all STX transfers sorted by event index.
184+
* @param tx - Base transaction
185+
* @returns Array of `memo` values
186+
*/
187+
function decodeSendManyContractCallMemos(tx: BaseTx, chainID: ChainID): string[] | undefined {
188+
if (
189+
getTxTypeString(tx.type_id) === 'contract_call' &&
190+
tx.contract_call_contract_id === getSendManyContract(chainID) &&
191+
tx.contract_call_function_name &&
192+
['send-many', 'send-stx-with-memo'].includes(tx.contract_call_function_name) &&
193+
tx.contract_call_function_args
194+
) {
195+
const decodeMemo = (memo?: ClarityValue): string => {
196+
return memo && memo.type_id === ClarityTypeID.Buffer
197+
? (hexToCV(memo.hex) as BufferCV).buffer.toString('utf8')
198+
: '';
199+
};
200+
try {
201+
const argList = decodeClarityValueList(tx.contract_call_function_args, true);
202+
if (tx.contract_call_function_name === 'send-many') {
203+
const list = argList[0] as ClarityValueList<ClarityValue>;
204+
return (list.list as ClarityValueTuple[]).map(item => decodeMemo(item.data.memo));
205+
} else if (tx.contract_call_function_name === 'send-stx-with-memo') {
206+
return [decodeMemo(argList[2])];
207+
}
208+
} catch (error) {
209+
logger.warn(`Could not decode send-many-memo arguments: ${error}`);
210+
return;
211+
}
212+
}
213+
}
214+
176215
async function processEvents(
177216
db: PgStore,
178217
events: DbEvent[],
179218
baseTx: BaseTx,
180-
operations: RosettaOperation[]
219+
operations: RosettaOperation[],
220+
chainID: ChainID
181221
) {
222+
// Is this a `send-many-memo` contract call transaction? If so, we must include the provided
223+
// `memo` values inside STX operation metadata entries. STX transfer events inside
224+
// `send-many-memo` contract calls come in the same order as the provided args, therefore we can
225+
// match them by index.
226+
const sendManyMemos = decodeSendManyContractCallMemos(baseTx, chainID);
227+
let sendManyStxTransferEventIndex = 0;
228+
182229
for (const event of events) {
183230
const txEventType = event.event_type;
184231
switch (txEventType) {
@@ -205,8 +252,16 @@ async function processEvents(
205252
stxAssetEvent.amount,
206253
() => 'Unexpected nullish amount'
207254
);
208-
operations.push(makeSenderOperation(tx, operations.length));
209-
operations.push(makeReceiverOperation(tx, operations.length));
255+
let index = operations.length;
256+
const sender = makeSenderOperation(tx, index++);
257+
const receiver = makeReceiverOperation(tx, index++);
258+
if (sendManyMemos) {
259+
sender.metadata = receiver.metadata = {
260+
memo: sendManyMemos[sendManyStxTransferEventIndex++],
261+
};
262+
}
263+
operations.push(sender);
264+
operations.push(receiver);
210265
break;
211266
case DbAssetEventTypeId.Burn:
212267
operations.push(makeBurnOperation(stxAssetEvent, baseTx, operations.length));
@@ -1009,7 +1064,7 @@ export function rawTxToBaseTx(raw_tx: string): BaseTx {
10091064
transactionType = DbTxTypeId.PoisonMicroblock;
10101065
break;
10111066
}
1012-
const dbtx: BaseTx = {
1067+
const dbTx: BaseTx = {
10131068
token_transfer_recipient_address: recipientAddr,
10141069
tx_id: txId,
10151070
anchor_mode: 3,
@@ -1025,10 +1080,10 @@ export function rawTxToBaseTx(raw_tx: string): BaseTx {
10251080

10261081
const txPayload = transaction.payload;
10271082
if (txPayload.type_id === TxPayloadTypeID.TokenTransfer) {
1028-
dbtx.token_transfer_memo = txPayload.memo_hex;
1083+
dbTx.token_transfer_memo = txPayload.memo_hex;
10291084
}
10301085

1031-
return dbtx;
1086+
return dbTx;
10321087
}
10331088

10341089
export async function getValidatedFtMetadata(

src/test-utils/test-builders.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,9 @@ interface TestSmartContractLogEventArgs {
394394
contract_identifier?: string;
395395
event_index?: number;
396396
tx_index?: number;
397+
canonical?: boolean;
398+
topic?: string;
399+
value?: string;
397400
}
398401

399402
/**
@@ -407,11 +410,11 @@ function testSmartContractLogEvent(args?: TestSmartContractLogEventArgs): DbSmar
407410
tx_id: args?.tx_id ?? TX_ID,
408411
tx_index: args?.tx_index ?? 0,
409412
block_height: args?.block_height ?? BLOCK_HEIGHT,
410-
canonical: true,
413+
canonical: args?.canonical ?? true,
411414
event_type: DbEventTypeId.SmartContractLog,
412415
contract_identifier: args?.contract_identifier ?? CONTRACT_ID,
413-
topic: 'some-topic',
414-
value: bufferToHexPrefixString(serializeCV(bufferCVFromString('some val'))),
416+
topic: args?.topic ?? 'some-topic',
417+
value: args?.value ?? bufferToHexPrefixString(serializeCV(bufferCVFromString('some val'))),
415418
};
416419
}
417420

0 commit comments

Comments
 (0)