From ade7652a4842307cd0b1bdda597ce94f46bda767 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:57:34 -0600 Subject: [PATCH 1/2] feat: solana standard tx parsing --- packages/unchained-client/openapitools.json | 13 ++ packages/unchained-client/src/index.ts | 1 + packages/unchained-client/src/solana/index.ts | 5 + .../parser/__tests__/mockData/solSelfSend.ts | 72 ++++++++++ .../parser/__tests__/mockData/solStandard.ts | 60 +++++++++ .../solana/parser/__tests__/solana.test.ts | 126 ++++++++++++++++++ .../src/solana/parser/index.ts | 98 ++++++++++++++ .../src/solana/parser/types.ts | 14 ++ 8 files changed, 389 insertions(+) create mode 100644 packages/unchained-client/src/solana/index.ts create mode 100644 packages/unchained-client/src/solana/parser/__tests__/mockData/solSelfSend.ts create mode 100644 packages/unchained-client/src/solana/parser/__tests__/mockData/solStandard.ts create mode 100644 packages/unchained-client/src/solana/parser/__tests__/solana.test.ts create mode 100644 packages/unchained-client/src/solana/parser/index.ts create mode 100644 packages/unchained-client/src/solana/parser/types.ts diff --git a/packages/unchained-client/openapitools.json b/packages/unchained-client/openapitools.json index 3e66633fe22..a8062c8f840 100644 --- a/packages/unchained-client/openapitools.json +++ b/packages/unchained-client/openapitools.json @@ -187,6 +187,19 @@ "useSingleRequestParameter": true } }, + "solana": { + "inputSpec": "https://raw.githubusercontent.com/shapeshift/unchained/develop/node/coinstacks/solana/api/src/swagger.json", + "generatorName": "typescript-fetch", + "output": "#{cwd}/src/generated/solana", + "enablePostProcessFile": true, + "reservedWordsMappings": { + "in": "in" + }, + "additionalProperties": { + "supportsES6": "true", + "useSingleRequestParameter": true + } + }, "thorchain": { "inputSpec": "https://raw.githubusercontent.com/shapeshift/unchained/develop/go/coinstacks/thorchain/api/swagger.json", "generatorName": "typescript-fetch", diff --git a/packages/unchained-client/src/index.ts b/packages/unchained-client/src/index.ts index 7ea558079ae..185b08a6e48 100644 --- a/packages/unchained-client/src/index.ts +++ b/packages/unchained-client/src/index.ts @@ -5,6 +5,7 @@ export * as ws from './websocket' export * as evm from './evm' export * as utxo from './utxo' export * as cosmossdk from './cosmossdk' +export * as solana from './solana' export * as ethereum from './evm/ethereum' export * as avalanche from './evm/avalanche' diff --git a/packages/unchained-client/src/solana/index.ts b/packages/unchained-client/src/solana/index.ts new file mode 100644 index 00000000000..f498d679d1c --- /dev/null +++ b/packages/unchained-client/src/solana/index.ts @@ -0,0 +1,5 @@ +import type { V1Api } from '../generated/solana' + +export type Api = V1Api + +export * from './parser' diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/solSelfSend.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/solSelfSend.ts new file mode 100644 index 00000000000..53da088c75f --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/solSelfSend.ts @@ -0,0 +1,72 @@ +import type { Tx } from '../../..' + +const tx: Tx = { + txid: '3owXWn8Em7FE7Dyao3kPLkTPySiGGSSo9e7VGiWDifk6GfQRrm2JYHdHStBzVRr6b6o1PztbGpuDsXb8o2yPxoV3', + blockHeight: 293321352, + description: + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV transferred 0.000000001 SOL to DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV.', + type: 'TRANSFER', + source: 'SYSTEM_PROGRAM', + fee: 25000, + feePayer: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + signature: + '3owXWn8Em7FE7Dyao3kPLkTPySiGGSSo9e7VGiWDifk6GfQRrm2JYHdHStBzVRr6b6o1PztbGpuDsXb8o2yPxoV3', + slot: 293321352, + timestamp: 1727896282, + tokenTransfers: [], + nativeTransfers: [ + { + fromUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + toUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + amount: 1, + }, + ], + accountData: [ + { + account: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + nativeBalanceChange: -25000, + tokenBalanceChanges: [], + }, + { + account: 'ComputeBudget111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: '11111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + ], + transactionError: null, + instructions: [ + { + accounts: [], + data: '3gJqkocMWaMm', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + { + accounts: [], + data: 'Fj2Eoy', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + { + accounts: [ + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + ], + data: '3Bxs412MvVNQj175', + programId: '11111111111111111111111111111111', + innerInstructions: [], + }, + ], + events: { + compressed: null, + nft: null, + swap: null, + }, +} + +export default { tx } diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/solStandard.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/solStandard.ts new file mode 100644 index 00000000000..6a2c59109a2 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/solStandard.ts @@ -0,0 +1,60 @@ +import type { Tx } from '../../..' + +const tx: Tx = { + txid: 'qN3jbqvw2ypfmTVJuUiohgLQgV4mq8oZ6QzuKhNeM8MX1bdAxCK7EoXJbvBUD61mhGmrFr1KQi5FqgcadfYi7CS', + blockHeight: 294850279, + description: + 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW transferred 0.010000388 SOL to DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL.', + type: 'TRANSFER', + source: 'SYSTEM_PROGRAM', + fee: 5000, + feePayer: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + signature: + 'qN3jbqvw2ypfmTVJuUiohgLQgV4mq8oZ6QzuKhNeM8MX1bdAxCK7EoXJbvBUD61mhGmrFr1KQi5FqgcadfYi7CS', + slot: 294850279, + timestamp: 1728580091, + tokenTransfers: [], + nativeTransfers: [ + { + fromUserAccount: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + toUserAccount: 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + amount: 10000388, + }, + ], + accountData: [ + { + account: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + nativeBalanceChange: -10005388, + tokenBalanceChanges: [], + }, + { + account: 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + nativeBalanceChange: 10000388, + tokenBalanceChanges: [], + }, + { + account: '11111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + ], + transactionError: null, + instructions: [ + { + accounts: [ + 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + ], + data: '3Bxs41dFLGCCYtUF', + programId: '11111111111111111111111111111111', + innerInstructions: [], + }, + ], + events: { + compressed: null, + nft: null, + swap: null, + }, +} + +export default { tx } diff --git a/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts b/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts new file mode 100644 index 00000000000..d6a5eb71511 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts @@ -0,0 +1,126 @@ +import { solanaChainId, solAssetId } from '@shapeshiftoss/caip' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +import { TransferType, TxStatus } from '../../../types' +import type { ParsedTx } from '../../parser' +import { TransactionParser } from '../index' +import solSelfSend from './mockData/solSelfSend' +import solStandard from './mockData/solStandard' + +const txParser = new TransactionParser({ assetId: solAssetId, chainId: solanaChainId }) + +describe('parseTx', () => { + beforeAll(() => { + vi.clearAllMocks() + }) + + describe('standard', () => { + it('should be able to parse sol send', async () => { + const { tx } = solStandard + const address = 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW' + + const expected: ParsedTx = { + address, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: solanaChainId, + confirmations: 1, + fee: { + assetId: solAssetId, + value: '5000', + }, + status: TxStatus.Confirmed, + transfers: [ + { + assetId: solAssetId, + components: [{ value: '10000388' }], + from: address, + to: 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + totalValue: '10000388', + type: TransferType.Send, + }, + ], + txid: tx.txid, + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + + it('should be able to parse sol receive', async () => { + const { tx } = solStandard + const address = 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL' + + const expected: ParsedTx = { + address, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: solanaChainId, + confirmations: 1, + status: TxStatus.Confirmed, + transfers: [ + { + assetId: solAssetId, + components: [{ value: '10000388' }], + from: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + to: address, + totalValue: '10000388', + type: TransferType.Receive, + }, + ], + txid: tx.txid, + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + }) + + describe('self send', () => { + it('should be able to parse sol', async () => { + const { tx } = solSelfSend + const address = 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV' + + const expected: ParsedTx = { + txid: tx.txid, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + address, + chainId: solanaChainId, + confirmations: 1, + status: TxStatus.Confirmed, + fee: { + value: '25000', + assetId: solAssetId, + }, + transfers: [ + { + type: TransferType.Send, + from: address, + to: address, + assetId: solAssetId, + totalValue: '1', + components: [{ value: '1' }], + }, + { + type: TransferType.Receive, + from: address, + to: address, + assetId: solAssetId, + totalValue: '1', + components: [{ value: '1' }], + }, + ], + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + }) +}) diff --git a/packages/unchained-client/src/solana/parser/index.ts b/packages/unchained-client/src/solana/parser/index.ts new file mode 100644 index 00000000000..fa22d8e1dec --- /dev/null +++ b/packages/unchained-client/src/solana/parser/index.ts @@ -0,0 +1,98 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' + +import { TransferType, TxStatus } from '../../types' +import { aggregateTransfer } from '../../utils' +import type { ParsedTx, SubParser, Tx } from './types' + +export * from './types' + +export interface TransactionParserArgs { + chainId: ChainId + assetId: AssetId +} + +export class TransactionParser { + chainId: ChainId + assetId: AssetId + + private parsers: SubParser[] = [] + + constructor(args: TransactionParserArgs) { + this.chainId = args.chainId + this.assetId = args.assetId + } + + /** + * Register custom transaction sub parser to parse custom op return data + * + * _parsers should be registered from most generic first to most specific last_ + */ + registerParser(parser: SubParser): void { + this.parsers.unshift(parser) + } + + protected registerParsers(parsers: SubParser[]): void { + parsers.forEach(parser => this.registerParser(parser)) + } + + async parse(tx: T, address: string): Promise { + const parserResult = await (async () => { + for (const parser of this.parsers) { + const result = await parser.parse(tx, address) + if (result) return result + } + })() + + const parsedTx: ParsedTx = { + address, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: this.chainId, + confirmations: 1, + status: this.getStatus(tx), + trade: parserResult?.trade, + transfers: parserResult?.transfers ?? [], + txid: tx.txid, + } + + // network fee + if (tx.feePayer === address && tx.fee) { + parsedTx.fee = { assetId: this.assetId, value: BigInt(tx.fee).toString() } + } + + tx.nativeTransfers?.forEach(nativeTransfer => { + const { amount, fromUserAccount, toUserAccount } = nativeTransfer + + // send amount + if (nativeTransfer.fromUserAccount === address) { + parsedTx.transfers = aggregateTransfer({ + assetId: this.assetId, + from: fromUserAccount ?? '', + to: toUserAccount ?? '', + transfers: parsedTx.transfers, + type: TransferType.Send, + value: BigInt(amount).toString(), + }) + } + + // receive amount + if (nativeTransfer.toUserAccount === address) { + parsedTx.transfers = aggregateTransfer({ + assetId: this.assetId, + from: fromUserAccount ?? '', + to: toUserAccount ?? '', + transfers: parsedTx.transfers, + type: TransferType.Receive, + value: BigInt(amount).toString(), + }) + } + }) + + return parsedTx + } + + private getStatus(tx: T): TxStatus { + if (tx.transactionError) return TxStatus.Failed + return TxStatus.Confirmed + } +} diff --git a/packages/unchained-client/src/solana/parser/types.ts b/packages/unchained-client/src/solana/parser/types.ts new file mode 100644 index 00000000000..de2b3774dc0 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/types.ts @@ -0,0 +1,14 @@ +import type * as solana from '../../generated/solana' +import type { StandardTx } from '../../types' + +export * from '../../generated/solana' + +export type Tx = solana.Tx + +export interface ParsedTx extends StandardTx {} + +export type TxSpecific = Partial> + +export interface SubParser { + parse(tx: T, address: string): Promise +} From 4ab1c60423c8a42b1a79db32cca9269a670e038d Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 14 Oct 2024 08:56:14 -0600 Subject: [PATCH 2/2] comment --- packages/unchained-client/src/solana/parser/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/unchained-client/src/solana/parser/index.ts b/packages/unchained-client/src/solana/parser/index.ts index fa22d8e1dec..c514f2e5ba9 100644 --- a/packages/unchained-client/src/solana/parser/index.ts +++ b/packages/unchained-client/src/solana/parser/index.ts @@ -48,6 +48,7 @@ export class TransactionParser { blockHeight: tx.blockHeight, blockTime: tx.timestamp, chainId: this.chainId, + // all transactions from unchained are finalized with at least 1 confirmation (unused throughout web) confirmations: 1, status: this.getStatus(tx), trade: parserResult?.trade,