txray 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/SECURITY.md +9 -0
- package/dist/adapters/examples.js +173 -0
- package/dist/adapters/rpc.js +132 -0
- package/dist/app.js +111 -0
- package/dist/cli.js +38 -0
- package/dist/domain/analyze.js +161 -0
- package/dist/domain/base58.js +48 -0
- package/dist/domain/errors.js +192 -0
- package/dist/domain/instructions.js +54 -0
- package/dist/domain/logs.js +47 -0
- package/dist/domain/schema.js +90 -0
- package/dist/public/app.js +7 -0
- package/dist/public/architecture.html +79 -0
- package/dist/public/architecture.svg +17 -0
- package/dist/public/chevron.svg +1 -0
- package/dist/public/guide.html +66 -0
- package/dist/public/index.html +180 -0
- package/dist/public/mark.svg +1 -0
- package/dist/public/styles.css +865 -0
- package/dist/server.js +26 -0
- package/docs/api.md +37 -0
- package/docs/architecture.md +32 -0
- package/docs/architecture.svg +17 -0
- package/docs/guide.md +41 -0
- package/docs/screenshot.png +0 -0
- package/package.json +80 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-27
|
|
4
|
+
|
|
5
|
+
- Install-and-run npm CLI: `npx txray`, with port, help and version options.
|
|
6
|
+
- Built assets included in the package, with a clean-consumer install test.
|
|
7
|
+
- Read-only confirmed Mainnet and Devnet transaction inspection.
|
|
8
|
+
- Known runtime-error explanations, explicit unknown custom errors and missing-data states.
|
|
9
|
+
- Legacy, v0 lookup-address and v1 transaction-config support.
|
|
10
|
+
- Exact recorded fees, compute, instruction timeline and nested logs.
|
|
11
|
+
- Dark local dashboard, fictional offline examples, text copy and JSON export.
|
|
12
|
+
- In-app guide, architecture, API documentation and automated tests.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Use Node.js 22.18+ and npm. Keep the tool read-only, local-first and small.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install
|
|
7
|
+
npm run check
|
|
8
|
+
npm run package:check
|
|
9
|
+
npx playwright install chromium
|
|
10
|
+
npm run test:e2e
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`npm run package:check` installs the built tarball into a clean temporary consumer and tests its actual executable, assets and dependency audit. It needs registry access but never publishes.
|
|
14
|
+
|
|
15
|
+
`npm run format` formats source and docs. `npm run dev` rebuilds browser assets once and watches the server; rerun `npm run build` after browser or documentation edits.
|
|
16
|
+
|
|
17
|
+
Keep domain logic separate from RPC and HTTP. Test new error mappings against a documented program/runtime definition; never infer a custom error's meaning from its number or a log substring. Missing data must not become success or zero.
|
|
18
|
+
|
|
19
|
+
Add regression tests for bug fixes. Use synthetic fixtures with an explicit label; never add private keys, `.env`, node_modules, build output, local ledgers or video production tools. Keep commits focused and use Conventional Commits.
|
|
20
|
+
|
|
21
|
+
A useful bug report includes Node version, selected network, expected and observed behavior, and a minimal public transaction signature if sharing it is appropriate. Never include wallet keys or access tokens.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Royal Pinto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# txray
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Solana Transaction Autopsy.** Paste a transaction signature and understand the failure, fee and execution behind it.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx txray
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires **Node.js 22.18+**. Open **http://127.0.0.1:4450**, paste a signature and choose Mainnet or Devnet. Or start with a labeled offline example. No wallet, SOL, API key or account needed.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
[Source on GitHub](https://github.com/royalpinto007/txray) · [npm package](https://www.npmjs.com/package/txray)
|
|
14
|
+
|
|
15
|
+
Use `npx txray --port 4452` for another port. `--help` shows options; `--version` prints the installed version. Press Ctrl+C to stop.
|
|
16
|
+
|
|
17
|
+
## Run from source
|
|
18
|
+
|
|
19
|
+
Requires **Node.js 22.18+** and npm.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
git clone https://github.com/royalpinto007/txray.git
|
|
23
|
+
cd txray
|
|
24
|
+
npm install
|
|
25
|
+
npm run demo
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## What you get
|
|
29
|
+
|
|
30
|
+
- Plain explanations for known runtime errors. Unknown custom errors stay unknown.
|
|
31
|
+
- Failed top-level instruction, program addresses and nested execution logs.
|
|
32
|
+
- Exact recorded fees, compute consumption and requested compute settings.
|
|
33
|
+
- Legacy, v0 and v1 support, with visible missing-data notes.
|
|
34
|
+
- Copyable diagnostics and JSON export with the full returned record.
|
|
35
|
+
|
|
36
|
+
Runs on your computer. No database, saved lookup history, telemetry or hosted service. Live signatures go to Solana's public RPC; availability, rate limits and historical coverage vary.
|
|
37
|
+
|
|
38
|
+
The RPC provides a total fee, not an exact base/priority split. Decoding covers basic System transfer and Compute Budget instructions; other instruction data stays available without guessed meanings. This is an execution debugger, not a security verdict or a full balance-change explorer.
|
|
39
|
+
|
|
40
|
+
**[Guide](docs/guide.md)** · **[Architecture](docs/architecture.md)** · **[Local API](docs/api.md)**
|
|
41
|
+
|
|
42
|
+
Guide and architecture pages are also available inside the dashboard, offline.
|
|
43
|
+
|
|
44
|
+
## Develop
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm run check
|
|
48
|
+
npm run package:check
|
|
49
|
+
npx playwright install chromium
|
|
50
|
+
npm run test:e2e
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Tests do not depend on public RPC. To use another local port: `PORT=4452 npm run demo`.
|
|
54
|
+
|
|
55
|
+
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT](LICENSE)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
This is a local read-only debugger. No wallets or private keys are needed. Live signatures are sent to a fixed public RPC endpoint. No lookup data is written to disk automatically.
|
|
4
|
+
|
|
5
|
+
Do not bind it publicly or use it as a hosted RPC proxy. Local processes with access to your machine can call a loopback service. Host and Origin checks reduce cross-site access; they are not user authentication.
|
|
6
|
+
|
|
7
|
+
Keep Node.js and dependencies current. Avoid sharing exports containing information you did not intend to disclose, even though the transaction record is public.
|
|
8
|
+
|
|
9
|
+
For a security issue, use the repository's private vulnerability reporting feature when available. Do not include secrets in public issues. Maintainer contact: royalpinto007@gmail.com.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { analyzeTransaction } from "../domain/analyze.js";
|
|
2
|
+
import { toBase58 } from "../domain/base58.js";
|
|
3
|
+
import { SYSTEM, COMPUTE } from "../domain/instructions.js";
|
|
4
|
+
export const examples = [
|
|
5
|
+
{
|
|
6
|
+
id: "insufficient-funds",
|
|
7
|
+
title: "Insufficient funds",
|
|
8
|
+
description: "A known instruction failure, with earlier work rolled back.",
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "unknown-program",
|
|
12
|
+
title: "Unknown program error",
|
|
13
|
+
description: "A custom error and nested calls, with no invented explanation.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "successful-v0",
|
|
17
|
+
title: "Successful v0",
|
|
18
|
+
description: "A transfer using a loaded address from a lookup table.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "missing-metadata",
|
|
22
|
+
title: "Missing metadata",
|
|
23
|
+
description: "A transaction whose outcome, fee and compute are unavailable.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "v1-config",
|
|
27
|
+
title: "v1 transaction",
|
|
28
|
+
description: "Compute and priority fee settings read from the message config.",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
const payer = toBase58(new Uint8Array(32).fill(7));
|
|
32
|
+
const recipient = toBase58(new Uint8Array(32).fill(8));
|
|
33
|
+
const unknown = toBase58(new Uint8Array(32).fill(9));
|
|
34
|
+
function base() {
|
|
35
|
+
const limit = new Uint8Array([2, 64, 13, 3, 0]);
|
|
36
|
+
const transfer = new Uint8Array(12);
|
|
37
|
+
transfer[0] = 2;
|
|
38
|
+
transfer[4] = 64;
|
|
39
|
+
transfer[5] = 66;
|
|
40
|
+
transfer[6] = 15;
|
|
41
|
+
return {
|
|
42
|
+
slot: 321654987,
|
|
43
|
+
blockTime: 1760000000,
|
|
44
|
+
version: "legacy",
|
|
45
|
+
transaction: {
|
|
46
|
+
signatures: [toBase58(new Uint8Array(64).fill(11))],
|
|
47
|
+
message: {
|
|
48
|
+
header: {
|
|
49
|
+
numRequiredSignatures: 1,
|
|
50
|
+
numReadonlySignedAccounts: 0,
|
|
51
|
+
numReadonlyUnsignedAccounts: 2,
|
|
52
|
+
},
|
|
53
|
+
recentBlockhash: payer,
|
|
54
|
+
accountKeys: [payer, recipient, COMPUTE, SYSTEM],
|
|
55
|
+
instructions: [
|
|
56
|
+
{
|
|
57
|
+
programIdIndex: 2,
|
|
58
|
+
accounts: [],
|
|
59
|
+
data: toBase58(limit),
|
|
60
|
+
},
|
|
61
|
+
{ programIdIndex: 3, accounts: [0, 1], data: toBase58(transfer) },
|
|
62
|
+
{ programIdIndex: 3, accounts: [0, 1], data: toBase58(transfer) },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
meta: {
|
|
67
|
+
err: { InstructionError: [1, "InsufficientFunds"] },
|
|
68
|
+
fee: 5000,
|
|
69
|
+
computeUnitsConsumed: 18420,
|
|
70
|
+
preBalances: [25000, 0, 1, 1],
|
|
71
|
+
postBalances: [20000, 0, 1, 1],
|
|
72
|
+
loadedAddresses: { writable: [], readonly: [] },
|
|
73
|
+
innerInstructions: [],
|
|
74
|
+
logMessages: [
|
|
75
|
+
`Program ${COMPUTE} invoke [1]`,
|
|
76
|
+
`Program ${COMPUTE} success`,
|
|
77
|
+
`Program ${SYSTEM} invoke [1]`,
|
|
78
|
+
"Program log: fictional example: account lacks required funds",
|
|
79
|
+
`Program ${SYSTEM} consumed 18420 of 200000 compute units`,
|
|
80
|
+
`Program ${SYSTEM} failed: insufficient funds`,
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function exampleTransaction(id) {
|
|
86
|
+
if (!examples.some((example) => example.id === id))
|
|
87
|
+
return null;
|
|
88
|
+
const raw = base();
|
|
89
|
+
if (id === "insufficient-funds")
|
|
90
|
+
return raw;
|
|
91
|
+
if (id === "missing-metadata")
|
|
92
|
+
return { ...raw, meta: null };
|
|
93
|
+
if (id === "unknown-program") {
|
|
94
|
+
raw.transaction.message.accountKeys.push(unknown);
|
|
95
|
+
raw.transaction.message.instructions[1].programIdIndex = 4;
|
|
96
|
+
raw.meta.err = { InstructionError: [1, { Custom: 6001 }] };
|
|
97
|
+
raw.meta.logMessages = [
|
|
98
|
+
`Program ${COMPUTE} invoke [1]`,
|
|
99
|
+
`Program ${COMPUTE} success`,
|
|
100
|
+
`Program ${unknown} invoke [1]`,
|
|
101
|
+
`Program ${SYSTEM} invoke [2]`,
|
|
102
|
+
`Program ${SYSTEM} consumed 120 of 197000 compute units`,
|
|
103
|
+
`Program ${SYSTEM} failed: custom program error: 0x1771`,
|
|
104
|
+
`Program ${unknown} consumed 2440 of 200000 compute units`,
|
|
105
|
+
`Program ${unknown} failed: custom program error: 0x1771`,
|
|
106
|
+
];
|
|
107
|
+
raw.meta.computeUnitsConsumed = 2590;
|
|
108
|
+
return raw;
|
|
109
|
+
}
|
|
110
|
+
raw.meta.err = null;
|
|
111
|
+
raw.meta.computeUnitsConsumed = 450;
|
|
112
|
+
raw.transaction.message.instructions =
|
|
113
|
+
raw.transaction.message.instructions.slice(0, 2);
|
|
114
|
+
raw.meta.logMessages = [
|
|
115
|
+
`Program ${COMPUTE} invoke [1]`,
|
|
116
|
+
`Program ${COMPUTE} success`,
|
|
117
|
+
`Program ${SYSTEM} invoke [1]`,
|
|
118
|
+
`Program ${SYSTEM} success`,
|
|
119
|
+
];
|
|
120
|
+
if (id === "successful-v0") {
|
|
121
|
+
raw.version = 0;
|
|
122
|
+
raw.transaction.message.accountKeys = [payer, COMPUTE, SYSTEM];
|
|
123
|
+
raw.transaction.message.instructions[0].programIdIndex = 1;
|
|
124
|
+
raw.transaction.message.instructions[1].programIdIndex = 2;
|
|
125
|
+
raw.transaction.message.instructions[1].accounts = [0, 3];
|
|
126
|
+
raw.meta.loadedAddresses.writable = [recipient];
|
|
127
|
+
raw.meta.preBalances = [2000000, 1, 1, 0];
|
|
128
|
+
raw.meta.postBalances = [995000, 1, 1, 1000000];
|
|
129
|
+
return {
|
|
130
|
+
...raw,
|
|
131
|
+
transaction: {
|
|
132
|
+
...raw.transaction,
|
|
133
|
+
message: {
|
|
134
|
+
...raw.transaction.message,
|
|
135
|
+
addressTableLookups: [
|
|
136
|
+
{ accountKey: unknown, writableIndexes: [0], readonlyIndexes: [] },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
raw.version = 1;
|
|
143
|
+
raw.meta.fee = 6000;
|
|
144
|
+
raw.meta.preBalances = [2000000, 0, 1, 1];
|
|
145
|
+
raw.meta.postBalances = [994000, 1000000, 1, 1];
|
|
146
|
+
return {
|
|
147
|
+
...raw,
|
|
148
|
+
transaction: {
|
|
149
|
+
...raw.transaction,
|
|
150
|
+
message: {
|
|
151
|
+
...raw.transaction.message,
|
|
152
|
+
transactionConfig: {
|
|
153
|
+
computeUnitLimit: 30000,
|
|
154
|
+
priorityFee: 1000,
|
|
155
|
+
heapSize: null,
|
|
156
|
+
loadedAccountsDataSizeLimit: 200000,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function inspectExample(id) {
|
|
163
|
+
const raw = exampleTransaction(id);
|
|
164
|
+
if (raw === null)
|
|
165
|
+
return null;
|
|
166
|
+
const signature = base().transaction.signatures[0];
|
|
167
|
+
return analyzeTransaction(raw, {
|
|
168
|
+
signature,
|
|
169
|
+
network: "devnet",
|
|
170
|
+
source: "example",
|
|
171
|
+
exampleId: id,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TxrayError } from "../domain/errors.js";
|
|
3
|
+
import { analyzeTransaction } from "../domain/analyze.js";
|
|
4
|
+
import { requestSchema } from "../domain/schema.js";
|
|
5
|
+
export const ENDPOINTS = {
|
|
6
|
+
devnet: "https://api.devnet.solana.com",
|
|
7
|
+
"mainnet-beta": "https://api.mainnet-beta.solana.com",
|
|
8
|
+
};
|
|
9
|
+
export const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
export function parseRpcJson(text) {
|
|
11
|
+
return JSON.parse(text, (_key, value, context) => typeof value === "number" &&
|
|
12
|
+
!Number.isSafeInteger(value) &&
|
|
13
|
+
context?.source &&
|
|
14
|
+
/^-?[0-9]+$/.test(context.source)
|
|
15
|
+
? context.source
|
|
16
|
+
: value);
|
|
17
|
+
}
|
|
18
|
+
const envelope = z.object({
|
|
19
|
+
jsonrpc: z.literal("2.0"),
|
|
20
|
+
id: z.literal(1),
|
|
21
|
+
result: z.unknown().optional(),
|
|
22
|
+
error: z.object({ code: z.number(), message: z.string() }).optional(),
|
|
23
|
+
});
|
|
24
|
+
async function readBody(response) {
|
|
25
|
+
if (Number(response.headers.get("content-length")) > MAX_RESPONSE_BYTES) {
|
|
26
|
+
await response.body?.cancel();
|
|
27
|
+
throw new TxrayError("RESPONSE_TOO_LARGE", "RPC response exceeded the 2 MiB limit.");
|
|
28
|
+
}
|
|
29
|
+
if (!response.body)
|
|
30
|
+
throw new TxrayError("RPC_INVALID_RESPONSE", "RPC returned an empty response.");
|
|
31
|
+
const reader = response.body.getReader();
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let size = 0;
|
|
34
|
+
try {
|
|
35
|
+
while (true) {
|
|
36
|
+
const chunk = await reader.read();
|
|
37
|
+
if (chunk.done)
|
|
38
|
+
break;
|
|
39
|
+
size += chunk.value.byteLength;
|
|
40
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
41
|
+
await reader.cancel();
|
|
42
|
+
throw new TxrayError("RESPONSE_TOO_LARGE", "RPC response exceeded the 2 MiB limit.");
|
|
43
|
+
}
|
|
44
|
+
chunks.push(chunk.value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
reader.releaseLock();
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return parseRpcJson(Buffer.concat(chunks).toString("utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new TxrayError("RPC_INVALID_RESPONSE", "RPC returned unreadable JSON.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function inspectTransaction(input, options = {}) {
|
|
58
|
+
const { signature, network } = requestSchema.parse(input);
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 12000);
|
|
61
|
+
try {
|
|
62
|
+
const response = await (options.fetchImpl ?? fetch)(ENDPOINTS[network], {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
Accept: "application/json",
|
|
67
|
+
},
|
|
68
|
+
redirect: "error",
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
jsonrpc: "2.0",
|
|
72
|
+
id: 1,
|
|
73
|
+
method: "getTransaction",
|
|
74
|
+
params: [
|
|
75
|
+
signature,
|
|
76
|
+
{
|
|
77
|
+
encoding: "json",
|
|
78
|
+
commitment: "confirmed",
|
|
79
|
+
maxSupportedTransactionVersion: 1,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
if (response.status === 429) {
|
|
85
|
+
await response.body?.cancel();
|
|
86
|
+
const retry = Number(response.headers.get("retry-after"));
|
|
87
|
+
throw new TxrayError("RPC_RATE_LIMITED", "The public RPC is busy. Wait before retrying.", 429, Number.isFinite(retry) && retry > 0
|
|
88
|
+
? Math.min(3600, Math.ceil(retry))
|
|
89
|
+
: 30);
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
await response.body?.cancel();
|
|
93
|
+
throw new TxrayError("RPC_UNAVAILABLE", `Public RPC returned HTTP ${response.status}. Try again later.`, 503);
|
|
94
|
+
}
|
|
95
|
+
const parsed = envelope.safeParse(await readBody(response));
|
|
96
|
+
if (!parsed.success)
|
|
97
|
+
throw new TxrayError("RPC_INVALID_RESPONSE", "RPC response did not match the expected format.");
|
|
98
|
+
const data = parsed.data;
|
|
99
|
+
if (data.error) {
|
|
100
|
+
if (data.error.code === -32015)
|
|
101
|
+
throw new TxrayError("UNSUPPORTED_VERSION", "The RPC cannot return this transaction version. Legacy, v0 and v1 are requested.");
|
|
102
|
+
throw new TxrayError("RPC_ERROR", `The RPC could not complete the lookup (code ${data.error.code}). Try again later.`);
|
|
103
|
+
}
|
|
104
|
+
if (data.result === null)
|
|
105
|
+
throw new TxrayError("TRANSACTION_NOT_FOUND", "Transaction not available on this network at confirmed commitment. Check the network; it may be unconfirmed, pruned or not found.", 404);
|
|
106
|
+
if (data.result === undefined)
|
|
107
|
+
throw new TxrayError("RPC_INVALID_RESPONSE", "RPC did not return a transaction result.");
|
|
108
|
+
try {
|
|
109
|
+
const report = analyzeTransaction(data.result, {
|
|
110
|
+
signature,
|
|
111
|
+
network,
|
|
112
|
+
source: "live",
|
|
113
|
+
});
|
|
114
|
+
if (report.raw.transaction.signatures[0] !== signature)
|
|
115
|
+
throw new Error("Signature mismatch");
|
|
116
|
+
return report;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new TxrayError("RPC_INVALID_RESPONSE", "The RPC transaction has an unsupported or malformed structure. No diagnosis was inferred.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof TxrayError)
|
|
124
|
+
throw error;
|
|
125
|
+
if (controller.signal.aborted)
|
|
126
|
+
throw new TxrayError("RPC_TIMEOUT", "The public RPC did not respond in time. Try again.", 504);
|
|
127
|
+
throw new TxrayError("RPC_UNAVAILABLE", "Could not reach the public RPC. Check your connection and try again.", 503);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import helmet from "helmet";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { inspectTransaction } from "./adapters/rpc.js";
|
|
6
|
+
import { examples, inspectExample } from "./adapters/examples.js";
|
|
7
|
+
import { TxrayError } from "./domain/errors.js";
|
|
8
|
+
import { requestSchema } from "./domain/schema.js";
|
|
9
|
+
export function createApp(options = {}) {
|
|
10
|
+
const app = express();
|
|
11
|
+
let active = 0;
|
|
12
|
+
let count = 0;
|
|
13
|
+
let since = Date.now();
|
|
14
|
+
app.disable("x-powered-by");
|
|
15
|
+
app.use(helmet({
|
|
16
|
+
contentSecurityPolicy: {
|
|
17
|
+
directives: {
|
|
18
|
+
defaultSrc: ["'self'"],
|
|
19
|
+
scriptSrc: ["'self'"],
|
|
20
|
+
styleSrc: ["'self'"],
|
|
21
|
+
imgSrc: ["'self'"],
|
|
22
|
+
connectSrc: ["'self'"],
|
|
23
|
+
fontSrc: ["'self'"],
|
|
24
|
+
objectSrc: ["'none'"],
|
|
25
|
+
frameAncestors: ["'none'"],
|
|
26
|
+
upgradeInsecureRequests: null,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
strictTransportSecurity: false,
|
|
30
|
+
}));
|
|
31
|
+
app.use((req, res, next) => {
|
|
32
|
+
const host = req.headers.host;
|
|
33
|
+
if (!host ||
|
|
34
|
+
!/^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?$/.test(host) ||
|
|
35
|
+
(req.headers.origin && req.headers.origin !== `http://${host}`) ||
|
|
36
|
+
req.headers["sec-fetch-site"] === "cross-site")
|
|
37
|
+
return next(new TxrayError("FORBIDDEN", "Open txray through its local address.", 403));
|
|
38
|
+
res.setHeader("Cache-Control", "no-store");
|
|
39
|
+
next();
|
|
40
|
+
});
|
|
41
|
+
app.use("/api", (req, _res, next) => {
|
|
42
|
+
if (req.method === "POST" && !req.is("application/json"))
|
|
43
|
+
return next(new TxrayError("UNSUPPORTED_MEDIA_TYPE", "Send application/json.", 415));
|
|
44
|
+
next();
|
|
45
|
+
});
|
|
46
|
+
app.use(express.json({ limit: "16kb", strict: true }));
|
|
47
|
+
app.get("/api/health", (_req, res) => res.json({ ok: true, app: "txray", readOnly: true }));
|
|
48
|
+
app.get("/api/examples", (_req, res) => res.json(examples));
|
|
49
|
+
app.get("/api/examples/:id", (req, res, next) => {
|
|
50
|
+
const report = inspectExample(req.params.id);
|
|
51
|
+
if (!report)
|
|
52
|
+
return next(new TxrayError("EXAMPLE_NOT_FOUND", "Example not found.", 404));
|
|
53
|
+
res.json(report);
|
|
54
|
+
});
|
|
55
|
+
app.post("/api/transactions", async (req, res, next) => {
|
|
56
|
+
try {
|
|
57
|
+
const input = requestSchema.parse(req.body);
|
|
58
|
+
if (active >= (options.maxConcurrent ?? 4))
|
|
59
|
+
throw new TxrayError("LOOKUP_BUSY", "Another lookup is in progress. Try again shortly.", 429, 2);
|
|
60
|
+
if (Date.now() - since >= 60000) {
|
|
61
|
+
since = Date.now();
|
|
62
|
+
count = 0;
|
|
63
|
+
}
|
|
64
|
+
if (count >= 30)
|
|
65
|
+
throw new TxrayError("LOCAL_RATE_LIMIT", "Limit of 30 live lookups per minute reached. Wait before retrying.", 429, 60);
|
|
66
|
+
active++;
|
|
67
|
+
count++;
|
|
68
|
+
try {
|
|
69
|
+
res.json(await inspectTransaction(input, options));
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
active--;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
next(error);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const publicDir = fileURLToPath(new URL("../dist/public/", import.meta.url));
|
|
80
|
+
app.get("/guide", (_req, res) => res.sendFile("guide.html", { root: publicDir }));
|
|
81
|
+
app.get("/architecture", (_req, res) => res.sendFile("architecture.html", { root: publicDir }));
|
|
82
|
+
app.use(express.static(publicDir, { index: "index.html", etag: false }));
|
|
83
|
+
app.use((_req, _res, next) => next(new TxrayError("NOT_FOUND", "Page or API route not found.", 404)));
|
|
84
|
+
const errors = (error, _req, res, _next) => {
|
|
85
|
+
let failure;
|
|
86
|
+
if (error instanceof TxrayError)
|
|
87
|
+
failure = error;
|
|
88
|
+
else if (error instanceof z.ZodError)
|
|
89
|
+
failure = new TxrayError("INVALID_INPUT", "Enter a valid transaction signature and choose Mainnet or Devnet.", 400);
|
|
90
|
+
else if (typeof error === "object" &&
|
|
91
|
+
error !== null &&
|
|
92
|
+
"type" in error &&
|
|
93
|
+
error.type === "entity.too.large")
|
|
94
|
+
failure = new TxrayError("BODY_TOO_LARGE", "Request body exceeds 16 KiB.", 413);
|
|
95
|
+
else if (error instanceof SyntaxError)
|
|
96
|
+
failure = new TxrayError("INVALID_JSON", "Request body is not valid JSON.", 400);
|
|
97
|
+
else
|
|
98
|
+
failure = new TxrayError("INTERNAL_ERROR", "txray could not complete this request.", 500);
|
|
99
|
+
if (failure.retryAfter)
|
|
100
|
+
res.setHeader("Retry-After", String(failure.retryAfter));
|
|
101
|
+
res.status(failure.status).json({
|
|
102
|
+
error: {
|
|
103
|
+
code: failure.code,
|
|
104
|
+
message: failure.message,
|
|
105
|
+
...(failure.retryAfter ? { retryAfter: failure.retryAfter } : {}),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
app.use(errors);
|
|
110
|
+
return app;
|
|
111
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
try {
|
|
5
|
+
const { values } = parseArgs({
|
|
6
|
+
options: {
|
|
7
|
+
help: { type: "boolean", short: "h" },
|
|
8
|
+
version: { type: "boolean", short: "v" },
|
|
9
|
+
port: { type: "string" },
|
|
10
|
+
},
|
|
11
|
+
allowPositionals: false,
|
|
12
|
+
});
|
|
13
|
+
if (values.help) {
|
|
14
|
+
console.log(`txray | Solana Transaction Autopsy
|
|
15
|
+
|
|
16
|
+
Usage: txray [--port 4450]
|
|
17
|
+
|
|
18
|
+
--port Local dashboard port (default: PORT or 4450)
|
|
19
|
+
-h, --help Show this help
|
|
20
|
+
-v, --version Show the package version
|
|
21
|
+
|
|
22
|
+
Open the printed localhost URL. Press Ctrl+C to stop.
|
|
23
|
+
Read-only; no wallet, keys or funds required.`);
|
|
24
|
+
}
|
|
25
|
+
else if (values.version) {
|
|
26
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
27
|
+
console.log(manifest.version);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
if (values.port !== undefined)
|
|
31
|
+
process.env.PORT = values.port;
|
|
32
|
+
await import("./server.js");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error(`txray: ${error instanceof Error ? error.message : "Could not start. Run txray --help."}`);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
}
|