Skip to content

Commit

Permalink
feat: Implement /decode/errors endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
CCristi authored Jun 13, 2024
1 parent d2d5033 commit a827547
Show file tree
Hide file tree
Showing 28 changed files with 1,021 additions and 612 deletions.
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ yarn-error.log
./packages/explorerkit-translator/node_modules
./packages/explorerkit-idls/node_modules
./packages/eslint-config-explorerkit/node_modules
node_modules
node_modules
.env
36 changes: 36 additions & 0 deletions .github/workflows/explorerkit-server-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: ExplorerKit Server CI

on:
pull_request:
paths:
- "packages/explorerkit-server/**"

env:
REDIS_URL: redis://localhost:6379

jobs:
run-test-cases:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup pnpm package manager
uses: pnpm/action-setup@v2
with:
version: 8.6.10
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 20
cache: "pnpm"
- name: Install Node Modules
run: pnpm install
- name: Build workspace
run: pnpm --filter "./packages/explorerkit-*" build
- name: Run ExplorerKit Server Lint
run: pnpm lint
working-directory: ./packages/explorerkit-server
- name: Run ExplorerKit Server Tests
run: pnpm test
working-directory: ./packages/explorerkit-server
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Use official Node.js image as the base image
FROM node:18
FROM node:20

# Set the working directory inside the container
WORKDIR /usr/src/app
Expand Down
23 changes: 23 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
version: "3"
services:
redis:
image: redis:alpine
container_name: client
restart: unless-stopped
expose:
- 6379
explorerkit:
depends_on:
- redis
build:
context: .
dockerfile: Dockerfile
container_name: server
restart: on-failure
environment:
- REDIS_URL=redis://redis:6379
- PORT=3000
ports:
- "3000:3000"
volumes:
- .:/app
2 changes: 2 additions & 0 deletions packages/explorerkit-server/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
REDIS_URL=redis://localhost:6379
PORT=3000
1 change: 1 addition & 0 deletions packages/explorerkit-server/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v20
10 changes: 9 additions & 1 deletion packages/explorerkit-server/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Explorer Kit Server

Configuration is done via environment variables. For local development, you can copy `env.sample` into `.env` and fill in the values.

```
cp env.sample .env
```

Also make sure to run pnpm build in the root directory to build the shared dependencies.

Build & watch

```
Expand Down Expand Up @@ -37,4 +45,4 @@ curl --location 'http://localhost:3000/decode/transactions' --header 'Content-Ty
}'
```

For example responses, see the [tests](./tests/server.test.ts).
For example responses, see the [tests](src/server.test.ts).
12 changes: 8 additions & 4 deletions packages/explorerkit-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"dev": "concurrently \"tsup src/index.ts --format esm,cjs --dts --watch\" \"node dist/index.mjs\"",
"dev": "concurrently \"tsup src/index.ts --format esm,cjs --dts --watch\" \"nodemon dist/index.mjs\"",
"serve": "nodemon dist/index.js",
"test": "vitest run",
"clean": "rimraf .turbo && rimraf node_modules && rimraf dist",
Expand All @@ -39,7 +39,7 @@
"eslint-config-explorerkit": "workspace:*",
"nodemon": "^3.0.2",
"supertest": "^6.3.3",
"vitest": "^0.34.2"
"vitest": "^1.6.0"
},
"dependencies": {
"@solana/web3.js": "^1.87.2",
Expand All @@ -48,8 +48,12 @@
"axios": "^1.3.3",
"body-parser": "^1.20.2",
"bs58": "^5.0.0",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"node-cache": "^5.1.2",
"prom-client": "^15.1.0"
"prom-client": "^15.1.0",
"redis": "^4.6.14",
"vite": "^5.2.13",
"vite-tsconfig-paths": "^4.3.2",
"zod": "^3.23.8"
}
}
53 changes: 53 additions & 0 deletions packages/explorerkit-server/src/components/decoders/errors.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { beforeAll, describe, expect, it, vi } from "vitest";

import { decodeProgramError } from "@/components/decoders/errors";
import { loadAllIdls } from "@/components/idls";

vi.mock("@/core/shared-dependencies", (loadActual) => {
const deps = {
cache: new Map(),
};

return {
...loadActual(),
initSharedDependencies: () => {},
getSharedDep: (name: keyof typeof deps) => deps[name],
getSharedDeps: () => deps,
};
});

describe("errors", () => {
let idls = new Map();
const jupError = {
errorCode: 0x1771,
programId: "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
};

beforeAll(async () => {
idls = await loadAllIdls([jupError.programId]);
});

describe("decodeProgramError", () => {
it("should return the decoded error", async () => {
const result = decodeProgramError(idls, jupError);
expect(result).toEqual({
decodedMessage: "Slippage tolerance exceeded",
errorCode: 6001,
kind: "SlippageToleranceExceeded",
programId: "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
});
});

it("should return error for an unknown programId", () => {
const result = decodeProgramError(idls, {
errorCode: 0x1771,
programId: "UnknownProgramId",
});

expect(result).toEqual({
errorCode: 6001,
programId: "UnknownProgramId",
});
});
});
});
31 changes: 31 additions & 0 deletions packages/explorerkit-server/src/components/decoders/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ErrorParserInterface, ParserType } from "@solanafm/explorer-kit";

import { IdlsMap } from "@/components/idls";
import { ProgramError } from "@/types";

export function decodeProgramError(idls: IdlsMap, programError: ProgramError): ProgramError {
if (!programError.errorCode) {
return programError;
}

const programId = programError.programId;
const parser = idls.get(programId);

if (!parser) {
return programError;
}

const hexErrorCode = `0x${programError.errorCode.toString(16)}`;
const errorParser = parser.createParser(ParserType.ERROR) as ErrorParserInterface;
const parsedError = errorParser.parseError(hexErrorCode);

if (!parsedError) {
return programError;
}

return {
...programError,
decodedMessage: parsedError.data,
kind: parsedError.name,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { beforeAll, describe, expect, it, vi } from "vitest";

import { decodeInstruction } from "@/components/decoders/instructions";
import { loadAllIdls } from "@/components/idls";

vi.mock("@/core/shared-dependencies", (loadActual) => {
const deps = {
cache: new Map(),
};

return {
...loadActual(),
initSharedDependencies: () => {},
getSharedDep: (name: keyof typeof deps) => deps[name],
getSharedDeps: () => deps,
};
});

describe("instructions", () => {
let idls = new Map();
const instruction = {
accountKeys: [],
encodedData: "3ta97iYzVb3u",
programId: "ComputeBudget111111111111111111111111111111",
};

beforeAll(async () => {
idls = await loadAllIdls([instruction.programId]);
});

describe("decodeInstruction", () => {
it("should return the decoded instruction", async () => {
const result = await decodeInstruction(idls, {
accountKeys: [],
encodedData: "3ta97iYzVb3u",
programId: "ComputeBudget111111111111111111111111111111",
});
expect(result).toEqual({
accountKeys: [],
decodedData: {
computeUnitPrice: 317673,
discriminator: 3,
},
encodedData: "3ta97iYzVb3u",
name: "setComputeUnitPrice",
programId: "ComputeBudget111111111111111111111111111111",
});
});
});

it("should return instruction for an unknown programId", async () => {
const result = await decodeInstruction(idls, {
accountKeys: [],
encodedData: "3ta97iYzVb3u",
programId: "UnknownProgramId",
});
expect(result).toEqual({
accountKeys: [],
decodedData: null,
encodedData: "3ta97iYzVb3u",
name: null,
programId: "UnknownProgramId",
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { InstructionParserInterface, ParserType } from "@solanafm/explorer-kit";
import { Buffer } from "buffer";

import { IdlsMap } from "@/components/idls";
import { Instruction, TopLevelInstruction } from "@/types";

export async function decodeInstruction(idls: IdlsMap, instruction: Instruction): Promise<Instruction> {
const programId = instruction.programId.toString();
let parsedInstruction = {
programId: programId.toString(),
encodedData: instruction.encodedData,
name: null,
decodedData: null,
accountKeys: instruction.accountKeys,
};

let parser = idls.get(programId);
if (parser == null) {
return parsedInstruction; // Short-circuit without decodedData since IDL is missing
}
let instructionParser = parser.createParser(ParserType.INSTRUCTION) as InstructionParserInterface;

// Parse the transaction
const decodedInstruction = instructionParser.parseInstructions(instruction.encodedData, instruction.accountKeys);
const decodedInstructionWithTypes = instructionParser.parseInstructions(
instruction.encodedData,
instruction.accountKeys,
true
);
const finalDecodedInstructionData = postProcessDecodedInstruction(
decodedInstruction?.data,
decodedInstructionWithTypes?.data
);
return {
programId: instruction.programId,
encodedData: instruction.encodedData,
name: decodedInstruction?.name || null,
decodedData: finalDecodedInstructionData || null,
accountKeys: instruction.accountKeys,
};
}

function postProcessDecodedInstruction(decodedInstructionData?: any, decodedInstructionDataWithTypes?: any): any {
if (!decodedInstructionData || !decodedInstructionDataWithTypes) {
return null;
}

Object.keys(decodedInstructionDataWithTypes).forEach((key) => {
const property = decodedInstructionDataWithTypes[key];
// If [[u8, X]], base64 encode it
if (
typeof property.type === "object" &&
typeof property.type.vec === "object" &&
Array.isArray(property.type.vec.array) &&
property.type.vec.array[0] === "u8"
) {
const rawValue = decodedInstructionData[key];
if (rawValue && Array.isArray(rawValue) && Array.isArray(rawValue[0])) {
decodedInstructionData[key] = rawValue.map((arr: number[]) => Buffer.from(arr).toString("base64"));
}
}
// If [u8, X], base64 encode it
if (typeof property.type === "object" && Array.isArray(property.type.array) && property.type.array[0] === "u8") {
// Base64 encode the byte array from decodedInstructionData
const byteArray = decodedInstructionData[key];
if (byteArray && Array.isArray(byteArray)) {
decodedInstructionData[key] = Buffer.from(byteArray).toString("base64");
}
}
// If bytes, base64 encode it
if (property.type === "bytes") {
const bytes = decodedInstructionData[key];
if (bytes && bytes.constructor === Uint8Array) {
decodedInstructionData[key] = Buffer.from(bytes).toString("base64");
}
}
});

return decodedInstructionData;
}

export function getProgramIds(instructionsPerTransaction: (TopLevelInstruction[] | null)[]): string[] {
const allProgramIds = instructionsPerTransaction
.flatMap((transactionInstructions) => transactionInstructions || [])
.flatMap((ix) => [ix.topLevelInstruction.programId, ...ix.flattenedInnerInstructions.map((i) => i.programId)]);

return Array.from(new Set(allProgramIds));
}
Loading

0 comments on commit a827547

Please sign in to comment.