Skip to content

Commit c7cc900

Browse files
authored
feat: include Stacks 2.1 stx-transfer-memo in Rosetta token transfer operations (#1290)
* chore: cleanup Rosetta operation type strings * feat: include `memo` from stx-transfer-memo events in Rosetta token transfer operations * chore: lint fix * chore: Rosetta tests for memo in stx-transfer-memo events * fix: Rosetta memo strings should be decoded from hex * chore: add `coinbase-pay-to-alt` tx type test for Rosetta parsing
1 parent cb5ca93 commit c7cc900

File tree

7 files changed

+379
-62
lines changed

7 files changed

+379
-62
lines changed

src/api/controllers/db-controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ async function parseRosettaTxDetail(opts: {
517517
events,
518518
opts.unlockingEvents
519519
);
520-
const txMemo = parseTransactionMemo(opts.tx);
520+
const txMemo = parseTransactionMemo(opts.tx.token_transfer_memo);
521521
const rosettaTx: RosettaTransaction = {
522522
transaction_identifier: { hash: opts.tx.tx_id },
523523
operations: operations,

src/api/rosetta-constants.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,29 @@ export enum RosettaOperationType {
4646
RevokeDelegateStx = 'revoke_delegate_stx',
4747
}
4848

49-
export const RosettaOperationTypes = [
50-
RosettaOperationType.TokenTransfer,
51-
RosettaOperationType.ContractCall,
52-
RosettaOperationType.SmartContract,
53-
RosettaOperationType.Coinbase,
54-
RosettaOperationType.PoisonMicroblock,
55-
RosettaOperationType.Fee,
56-
RosettaOperationType.Mint,
57-
RosettaOperationType.Burn,
58-
RosettaOperationType.MinerReward,
59-
RosettaOperationType.StxLock, // stx event
60-
RosettaOperationType.StxUnlock, // forged event
61-
RosettaOperationType.StackStx, // PoX contract function
62-
RosettaOperationType.DelegateStx, // PoX contract function
63-
RosettaOperationType.RevokeDelegateStx, // PoX contract function
64-
];
49+
type RosettaOperationTypeUnion = `${RosettaOperationType}`;
50+
51+
// Function that leverages typescript to ensure a given array contains all values from a type union
52+
const arrayOfAllOpTypes = <T extends RosettaOperationTypeUnion[]>(
53+
array: T & ([RosettaOperationTypeUnion] extends [T[number]] ? unknown : 'Invalid')
54+
) => array;
55+
56+
export const RosettaOperationTypes = arrayOfAllOpTypes([
57+
'token_transfer',
58+
'contract_call',
59+
'smart_contract',
60+
'coinbase',
61+
'poison_microblock',
62+
'fee',
63+
'mint',
64+
'burn',
65+
'miner_reward',
66+
'stx_lock',
67+
'stx_unlock',
68+
'stack_stx',
69+
'delegate_stx',
70+
'revoke_delegate_stx',
71+
]) as RosettaOperationType[];
6572

6673
export const RosettaOperationStatuses = [
6774
{

src/api/routes/rosetta/construction.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export function createRosettaConstructionRouter(db: PgStore, chainId: ChainID):
185185
}
186186

187187
let transaction: StacksTransaction;
188-
switch (options.type) {
188+
switch (options.type as RosettaOperationType) {
189189
case RosettaOperationType.TokenTransfer:
190190
// dummy transaction to calculate size
191191
const dummyTokenTransferTx: UnsignedTokenTransferOptions = {
@@ -328,7 +328,7 @@ export function createRosettaConstructionRouter(db: PgStore, chainId: ChainID):
328328
}
329329

330330
const request: RosettaConstructionMetadataRequest = req.body;
331-
const options: RosettaOptions = req.body.options;
331+
const options: RosettaOptions = request.options;
332332

333333
if (options?.sender_address && !isValidC32Address(options.sender_address)) {
334334
res.status(400).json(RosettaErrors[RosettaErrorsTypes.invalidSender]);
@@ -345,7 +345,7 @@ export function createRosettaConstructionRouter(db: PgStore, chainId: ChainID):
345345
}
346346

347347
let response = {} as RosettaConstructionMetadataResponse;
348-
switch (options.type) {
348+
switch (options.type as RosettaOperationType) {
349349
case RosettaOperationType.TokenTransfer:
350350
const recipientAddress = options.token_transfer_recipient_address;
351351
if (options?.decimals !== RosettaConstants.decimals) {
@@ -515,7 +515,7 @@ export function createRosettaConstructionRouter(db: PgStore, chainId: ChainID):
515515
try {
516516
const baseTx = rawTxToBaseTx(inputTx);
517517
const operations = await getOperations(baseTx, db);
518-
const txMemo = parseTransactionMemo(baseTx);
518+
const txMemo = parseTransactionMemo(baseTx.token_transfer_memo);
519519
let response: RosettaConstructionParseResponse;
520520
if (signed) {
521521
response = {
@@ -642,7 +642,7 @@ export function createRosettaConstructionRouter(db: PgStore, chainId: ChainID):
642642
}
643643

644644
let transaction: StacksTransaction;
645-
switch (options.type) {
645+
switch (options.type as RosettaOperationType) {
646646
case RosettaOperationType.TokenTransfer: {
647647
const recipientAddress = options.token_transfer_recipient_address;
648648
if (!recipientAddress) {

src/api/routes/rosetta/mempool.ts

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

7272
const operations = await getOperations(mempoolTxQuery.result, db);
73-
const txMemo = parseTransactionMemo(mempoolTxQuery.result);
73+
const txMemo = parseTransactionMemo(mempoolTxQuery.result.token_transfer_memo);
7474
const transaction: RosettaTransaction = {
7575
transaction_identifier: { hash: tx_id },
7676
operations: operations,

src/rosetta-helpers.ts

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,19 @@ type RosettaRevokeDelegateContractArgs = {
103103
result: string;
104104
};
105105

106-
export function parseTransactionMemo(tx: BaseTx): string | null {
107-
if (tx.token_transfer_memo && tx.token_transfer_memo != '') {
106+
export function parseTransactionMemo(memoHex: string | undefined): string | null {
107+
if (memoHex) {
108108
// Memos are a fixed-length 34 byte array. Any memo representing a string that is
109109
// less than 34 bytes long will have right-side padded null-bytes.
110-
return tx.token_transfer_memo.replace(/\0.*$/g, '');
110+
let memoBuffer = hexToBuffer(memoHex);
111+
while (memoBuffer.length > 0 && memoBuffer[memoBuffer.length - 1] === 0) {
112+
memoBuffer = memoBuffer.slice(0, memoBuffer.length - 1);
113+
}
114+
if (memoBuffer.length === 0) {
115+
return null;
116+
}
117+
const memoDecoded = memoBuffer.toString('utf8');
118+
return memoDecoded;
111119
}
112120
return null;
113121
}
@@ -120,22 +128,22 @@ export async function getOperations(
120128
stxUnlockEvents?: StxUnlockEvent[]
121129
): Promise<RosettaOperation[]> {
122130
const operations: RosettaOperation[] = [];
123-
const txType = getTxTypeString(tx.type_id);
131+
const txType = getTxTypeString(tx.type_id) as RosettaOperationType;
124132
switch (txType) {
125-
case 'token_transfer':
133+
case RosettaOperationType.TokenTransfer:
126134
operations.push(makeFeeOperation(tx));
127-
operations.push(makeSenderOperation(tx, operations.length));
128-
operations.push(makeReceiverOperation(tx, operations.length));
135+
operations.push(makeSenderOperation(tx, operations.length, tx.token_transfer_memo));
136+
operations.push(makeReceiverOperation(tx, operations.length, tx.token_transfer_memo));
129137
break;
130-
case 'contract_call':
138+
case RosettaOperationType.ContractCall:
131139
operations.push(makeFeeOperation(tx));
132140
operations.push(await makeCallContractOperation(tx, db, operations.length));
133141
break;
134-
case 'smart_contract':
142+
case RosettaOperationType.SmartContract:
135143
operations.push(makeFeeOperation(tx));
136144
operations.push(makeDeployContractOperation(tx, operations.length));
137145
break;
138-
case 'coinbase':
146+
case RosettaOperationType.Coinbase:
139147
operations.push(makeCoinbaseOperation(tx, 0));
140148
if (minerRewards !== undefined) {
141149
getMinerOperations(minerRewards, operations);
@@ -144,7 +152,7 @@ export async function getOperations(
144152
processUnlockingEvents(stxUnlockEvents, operations);
145153
}
146154
break;
147-
case 'poison_microblock':
155+
case RosettaOperationType.PoisonMicroblock:
148156
operations.push(makePoisonMicroblockOperation(tx, 0));
149157
break;
150158
default:
@@ -196,8 +204,8 @@ async function processEvents(
196204
stxAssetEvent.amount,
197205
() => 'Unexpected nullish amount'
198206
);
199-
operations.push(makeSenderOperation(tx, operations.length));
200-
operations.push(makeReceiverOperation(tx, operations.length));
207+
operations.push(makeSenderOperation(tx, operations.length, stxAssetEvent.memo));
208+
operations.push(makeReceiverOperation(tx, operations.length, stxAssetEvent.memo));
201209
break;
202210
case DbAssetEventTypeId.Burn:
203211
operations.push(makeBurnOperation(stxAssetEvent, baseTx, operations.length));
@@ -254,7 +262,7 @@ function makeStakeLockOperation(
254262
stake_metadata.unlock_height = tx.unlock_height.toString();
255263
const lock: RosettaOperation = {
256264
operation_identifier: { index: index },
257-
type: getEventTypeString(tx.event_type),
265+
type: RosettaOperationType.StxLock,
258266
status: getTxStatus(baseTx.status),
259267
account: {
260268
address: unwrapOptional(tx.locked_address, () => 'Unexpected nullish locked_address'),
@@ -337,7 +345,7 @@ function makeFeeOperation(tx: BaseTx): RosettaOperation {
337345
function makeBurnOperation(tx: DbStxEvent, baseTx: BaseTx, index: number): RosettaOperation {
338346
const burn: RosettaOperation = {
339347
operation_identifier: { index: index },
340-
type: getAssetEventTypeString(tx.asset_event_type_id),
348+
type: RosettaOperationType.Burn,
341349
status: getTxStatus(baseTx.status),
342350
account: {
343351
address: unwrapOptional(baseTx.sender_address, () => 'Unexpected nullish sender_address'),
@@ -359,7 +367,7 @@ function makeFtBurnOperation(
359367
): RosettaOperation {
360368
const burn: RosettaOperation = {
361369
operation_identifier: { index: index },
362-
type: getAssetEventTypeString(ftEvent.asset_event_type_id),
370+
type: RosettaOperationType.Burn,
363371
status: getTxStatus(baseTx.status),
364372
account: {
365373
address: unwrapOptional(ftEvent.sender, () => 'Unexpected nullish sender_address'),
@@ -379,7 +387,7 @@ function makeFtBurnOperation(
379387
function makeMintOperation(tx: DbStxEvent, baseTx: BaseTx, index: number): RosettaOperation {
380388
const mint: RosettaOperation = {
381389
operation_identifier: { index: index },
382-
type: getAssetEventTypeString(tx.asset_event_type_id),
390+
type: RosettaOperationType.Mint,
383391
status: getTxStatus(baseTx.status),
384392
account: {
385393
address: unwrapOptional(tx.recipient, () => 'Unexpected nullish sender_address'),
@@ -403,7 +411,7 @@ function makeFtMintOperation(
403411
): RosettaOperation {
404412
const mint: RosettaOperation = {
405413
operation_identifier: { index: index },
406-
type: getAssetEventTypeString(ftEvent.asset_event_type_id),
414+
type: RosettaOperationType.Mint,
407415
status: getTxStatus(baseTx.status),
408416
account: {
409417
address: unwrapOptional(ftEvent.recipient, () => 'Unexpected nullish sender_address'),
@@ -423,10 +431,14 @@ function makeFtMintOperation(
423431
return mint;
424432
}
425433

426-
function makeSenderOperation(tx: BaseTx, index: number): RosettaOperation {
434+
function makeSenderOperation(
435+
tx: BaseTx,
436+
index: number,
437+
memo: string | undefined
438+
): RosettaOperation {
427439
const sender: RosettaOperation = {
428440
operation_identifier: { index: index },
429-
type: 'token_transfer', //Sender operation should always be token_transfer,
441+
type: RosettaOperationType.TokenTransfer, //Sender operation should always be token_transfer,
430442
status: getTxStatus(tx.status),
431443
account: {
432444
address: unwrapOptional(tx.sender_address, () => 'Unexpected nullish sender_address'),
@@ -444,6 +456,13 @@ function makeSenderOperation(tx: BaseTx, index: number): RosettaOperation {
444456
},
445457
};
446458

459+
if (memo) {
460+
sender.metadata = {
461+
...sender.metadata,
462+
memo: parseTransactionMemo(memo),
463+
};
464+
}
465+
447466
return sender;
448467
}
449468

@@ -455,7 +474,7 @@ function makeFtSenderOperation(
455474
): RosettaOperation {
456475
const sender: RosettaOperation = {
457476
operation_identifier: { index: index },
458-
type: 'token_transfer',
477+
type: RosettaOperationType.TokenTransfer,
459478
status: getTxStatus(tx.status),
460479
account: {
461480
address: unwrapOptional(ftEvent.sender, () => 'Unexpected nullish sender_address'),
@@ -478,11 +497,15 @@ function makeFtSenderOperation(
478497
return sender;
479498
}
480499

481-
function makeReceiverOperation(tx: BaseTx, index: number): RosettaOperation {
500+
function makeReceiverOperation(
501+
tx: BaseTx,
502+
index: number,
503+
memo: string | undefined
504+
): RosettaOperation {
482505
const receiver: RosettaOperation = {
483506
operation_identifier: { index: index },
484507
related_operations: [{ index: index - 1 }],
485-
type: 'token_transfer', //Receiver operation should always be token_transfer
508+
type: RosettaOperationType.TokenTransfer, //Receiver operation should always be token_transfer
486509
status: getTxStatus(tx.status),
487510
account: {
488511
address: unwrapOptional(
@@ -503,6 +526,13 @@ function makeReceiverOperation(tx: BaseTx, index: number): RosettaOperation {
503526
},
504527
};
505528

529+
if (memo) {
530+
receiver.metadata = {
531+
...receiver.metadata,
532+
memo: parseTransactionMemo(memo),
533+
};
534+
}
535+
506536
return receiver;
507537
}
508538

@@ -515,7 +545,7 @@ function makeFtReceiverOperation(
515545
const receiver: RosettaOperation = {
516546
operation_identifier: { index: index },
517547
related_operations: [{ index: index - 1 }],
518-
type: 'token_transfer',
548+
type: RosettaOperationType.TokenTransfer,
519549
status: getTxStatus(tx.status),
520550
account: {
521551
address: unwrapOptional(
@@ -545,7 +575,7 @@ function makeFtReceiverOperation(
545575
function makeDeployContractOperation(tx: BaseTx, index: number): RosettaOperation {
546576
const deployer: RosettaOperation = {
547577
operation_identifier: { index: index },
548-
type: getTxTypeString(tx.type_id),
578+
type: RosettaOperationType.SmartContract,
549579
status: getTxStatus(tx.status),
550580
account: {
551581
address: unwrapOptional(tx.sender_address, () => 'Unexpected nullish sender_address'),
@@ -562,7 +592,7 @@ async function makeCallContractOperation(
562592
): Promise<RosettaOperation> {
563593
const contractCallOp: RosettaOperation = {
564594
operation_identifier: { index: index },
565-
type: getTxTypeString(tx.type_id),
595+
type: RosettaOperationType.ContractCall,
566596
status: getTxStatus(tx.status),
567597
account: {
568598
address: unwrapOptional(tx.sender_address, () => 'Unexpected nullish sender_address'),
@@ -598,7 +628,7 @@ function makeCoinbaseOperation(tx: BaseTx, index: number): RosettaOperation {
598628
// TODO : Add more mappings in operations for coinbase
599629
const sender: RosettaOperation = {
600630
operation_identifier: { index: index },
601-
type: getTxTypeString(tx.type_id),
631+
type: RosettaOperationType.Coinbase,
602632
status: getTxStatus(tx.status),
603633
account: {
604634
address: unwrapOptional(tx.sender_address, () => 'Unexpected nullish sender_address'),
@@ -612,7 +642,7 @@ function makePoisonMicroblockOperation(tx: BaseTx, index: number): RosettaOperat
612642
// TODO : add more mappings in operations for poison-microblock
613643
const sender: RosettaOperation = {
614644
operation_identifier: { index: index },
615-
type: getTxTypeString(tx.type_id),
645+
type: RosettaOperationType.PoisonMicroblock,
616646
status: getTxStatus(tx.status),
617647
account: {
618648
address: unwrapOptional(tx.sender_address, () => 'Unexpected nullish sender_address'),
@@ -645,7 +675,7 @@ export function getOptionsFromOperations(operations: RosettaOperation[]): Rosett
645675
const options: RosettaOptions = {};
646676

647677
for (const operation of operations) {
648-
switch (operation.type) {
678+
switch (operation.type as RosettaOperationType) {
649679
case RosettaOperationType.Fee:
650680
options.fee = operation.amount?.value;
651681
break;

0 commit comments

Comments
 (0)