diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a567d67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Ryan Conceicao + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 5f4f8c6..b44a264 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,98 @@ -# AnchorES +

+ AnchorES + +

-Minimal Solana transaction parser. +Minimal library for parsing Solana transaction, instructions and events. -- Tree-shakeable: 10KB (2.5KB gzip) to parse Jupiter Swap Events +- Tree-shakeable: 7.82kB (3.48kB gzip) to parse Jupiter Swap Events - ESM and commonjs - No IDL, structs defined in code +- Typescript friendly - Minimal dependencies + +> [!WARNING] +> **This is still a work in progress**. Support for all Borsh types is not yet implemented, and the API is subject to change. + +## Install + +```bash +npm i anchores +``` + +## Parsing Jupiter Swap Transactions + +We export a `parseTransaction` function that takes a program id, a list of instruction and event parsers, and the transaction to parse. + +```typescript +import { parseTransaction } from "anchores"; +import { JUPITER_V6_PROGRAM_ID, SwapEvent } from "anchores/parsers/jupiter"; + +const tx = await connection.getParsedTransaction("txhash"); +const events = parseTransaction( + JUPITER_V6_PROGRAM_ID, + { + events: [SwapEvent], + }, + tx, +); +``` + +See tests for other usage examples. + +## Declaring your own parser + +You can also declare your own parsers. Here is an example of a parser for the Jupiter Swap Event: + +```typescript +import * as b from "anchores/binary"; + +export function parseSwapEvent(data: Uint8Array) { + const reader = b.createReader(data); + return { + amm: b.publicKey(reader), + inputMint: b.publicKey(reader), + inputAmount: b.u64(reader), + outputMint: b.publicKey(reader), + outputAmount: b.u64(reader), + }; +} +export type ParsedSwapEvent = ReturnType; +``` + +You can use this function directly or form a parser object to pass to `parseTransaction`, `decodeEvents`, `decodeStructs`. + +```typescript +import { createSighash } from "anchores/anchor"; + +export const SwapEvent = { + name: "SwapEvent" as const, + discriminator: createSighash("event", "SwapEvent"), + // declared above + parse: parseSwapEvent, +}; + +// Usage +const events = parseTransaction( + PROGRAM_ID, + { + events: [SwapEvent], + }, + tx, +); +// or +const structs = tx.meta.innerInstructions.flatMap((inner) => + inner.instructions.filter(isJupiterInstruction).map((ix) => { + const ixData = base58.decode(ix.data); + const instruct = decodeStructs([SwapInstruction], ixData); + if (instruct) { + return instruct; + } + + const event = decodeEvents([SwapEvent], ixData); + return event; + }), +); +``` diff --git a/package.json b/package.json index 27486b9..8787a32 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,22 @@ { "name": "anchores", "version": "0.0.1", - "description": "", - "keywords": [], - "author": "", - "license": "ISC", + "description": "Minimal library for parsing Solana transaction, instructions and events.", + "keywords": [ + "solana", + "tx", + "transaction", + "parser", + "anchor", + "borsh", + "jupiter", + "instruction", + "event", + "web3" + ], + "author": "Ryan Conceicao", + "license": "MIT", + "repository": "ryoid/anchores", "type": "module", "sideEffects": false, "files": [ diff --git a/src/README.md b/src/README.md deleted file mode 100644 index fbc7115..0000000 --- a/src/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Tiny Solana Parser - -Minimal Solana transaction parser. - -- Tree-shakeable: 10KB (2.5KB gzip) for Jupiter Swaps parser -- ESM and commonjs -- No IDL, structs defined in code -- Minimal dependencies diff --git a/src/binary.test.ts b/src/binary.test.ts new file mode 100644 index 0000000..f0f3a15 --- /dev/null +++ b/src/binary.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import * as b from "./binary.js"; + +describe("binary functions", () => { + it("deserialize publicKey", () => { + const data = new Uint8Array([ + 6, 155, 136, 87, 254, 171, 129, 132, 251, 104, 127, 99, 70, 24, 192, 53, + 218, 196, 57, 220, 26, 235, 59, 85, 152, 160, 240, 0, 0, 0, 0, 1, + ]); + const reader = b.createReader(data); + + expect(b.publicKey(reader)).toBe( + "So11111111111111111111111111111111111111112", + ); + expect(reader.offset).toBe(32); + }); + + it("deserialize u32", () => { + const nsmall = [103, 0, 0, 0]; + const nmax = [255, 255, 255, 255]; + const reader = b.createReader(new Uint8Array([...nsmall, ...nmax])); + + expect(b.u32(reader)).toBe(103); + expect(reader.offset).toBe(4); + + expect(b.u32(reader)).toBe(4_294_967_295); + }); + + it("deserialize u64", () => { + const nsmall = [103, 0, 0, 0, 0, 0, 0, 0]; + const nlarge = [0, 1, 2, 3, 4, 5, 6, 7]; + const nmax = [255, 255, 255, 255, 255, 255, 255, 255]; + const data = new Uint8Array([...nsmall, ...nlarge, ...nmax]); + const reader = b.createReader(data); + + expect(b.u64(reader)).toBe(103n); + expect(reader.offset).toBe(8); + + expect(b.u64(reader)).toBe(506_097_522_914_230_528n); + + expect(b.u64(reader)).toBe(18_446_744_073_709_551_615n); + }); + + it("deserialize u128", () => { + const nsmall = [104, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const nlarge = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + const nmax = [ + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, + ]; + const data = new Uint8Array([...nsmall, ...nlarge, ...nmax]); + const reader = b.createReader(data); + + expect(b.u128(reader)).toBe(104n); + expect(reader.offset).toBe(16); + + expect(b.u128(reader)).toBe( + 20_011_376_718_272_490_338_853_433_276_725_592_320n, + ); + expect(b.u128(reader)).toBe( + 340_282_366_920_938_463_463_374_607_431_768_211_455n, + ); + }); + + it("deserialize i32", () => { + const data = new Uint8Array([255, 255, 255, 127]); + const reader = b.createReader(data); + + expect(b.i32(reader)).toBe(2_147_483_647); + expect(reader.offset).toBe(4); + }); + + it("deserialize bool", () => { + const data = new Uint8Array([1, 0]); + const reader = b.createReader(data); + + expect(b.bool(reader)).toBe(true); + expect(reader.offset).toBe(1); + + expect(b.bool(reader)).toBe(false); + expect(reader.offset).toBe(2); + }); + + it("deserialize string", () => { + const ssimple = [4, 0, 0, 0, 65, 66, 67, 68]; + const sutf8 = [ + 30, 0, 0, 0, 195, 179, 195, 177, 64, 226, 128, 161, 216, 143, 216, 171, + 32, 230, 188, 162, 224, 160, 182, 226, 173, 144, 240, 159, 148, 146, 244, + 128, 128, 128, + ]; + const reader = b.createReader(new Uint8Array([...ssimple, ...sutf8])); + + expect(b.string(reader)).toBe("ABCD"); + expect(reader.offset).toBe(8); + + expect(b.string(reader)).toBe("óñ@‡؏ث 漢࠶⭐🔒􀀀"); + expect(reader.offset).toBe(42); + }); +}); diff --git a/src/binary.ts b/src/binary.ts index 70d177d..bcb3c83 100644 --- a/src/binary.ts +++ b/src/binary.ts @@ -1,21 +1,77 @@ import { base58 } from "@scure/base"; -export function publicKey(data: Uint8Array, offset: number): string { - return base58.encode(data.subarray(offset, offset + 32)); +export type Reader = { + data: Uint8Array; + view: DataView; + offset: number; +}; + +export function createReader(data: Uint8Array): Reader { + return { + data, + view: new DataView(data.buffer, data.byteOffset, data.byteLength), + offset: 0, + }; } -export function u64(view: DataView, offset: number): bigint { - return view.getBigUint64(offset, true); +/** + * Deserializer function. + * + * Consume bytes by moving the reader's offset. + */ +export type Deserializer = (reader: Reader) => T; + +export function publicKey(reader: Reader): string { + const size = 32; + const value = base58.encode( + reader.data.subarray(reader.offset, reader.offset + size), + ); + reader.offset += size; + return value; +} + +export function u32(reader: Reader): number { + const value = reader.view.getUint32(reader.offset, true); + reader.offset += 4; + return value; +} + +export function u64(reader: Reader): bigint { + const value = reader.view.getBigUint64(reader.offset, true); + reader.offset += 8; + return value; +} + +export function u128(reader: Reader): bigint { + const size = 16; + const chunk = reader.data.subarray(reader.offset, reader.offset + size); + let value = 0n; + for (let i = size - 1; i >= 0; i--) { + value = value << 8n; + value = value + BigInt(chunk[i]); + } + + reader.offset += size; + return value; } -export function u128(view: DataView, offset: number): bigint { - return view.getBigUint64(offset, true) + view.getBigUint64(offset + 8, true); +export function i32(reader: Reader): number { + const value = reader.view.getInt32(reader.offset, true); + reader.offset += 4; + return value; } -export function i32(view: DataView, offset: number): number { - return view.getInt32(offset, true); +export function bool(reader: Reader): boolean { + const value = reader.view.getUint8(reader.offset) > 0; + reader.offset += 1; + return value; } -export function bool(view: DataView, offset: number): boolean { - return view.getUint8(offset) > 0; +export function string(reader: Reader): string { + const len = u32(reader); + const value = new TextDecoder().decode( + reader.data.subarray(reader.offset, reader.offset + len), + ); + reader.offset += len; + return value; } diff --git a/src/parsers/jupiter.ts b/src/parsers/jupiter.ts index 4202445..cb35c7b 100644 --- a/src/parsers/jupiter.ts +++ b/src/parsers/jupiter.ts @@ -5,13 +5,13 @@ export const JUPITER_V6_PROGRAM_ID = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"; export function parseSwapEvent(data: Uint8Array) { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const reader = b.createReader(data); return { - amm: b.publicKey(data, 0), // + 32 - inputMint: b.publicKey(data, 32), // + 32 - inputAmount: b.u64(view, 64), // + 8 - outputMint: b.publicKey(data, 72), // + 32 - outputAmount: b.u64(view, 104), // + 8 + amm: b.publicKey(reader), + inputMint: b.publicKey(reader), + inputAmount: b.u64(reader), + outputMint: b.publicKey(reader), + outputAmount: b.u64(reader), }; } export type ParsedSwapEvent = ReturnType; @@ -23,11 +23,11 @@ export const SwapEvent = { }; export function parseFeeEvent(data: Uint8Array) { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const reader = b.createReader(data); return { - account: b.publicKey(data, 0), // + 32 - mint: b.publicKey(data, 32), // + 32 - amount: b.u64(view, 64), // + 8 + account: b.publicKey(reader), + mint: b.publicKey(reader), + amount: b.u64(reader), }; } export type ParsedFeeEvent = ReturnType; diff --git a/src/parsers/meteora-dlmm.ts b/src/parsers/meteora-dlmm.ts index 1d56e3b..10f3575 100644 --- a/src/parsers/meteora-dlmm.ts +++ b/src/parsers/meteora-dlmm.ts @@ -5,19 +5,19 @@ export const METEORA_DLMM_PROGRAM_ID = "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo"; export function parseSwapEvent(data: Uint8Array) { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const reader = b.createReader(data); return { - lbPair: b.publicKey(data, 0), // + 32 - from: b.publicKey(data, 32), // + 32 - startBinId: b.i32(view, 64), // + 4 - endBinId: b.i32(view, 68), // + 4 - amountIn: b.u64(view, 72), // + 8 - amountOut: b.u64(view, 80), // + 8 - swapForY: b.bool(view, 88), // + 1 - fee: b.u64(view, 89), // + 8 - protocolFee: b.u64(view, 97), // + 8 - feeBps: b.u128(view, 105), // + 16 - hostFee: b.u64(view, 121), // + 8 + lbPair: b.publicKey(reader), + from: b.publicKey(reader), + startBinId: b.i32(reader), + endBinId: b.i32(reader), + amountIn: b.u64(reader), + amountOut: b.u64(reader), + swapForY: b.bool(reader), + fee: b.u64(reader), + protocolFee: b.u64(reader), + feeBps: b.u128(reader), + hostFee: b.u64(reader), }; } export type ParsedSwapEvent = ReturnType; @@ -29,10 +29,10 @@ export const SwapEvent = { }; export function parseSwapInstruction(data: Uint8Array) { - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const reader = b.createReader(data); return { - amountIn: b.u64(view, 0), // + 8 - minAmountOut: b.u64(view, 8), // + 8 + amountIn: b.u64(reader), + minAmountOut: b.u64(reader), }; } export type ParsedSwapInstruction = ReturnType;