@effectstream/sync 0.102.0 → 0.103.1
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/package.json +5 -5
- package/src/sync-protocols/midnight/MidnightClient.ts +11 -3
- package/src/sync-protocols/midnight/fetcher.ts +16 -11
- package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
- package/src/sync-protocols/solana/SolanaClient.ts +30 -0
- package/src/sync-protocols/solana/fetcher.ts +155 -57
- package/src/sync-protocols/solana/read-primitives.test.ts +250 -3
- package/test/zswap-decoder.test.ts +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.103.1",
|
|
4
4
|
"license": "(MIT OR Apache-2.0)",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": "./src/mod.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@effectstream/config": "0.
|
|
17
|
-
"@effectstream/db": "0.
|
|
18
|
-
"@effectstream/log": "0.
|
|
19
|
-
"@effectstream/utils": "0.
|
|
16
|
+
"@effectstream/config": "0.103.1",
|
|
17
|
+
"@effectstream/db": "0.103.1",
|
|
18
|
+
"@effectstream/log": "0.103.1",
|
|
19
|
+
"@effectstream/utils": "0.103.1",
|
|
20
20
|
"@midnight-ntwrk/ledger-v8": "8.1.0",
|
|
21
21
|
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
|
|
22
22
|
"@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
|
|
@@ -30,11 +30,19 @@ export interface MidnightGqlBlockState {
|
|
|
30
30
|
intentHash: string;
|
|
31
31
|
outputIndex: number;
|
|
32
32
|
owner: string;
|
|
33
|
+
/** u128 value as a decimal string */
|
|
34
|
+
value: string;
|
|
35
|
+
/** hex-encoded serialized token type */
|
|
36
|
+
tokenType: string;
|
|
33
37
|
}[];
|
|
34
38
|
unshieldedCreatedOutputs?: {
|
|
35
39
|
intentHash: string;
|
|
36
40
|
outputIndex: number;
|
|
37
41
|
owner: string;
|
|
42
|
+
/** u128 value as a decimal string */
|
|
43
|
+
value: string;
|
|
44
|
+
/** hex-encoded serialized token type */
|
|
45
|
+
tokenType: string;
|
|
38
46
|
}[];
|
|
39
47
|
// Present only on RegularTransaction (selected via inline fragment): the
|
|
40
48
|
// coin-commitment Merkle tree root after this transaction.
|
|
@@ -54,7 +62,7 @@ export interface MidnightGqlBlockState {
|
|
|
54
62
|
export interface BlockFetchOptions {
|
|
55
63
|
/** Include contractActions in the transaction fields (needed for MidnightGenericPrimitive). Default: true */
|
|
56
64
|
contractActions?: boolean;
|
|
57
|
-
/** Include zswapLedgerEvents in the transaction fields (needed for
|
|
65
|
+
/** Include zswapLedgerEvents in the transaction fields (needed for MidnightNullifierAndCommitmentPrimitive). Default: true */
|
|
58
66
|
zswapLedgerEvents?: boolean;
|
|
59
67
|
/** Include unshieldedSpentOutputs (needed for MidnightUnshieldedSpendPrimitive). Default: false */
|
|
60
68
|
unshieldedSpentOutputs?: boolean;
|
|
@@ -150,10 +158,10 @@ export class MidnightClient {
|
|
|
150
158
|
? `zswapLedgerEvents { id raw maxId }`
|
|
151
159
|
: "";
|
|
152
160
|
const unshieldedSpentField = unshieldedSpentOutputs
|
|
153
|
-
? `unshieldedSpentOutputs { intentHash outputIndex owner }`
|
|
161
|
+
? `unshieldedSpentOutputs { intentHash outputIndex owner value tokenType }`
|
|
154
162
|
: "";
|
|
155
163
|
const unshieldedCreatedField = unshieldedCreatedOutputs
|
|
156
|
-
? `unshieldedCreatedOutputs { intentHash outputIndex owner }`
|
|
164
|
+
? `unshieldedCreatedOutputs { intentHash outputIndex owner value tokenType }`
|
|
157
165
|
: "";
|
|
158
166
|
// zswapMerkleTreeRoot lives on RegularTransaction, not the Transaction
|
|
159
167
|
// interface, so it must be selected through an inline fragment.
|
|
@@ -19,7 +19,7 @@ import type { RootOutput, RootPage } from "../types.ts";
|
|
|
19
19
|
import { bound } from "@effectstream/utils";
|
|
20
20
|
import { MidnightClient, type BlockFetchOptions, type MidnightGqlBlockState } from "./MidnightClient.ts";
|
|
21
21
|
import { ContractState, StateValue } from "@midnight-ntwrk/onchain-runtime";
|
|
22
|
-
import {
|
|
22
|
+
import { decodeZswapEvent } from "./zswap-decoder.ts";
|
|
23
23
|
import { decodeTokenMints } from "./mint-decoder.ts";
|
|
24
24
|
|
|
25
25
|
export class MidnightFetcher extends BaseDataFetcher<
|
|
@@ -78,14 +78,14 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
78
78
|
const blockFetchOptions: BlockFetchOptions = {
|
|
79
79
|
contractActions: this.config.primitives.some(
|
|
80
80
|
(p) =>
|
|
81
|
-
p.primitive.type !== "Midnight:
|
|
81
|
+
p.primitive.type !== "Midnight:NullifierAndCommitment" &&
|
|
82
82
|
p.primitive.type !== "Midnight:UnshieldedSpend" &&
|
|
83
83
|
p.primitive.type !== "Midnight:UnshieldedCreate" &&
|
|
84
84
|
p.primitive.type !== "Midnight:ZswapRoot" &&
|
|
85
85
|
p.primitive.type !== "Midnight:TokenMint",
|
|
86
86
|
),
|
|
87
87
|
zswapLedgerEvents: this.config.primitives.some(
|
|
88
|
-
(p) => p.primitive.type === "Midnight:
|
|
88
|
+
(p) => p.primitive.type === "Midnight:NullifierAndCommitment",
|
|
89
89
|
),
|
|
90
90
|
unshieldedSpentOutputs: this.config.primitives.some(
|
|
91
91
|
(p) => p.primitive.type === "Midnight:UnshieldedSpend",
|
|
@@ -183,8 +183,8 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
183
183
|
const syncResults: PrimitiveType[] = [];
|
|
184
184
|
|
|
185
185
|
for (const primitiveEntry of primitiveEntries) {
|
|
186
|
-
if (primitiveEntry.primitive.type === "Midnight:
|
|
187
|
-
syncResults.push(...this.
|
|
186
|
+
if (primitiveEntry.primitive.type === "Midnight:NullifierAndCommitment") {
|
|
187
|
+
syncResults.push(...this.fetchZswapEvents(height, primitiveEntry, block));
|
|
188
188
|
} else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
|
|
189
189
|
syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
|
|
190
190
|
} else if (primitiveEntry.primitive.type === "Midnight:UnshieldedCreate") {
|
|
@@ -205,16 +205,19 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
@bound
|
|
208
|
-
|
|
208
|
+
fetchZswapEvents(
|
|
209
209
|
height: number,
|
|
210
210
|
primitiveEntry: PrimitiveEntryType,
|
|
211
211
|
block: MidnightGqlBlockState,
|
|
212
212
|
): PrimitiveType[] {
|
|
213
|
+
const capture = primitiveEntry.primitive.capture ?? "both";
|
|
213
214
|
const results: PrimitiveType[] = [];
|
|
214
215
|
for (const tx of block.block.transactions) {
|
|
215
216
|
for (const event of tx.zswapLedgerEvents ?? []) {
|
|
216
|
-
const decoded =
|
|
217
|
+
const decoded = decodeZswapEvent(event.raw);
|
|
217
218
|
if (!decoded) continue;
|
|
219
|
+
if (decoded.kind === "nullifier" && capture === "commitments") continue;
|
|
220
|
+
if (decoded.kind === "commitment" && capture === "nullifiers") continue;
|
|
218
221
|
results.push({
|
|
219
222
|
syncProtocol: {
|
|
220
223
|
name: primitiveEntry.syncProtocol,
|
|
@@ -224,12 +227,10 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
224
227
|
},
|
|
225
228
|
primitive: primitiveEntry.primitive.name,
|
|
226
229
|
output: {
|
|
227
|
-
payloadType:
|
|
230
|
+
payloadType: `midnight-${decoded.kind}`,
|
|
228
231
|
payload: {
|
|
229
|
-
|
|
230
|
-
txHash: decoded.txHash,
|
|
232
|
+
...decoded,
|
|
231
233
|
eventId: event.id,
|
|
232
|
-
logicalSegment: decoded.logicalSegment,
|
|
233
234
|
},
|
|
234
235
|
},
|
|
235
236
|
});
|
|
@@ -261,6 +262,8 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
261
262
|
owner: spend.owner,
|
|
262
263
|
intentHash: spend.intentHash,
|
|
263
264
|
outputIndex: spend.outputIndex,
|
|
265
|
+
value: spend.value,
|
|
266
|
+
tokenType: spend.tokenType,
|
|
264
267
|
txHash: tx.hash,
|
|
265
268
|
},
|
|
266
269
|
},
|
|
@@ -296,6 +299,8 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
296
299
|
owner: created.owner,
|
|
297
300
|
intentHash: created.intentHash,
|
|
298
301
|
outputIndex: created.outputIndex,
|
|
302
|
+
value: created.value,
|
|
303
|
+
tokenType: created.tokenType,
|
|
299
304
|
txHash: tx.hash,
|
|
300
305
|
},
|
|
301
306
|
},
|
|
@@ -1,58 +1,26 @@
|
|
|
1
|
-
import { Buffer } from "node:buffer";
|
|
2
1
|
import * as ledger from '@midnight-ntwrk/ledger-v8';
|
|
3
2
|
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
try {
|
|
7
|
-
const bytes = Uint8Array.from(
|
|
8
|
-
Buffer.from(rawHex.replace(/^0x/, ""), "hex"),
|
|
9
|
-
);
|
|
10
|
-
const event = ledger.Event.deserialize(bytes);
|
|
11
|
-
console.log("event", event);
|
|
12
|
-
// if (event.type === "ZswapInput") {
|
|
13
|
-
// event.nullifier — Uint8Array (32 bytes)
|
|
14
|
-
// event.source.transactionHash
|
|
15
|
-
// event.source.logicalSegment
|
|
16
|
-
// } else if (event.type === "ZswapOutput") {
|
|
17
|
-
// event.commitment, etc.
|
|
18
|
-
// }
|
|
19
|
-
} catch {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ─── SCALE compact integer decoder ───────────────────────────────────────────
|
|
3
|
+
// Wire format of a raw zswap ledger event, for reference (midnight-ledger 8.x,
|
|
4
|
+
// `midnight-ledger/ledger/src/events.rs` + `storage-core/src/arena.rs`):
|
|
26
5
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
6
|
+
// 19 bytes outer tag "midnight:event[v9]:"
|
|
7
|
+
// then a TopoSortedNodes envelope (NOT the struct directly):
|
|
8
|
+
// compact_u32 node count, and per node: compact_u32 child count,
|
|
9
|
+
// compact_u64 child indices, compact_u32 data length, data bytes.
|
|
10
|
+
// The root node's data is:
|
|
11
|
+
// 32 bytes EventSource.transaction_hash
|
|
12
|
+
// 2 bytes logical_segment (LE u16)
|
|
13
|
+
// 2 bytes physical_segment (LE u16)
|
|
14
|
+
// 1 byte EventDetails variant: 0=ZswapInput, 1=ZswapOutput, 2+=other
|
|
15
|
+
// ZswapInput: 32-byte nullifier + Option<Sp<ContractAddress>> byte
|
|
16
|
+
// ZswapOutput: 32-byte commitment + variable-length preimage evidence
|
|
17
|
+
// + Option<Sp<ContractAddress>> byte + compact_u64 mt_index
|
|
18
|
+
// A Some(..) contract address is a SEPARATE node in the envelope, shifting
|
|
19
|
+
// all offsets — so positional parsing is not viable; always deserialize via
|
|
20
|
+
// the ledger bindings.
|
|
32
21
|
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
const mode = first & 0b11;
|
|
36
|
-
if (mode === 0b00) {
|
|
37
|
-
return [(first & 0xFC) >> 2, pos + 1];
|
|
38
|
-
} else if (mode === 0b01) {
|
|
39
|
-
const second = b[pos + 1];
|
|
40
|
-
const v0 = ((first & 0xFC) >> 2) | ((second & 0x03) << 6);
|
|
41
|
-
const v1 = (second & 0xFC) >> 2;
|
|
42
|
-
return [(v0 | (v1 << 8)) >>> 0, pos + 2];
|
|
43
|
-
} else if (mode === 0b10) {
|
|
44
|
-
const s = b[pos + 1], t = b[pos + 2], u = b[pos + 3];
|
|
45
|
-
const v0 = ((first & 0xFC) >> 2) | ((s & 0x03) << 6);
|
|
46
|
-
const v1 = ((s & 0xFC) >> 2) | ((t & 0x03) << 6);
|
|
47
|
-
const v2 = ((t & 0xFC) >> 2) | ((u & 0x03) << 6);
|
|
48
|
-
const v3 = (u & 0xFC) >> 2;
|
|
49
|
-
return [(v0 | (v1 << 8) | (v2 << 16) | (v3 << 24)) >>> 0, pos + 4];
|
|
50
|
-
} else {
|
|
51
|
-
const n = ((first & 0xFC) >> 2) + 4;
|
|
52
|
-
let val = 0;
|
|
53
|
-
for (let i = 0; i < n; i++) val |= b[pos + 1 + i] << (i * 8);
|
|
54
|
-
return [val >>> 0, pos + 1 + n];
|
|
55
|
-
}
|
|
22
|
+
function normalizeHex(hex: string): string {
|
|
23
|
+
return (hex.startsWith("0x") ? hex.slice(2) : hex).toLowerCase();
|
|
56
24
|
}
|
|
57
25
|
|
|
58
26
|
function hexToBytes(hex: string): Uint8Array {
|
|
@@ -64,60 +32,90 @@ function hexToBytes(hex: string): Uint8Array {
|
|
|
64
32
|
return arr;
|
|
65
33
|
}
|
|
66
34
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
35
|
+
/** A zswap ledger event decoded via the native ledger-v8 bindings. */
|
|
36
|
+
export type DecodedZswapEvent =
|
|
37
|
+
| {
|
|
38
|
+
kind: "nullifier";
|
|
39
|
+
/** 32-byte nullifier, hex */
|
|
40
|
+
nullifier: string;
|
|
41
|
+
/** 32-byte transaction hash, hex */
|
|
42
|
+
txHash: string;
|
|
43
|
+
/** Logical segment index */
|
|
44
|
+
logicalSegment: number;
|
|
45
|
+
/** Contract address that spent the coin, hex — absent for user coins */
|
|
46
|
+
contract?: string;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
kind: "commitment";
|
|
50
|
+
/** 32-byte coin commitment, hex */
|
|
51
|
+
commitment: string;
|
|
52
|
+
/** Index of the commitment in the zswap Merkle tree, u64 as decimal string */
|
|
53
|
+
mtIndex: string;
|
|
54
|
+
/** 32-byte transaction hash, hex */
|
|
55
|
+
txHash: string;
|
|
56
|
+
/** Logical segment index */
|
|
57
|
+
logicalSegment: number;
|
|
58
|
+
/** Contract address that received the coin, hex — absent for user coins */
|
|
59
|
+
contract?: string;
|
|
60
|
+
};
|
|
90
61
|
|
|
91
62
|
/**
|
|
92
|
-
* Decode a raw
|
|
93
|
-
*
|
|
94
|
-
*
|
|
63
|
+
* Decode a raw zswap ledger event (ZswapInput or ZswapOutput) using the
|
|
64
|
+
* native ledger-v8 WASM deserializer. The indexer's `zswapLedgerEvents`
|
|
65
|
+
* field only ever carries these two variants, so a `null` return means the
|
|
66
|
+
* event could not be decoded (e.g. a ledger event-format version bump) and
|
|
67
|
+
* is logged.
|
|
95
68
|
*/
|
|
96
|
-
export function
|
|
97
|
-
|
|
98
|
-
): DecodedZswapInput | null {
|
|
69
|
+
export function decodeZswapEvent(rawHex: string): DecodedZswapEvent | null {
|
|
70
|
+
let event: ledger.Event;
|
|
99
71
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
pos += 2;
|
|
107
|
-
pos += 2; // skip physical segment
|
|
108
|
-
|
|
109
|
-
const [variant, p1] = readScaleU32(b, pos);
|
|
110
|
-
pos = p1;
|
|
111
|
-
|
|
112
|
-
if (variant !== 0) {
|
|
113
|
-
return null; // ZswapOutput or other — not a nullifier event
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
pos += 4; // skip 4-byte contract_id
|
|
117
|
-
|
|
118
|
-
const nullifier = bytesToHex(b.slice(pos, pos + 32));
|
|
119
|
-
return { txHash, logicalSegment, nullifier };
|
|
120
|
-
} catch {
|
|
72
|
+
event = ledger.Event.deserialize(hexToBytes(rawHex));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.warn(
|
|
75
|
+
"[midnight] failed to deserialize zswap ledger event — ledger event format may have changed",
|
|
76
|
+
{ raw: rawHex, error: String(err) },
|
|
77
|
+
);
|
|
121
78
|
return null;
|
|
122
79
|
}
|
|
80
|
+
// EventDetails is an open union ({ tag: string } catch-all), so narrow manually.
|
|
81
|
+
const content = event.content as {
|
|
82
|
+
tag: string;
|
|
83
|
+
nullifier?: string;
|
|
84
|
+
commitment?: string;
|
|
85
|
+
contract?: string;
|
|
86
|
+
mtIndex?: bigint;
|
|
87
|
+
};
|
|
88
|
+
const source = {
|
|
89
|
+
txHash: normalizeHex(event.source.transactionHash),
|
|
90
|
+
logicalSegment: event.source.logicalSegment,
|
|
91
|
+
};
|
|
92
|
+
const contract = content.contract != null
|
|
93
|
+
? { contract: normalizeHex(content.contract) }
|
|
94
|
+
: {};
|
|
95
|
+
if (content.tag === "zswapInput" && content.nullifier != null) {
|
|
96
|
+
return {
|
|
97
|
+
kind: "nullifier",
|
|
98
|
+
nullifier: normalizeHex(content.nullifier),
|
|
99
|
+
...contract,
|
|
100
|
+
...source,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
content.tag === "zswapOutput" &&
|
|
105
|
+
content.commitment != null &&
|
|
106
|
+
content.mtIndex != null
|
|
107
|
+
) {
|
|
108
|
+
return {
|
|
109
|
+
kind: "commitment",
|
|
110
|
+
commitment: normalizeHex(content.commitment),
|
|
111
|
+
mtIndex: content.mtIndex.toString(),
|
|
112
|
+
...contract,
|
|
113
|
+
...source,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
console.warn(
|
|
117
|
+
"[midnight] unexpected zswap ledger event variant",
|
|
118
|
+
{ raw: rawHex, tag: content.tag },
|
|
119
|
+
);
|
|
120
|
+
return null;
|
|
123
121
|
}
|
|
@@ -43,9 +43,39 @@ export type SolanaTransaction = {
|
|
|
43
43
|
writable: string[];
|
|
44
44
|
readonly: string[];
|
|
45
45
|
} | null;
|
|
46
|
+
/**
|
|
47
|
+
* SPL token balances after the transaction, one record per (token account,
|
|
48
|
+
* mint) pair it touched. Returned by `getBlock` with
|
|
49
|
+
* `transactionDetails: "full"`; absent on transactions that touched no token
|
|
50
|
+
* account, and on validators old enough not to report them.
|
|
51
|
+
*
|
|
52
|
+
* `accountIndex` indexes the SAME resolved list as `pre`/`postBalances` — see
|
|
53
|
+
* {@link resolveAccountKeys} — so a token account reached through a lookup
|
|
54
|
+
* table is only findable after resolution.
|
|
55
|
+
*/
|
|
56
|
+
postTokenBalances?: SolanaTokenBalance[] | null;
|
|
57
|
+
/** Pre-state counterpart of {@link postTokenBalances}. */
|
|
58
|
+
preTokenBalances?: SolanaTokenBalance[] | null;
|
|
46
59
|
} | null;
|
|
47
60
|
};
|
|
48
61
|
|
|
62
|
+
export type SolanaTokenBalance = {
|
|
63
|
+
/** Index into the resolved account list, NOT into `message.accountKeys` alone. */
|
|
64
|
+
accountIndex: number;
|
|
65
|
+
mint: string;
|
|
66
|
+
/** Optional: older validators omit it. */
|
|
67
|
+
owner?: string;
|
|
68
|
+
/** The owning token program, i.e. SPL Token or Token-2022. Optional for the same reason. */
|
|
69
|
+
programId?: string;
|
|
70
|
+
uiTokenAmount: {
|
|
71
|
+
/** Raw u64 in base units, as a string. */
|
|
72
|
+
amount: string;
|
|
73
|
+
decimals: number;
|
|
74
|
+
uiAmount: number | null;
|
|
75
|
+
uiAmountString?: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
49
79
|
/**
|
|
50
80
|
* The account list `pre`/`postBalances` are indexed against: static message
|
|
51
81
|
* keys first, then lookup-table writable addresses, then lookup-table readonly
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ConfigSyncProtocolType,
|
|
3
3
|
type PrimitiveEntry,
|
|
4
|
+
SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
|
|
5
|
+
SOLANA_PRIMITIVE_PROGRAM_LOG,
|
|
6
|
+
SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
|
|
4
7
|
} from "@effectstream/config";
|
|
5
8
|
import { BaseDataFetcher } from "../base/fetcher.ts";
|
|
6
9
|
import type { DataFetched } from "../base/fetcher.ts";
|
|
@@ -17,7 +20,11 @@ import type {
|
|
|
17
20
|
Page,
|
|
18
21
|
PrimitiveType,
|
|
19
22
|
} from "./types.ts";
|
|
20
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
resolveAccountKeys,
|
|
25
|
+
SolanaClient,
|
|
26
|
+
type SolanaTokenBalance,
|
|
27
|
+
} from "./SolanaClient.ts";
|
|
21
28
|
import { requestTimeoutOf } from "../common/http.ts";
|
|
22
29
|
import { extractProgramLogs } from "./program-logs.ts";
|
|
23
30
|
import { call, sleep, type Operation } from "effection";
|
|
@@ -48,6 +55,19 @@ export class SolanaFetcher extends BaseDataFetcher<
|
|
|
48
55
|
*/
|
|
49
56
|
private lastKnownBlockTime: number | undefined;
|
|
50
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Messages already logged by `warnOnce`. `readPrimitives` runs per transaction,
|
|
60
|
+
* so a misconfigured primitive would otherwise emit an identical line thousands
|
|
61
|
+
* of times during catch-up and bury everything else.
|
|
62
|
+
*/
|
|
63
|
+
private readonly warnedOnce = new Set<string>();
|
|
64
|
+
|
|
65
|
+
private warnOnce(message: string): void {
|
|
66
|
+
if (this.warnedOnce.has(message)) return;
|
|
67
|
+
this.warnedOnce.add(message);
|
|
68
|
+
console.warn(message);
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
constructor(
|
|
52
72
|
readonly config: ConfigType,
|
|
53
73
|
) {
|
|
@@ -252,6 +272,7 @@ export class SolanaFetcher extends BaseDataFetcher<
|
|
|
252
272
|
writable: string[];
|
|
253
273
|
readonly: string[];
|
|
254
274
|
} | null;
|
|
275
|
+
postTokenBalances?: SolanaTokenBalance[] | null;
|
|
255
276
|
} | null;
|
|
256
277
|
}[];
|
|
257
278
|
},
|
|
@@ -290,65 +311,142 @@ export class SolanaFetcher extends BaseDataFetcher<
|
|
|
290
311
|
for (const entry of primitiveEntries) {
|
|
291
312
|
const prim = entry.primitive;
|
|
292
313
|
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
slot,
|
|
314
|
+
// Dispatch on `prim.type`, NOT on which optional fields happen to be set.
|
|
315
|
+
// `SolanaPrimitive` is a flat bag of optionals, so field-truthy dispatch
|
|
316
|
+
// fails two ways once a third primitive exists: a TokenAccount entry
|
|
317
|
+
// matches no field branch and silently emits nothing, and any field it
|
|
318
|
+
// shares with an earlier branch (`address` being the natural name for a
|
|
319
|
+
// token account) routes it into that branch instead. Neither is a type
|
|
320
|
+
// error. `type` is always populated at runtime because
|
|
321
|
+
// `Primitive.getConfig()` sets it and runtime/src/main.ts replaces the
|
|
322
|
+
// config entry with its output.
|
|
323
|
+
switch (prim.type) {
|
|
324
|
+
// ── SOLANA:AccountBalance — watch an address's lamport balance ──
|
|
325
|
+
case SOLANA_PRIMITIVE_ACCOUNT_BALANCE: {
|
|
326
|
+
if (!prim.address) continue;
|
|
327
|
+
const idx = accountKeys.indexOf(prim.address);
|
|
328
|
+
if (idx === -1) continue;
|
|
329
|
+
allPrimitives.push({
|
|
330
|
+
syncProtocol: {
|
|
331
|
+
name: entry.syncProtocol,
|
|
332
|
+
blockNumber: slot,
|
|
333
|
+
transactionHash: txHash,
|
|
334
|
+
contractAddress: prim.address,
|
|
335
|
+
logIndex: txIndex,
|
|
312
336
|
},
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const programLogs = extractProgramLogs(logs, prim.programId);
|
|
325
|
-
if (programLogs == null) continue;
|
|
326
|
-
// Filter by eventType if specified — against this program's own lines
|
|
327
|
-
// only, so another program can't trigger it by echoing the string.
|
|
328
|
-
if (prim.eventType) {
|
|
329
|
-
const hasMatchingLog = programLogs.some((log) =>
|
|
330
|
-
log.includes(prim.eventType!)
|
|
331
|
-
);
|
|
332
|
-
if (!hasMatchingLog) continue;
|
|
337
|
+
primitive: prim.name,
|
|
338
|
+
output: {
|
|
339
|
+
payloadType: "solana:balance",
|
|
340
|
+
payload: {
|
|
341
|
+
address: prim.address,
|
|
342
|
+
lamports: postBalances[idx] ?? 0,
|
|
343
|
+
slot,
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
333
348
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
+
|
|
350
|
+
// ── SOLANA:ProgramLog — logs the watched programId actually emitted ──
|
|
351
|
+
case SOLANA_PRIMITIVE_PROGRAM_LOG: {
|
|
352
|
+
if (!prim.programId) continue;
|
|
353
|
+
// Source of truth is the log stream's invoke/success framing, NOT
|
|
354
|
+
// accountKeys: naming a program as an account doesn't invoke it, and
|
|
355
|
+
// a program reached through a lookup table isn't in accountKeys at
|
|
356
|
+
// all. See program-logs.ts.
|
|
357
|
+
const programLogs = extractProgramLogs(logs, prim.programId);
|
|
358
|
+
if (programLogs == null) continue;
|
|
359
|
+
// Filter by eventType if specified — against this program's own lines
|
|
360
|
+
// only, so another program can't trigger it by echoing the string.
|
|
361
|
+
if (prim.eventType) {
|
|
362
|
+
const hasMatchingLog = programLogs.some((log) =>
|
|
363
|
+
log.includes(prim.eventType!)
|
|
364
|
+
);
|
|
365
|
+
if (!hasMatchingLog) continue;
|
|
366
|
+
}
|
|
367
|
+
allPrimitives.push({
|
|
368
|
+
syncProtocol: {
|
|
369
|
+
name: entry.syncProtocol,
|
|
370
|
+
blockNumber: slot,
|
|
371
|
+
transactionHash: txHash,
|
|
372
|
+
contractAddress: prim.programId,
|
|
373
|
+
logIndex: txIndex,
|
|
349
374
|
},
|
|
350
|
-
|
|
351
|
-
|
|
375
|
+
primitive: prim.name,
|
|
376
|
+
output: {
|
|
377
|
+
payloadType: "solana:transaction",
|
|
378
|
+
payload: {
|
|
379
|
+
programId: prim.programId,
|
|
380
|
+
slot,
|
|
381
|
+
logMessages: programLogs,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── SOLANA:TokenAccount — SPL balance of a watched token account ──
|
|
389
|
+
case SOLANA_PRIMITIVE_TOKEN_ACCOUNT: {
|
|
390
|
+
// An entry with no filter would match every token balance on chain.
|
|
391
|
+
// The primitive constructor rejects that, so reaching here means a
|
|
392
|
+
// hand-built config bypassed it.
|
|
393
|
+
if (!prim.mint && !prim.owner && !prim.tokenAccount) {
|
|
394
|
+
this.warnOnce(
|
|
395
|
+
`[Solana] primitive "${prim.name}" is ${SOLANA_PRIMITIVE_TOKEN_ACCOUNT} with no ` +
|
|
396
|
+
`mint, owner or tokenAccount — it would match every token balance, so it is skipped.`,
|
|
397
|
+
);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
for (const bal of tx.meta.postTokenBalances ?? []) {
|
|
401
|
+
if (prim.mint && bal.mint !== prim.mint) continue;
|
|
402
|
+
if (prim.owner && bal.owner !== prim.owner) continue;
|
|
403
|
+
if (prim.tokenProgramId && bal.programId !== prim.tokenProgramId) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// `accountIndex` indexes the RESOLVED list, same as postBalances, so
|
|
407
|
+
// a token account pulled in via a lookup table is only findable here
|
|
408
|
+
// (security fix B3 applied to token balances).
|
|
409
|
+
const tokenAccount = accountKeys[bal.accountIndex];
|
|
410
|
+
if (tokenAccount == null) continue;
|
|
411
|
+
if (prim.tokenAccount && tokenAccount !== prim.tokenAccount) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
allPrimitives.push({
|
|
415
|
+
syncProtocol: {
|
|
416
|
+
name: entry.syncProtocol,
|
|
417
|
+
blockNumber: slot,
|
|
418
|
+
transactionHash: txHash,
|
|
419
|
+
contractAddress: bal.mint,
|
|
420
|
+
logIndex: txIndex,
|
|
421
|
+
},
|
|
422
|
+
primitive: prim.name,
|
|
423
|
+
output: {
|
|
424
|
+
payloadType: "solana:token-balance",
|
|
425
|
+
payload: {
|
|
426
|
+
tokenAccount,
|
|
427
|
+
mint: bal.mint,
|
|
428
|
+
owner: bal.owner ?? "",
|
|
429
|
+
amount: bal.uiTokenAmount.amount,
|
|
430
|
+
decimals: bal.uiTokenAmount.decimals,
|
|
431
|
+
slot,
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
default:
|
|
440
|
+
// A Solana sync protocol carrying a primitive type this fetcher does
|
|
441
|
+
// not implement produced nothing and said nothing before. Warn once
|
|
442
|
+
// per type rather than per transaction, which would be thousands of
|
|
443
|
+
// identical lines during catch-up.
|
|
444
|
+
this.warnOnce(
|
|
445
|
+
`[Solana] primitive "${prim.name}" has unsupported type "${prim.type}" — ignored. ` +
|
|
446
|
+
`Supported: ${SOLANA_PRIMITIVE_ACCOUNT_BALANCE}, ${SOLANA_PRIMITIVE_PROGRAM_LOG}, ` +
|
|
447
|
+
`${SOLANA_PRIMITIVE_TOKEN_ACCOUNT}.`,
|
|
448
|
+
);
|
|
449
|
+
continue;
|
|
352
450
|
}
|
|
353
451
|
}
|
|
354
452
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
|
|
4
|
+
SOLANA_PRIMITIVE_PROGRAM_LOG,
|
|
5
|
+
SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
|
|
6
|
+
} from "@effectstream/config";
|
|
2
7
|
import { SolanaFetcher } from "./fetcher.ts";
|
|
3
8
|
import type { ConfigType } from "./types.ts";
|
|
4
9
|
|
|
@@ -33,13 +38,25 @@ function run(fetcher: SolanaFetcher, slot: number, block: any, entries: any[]) {
|
|
|
33
38
|
return step.value as any[];
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
// `type` is what readPrimitives dispatches on. These fixtures used to omit it,
|
|
42
|
+
// which the old field-truthy dispatch tolerated — real configs always carry it,
|
|
43
|
+
// because `Primitive.getConfig()` sets it and runtime/src/main.ts substitutes its
|
|
44
|
+
// output for the raw config entry.
|
|
36
45
|
const programLogEntry = {
|
|
37
46
|
syncProtocol: "parallelSolanaRPC",
|
|
38
|
-
primitive: {
|
|
47
|
+
primitive: {
|
|
48
|
+
name: "WatchedLog",
|
|
49
|
+
type: SOLANA_PRIMITIVE_PROGRAM_LOG,
|
|
50
|
+
programId: WATCHED_PROGRAM,
|
|
51
|
+
},
|
|
39
52
|
};
|
|
40
53
|
const balanceEntry = {
|
|
41
54
|
syncProtocol: "parallelSolanaRPC",
|
|
42
|
-
primitive: {
|
|
55
|
+
primitive: {
|
|
56
|
+
name: "WatchedBalance",
|
|
57
|
+
type: SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
|
|
58
|
+
address: WATCHED_ADDRESS,
|
|
59
|
+
},
|
|
43
60
|
};
|
|
44
61
|
|
|
45
62
|
/** A transaction that genuinely invoked the watched program. */
|
|
@@ -136,7 +153,12 @@ test("eventType filters against the program's OWN lines only", () => {
|
|
|
136
153
|
});
|
|
137
154
|
const entry = {
|
|
138
155
|
syncProtocol: "parallelSolanaRPC",
|
|
139
|
-
primitive: {
|
|
156
|
+
primitive: {
|
|
157
|
+
name: "WatchedLog",
|
|
158
|
+
type: SOLANA_PRIMITIVE_PROGRAM_LOG,
|
|
159
|
+
programId: WATCHED_PROGRAM,
|
|
160
|
+
eventType: "MARKER",
|
|
161
|
+
},
|
|
140
162
|
};
|
|
141
163
|
expect(run(makeFetcher(), 42, { transactions: [tx] }, [entry])).toEqual([]);
|
|
142
164
|
});
|
|
@@ -186,3 +208,228 @@ test("dispatches both primitive kinds from one transaction", () => {
|
|
|
186
208
|
test("returns nothing when no primitives are configured", () => {
|
|
187
209
|
expect(run(makeFetcher(), 42, { transactions: [invokingTx()] }, [])).toEqual([]);
|
|
188
210
|
});
|
|
211
|
+
|
|
212
|
+
// ───────────────────────── SOLANA:TokenAccount ─────────────────────────
|
|
213
|
+
|
|
214
|
+
const MINT = "Mint111111111111111111111111111111111111111";
|
|
215
|
+
const OTHER_MINT = "0therMint11111111111111111111111111111111";
|
|
216
|
+
const TOKEN_ACCOUNT = "Tok111111111111111111111111111111111111111";
|
|
217
|
+
const OWNER = "0wner11111111111111111111111111111111111111";
|
|
218
|
+
const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
219
|
+
const TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
220
|
+
|
|
221
|
+
const tokenEntry = {
|
|
222
|
+
syncProtocol: "parallelSolanaRPC",
|
|
223
|
+
primitive: {
|
|
224
|
+
name: "WatchedToken",
|
|
225
|
+
type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
|
|
226
|
+
mint: MINT,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** `meta.postTokenBalances` record shape, per the JSON-RPC `getBlock` response. */
|
|
231
|
+
function tokenBalance(overrides: Record<string, unknown> = {}) {
|
|
232
|
+
return {
|
|
233
|
+
accountIndex: 0,
|
|
234
|
+
mint: MINT,
|
|
235
|
+
owner: OWNER,
|
|
236
|
+
programId: TOKEN_PROGRAM,
|
|
237
|
+
uiTokenAmount: {
|
|
238
|
+
amount: "1500000000",
|
|
239
|
+
decimals: 9,
|
|
240
|
+
uiAmount: 1.5,
|
|
241
|
+
uiAmountString: "1.5",
|
|
242
|
+
},
|
|
243
|
+
...overrides,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A successful transaction carrying token balances. */
|
|
248
|
+
function tokenTx(
|
|
249
|
+
balances: Record<string, unknown>[],
|
|
250
|
+
overrides: Record<string, unknown> = {},
|
|
251
|
+
) {
|
|
252
|
+
return {
|
|
253
|
+
transaction: {
|
|
254
|
+
message: { accountKeys: [TOKEN_ACCOUNT, WATCHED_PROGRAM] },
|
|
255
|
+
signatures: ["sig-tok"],
|
|
256
|
+
},
|
|
257
|
+
meta: {
|
|
258
|
+
err: null,
|
|
259
|
+
logMessages: [],
|
|
260
|
+
postBalances: [0, 0],
|
|
261
|
+
postTokenBalances: balances,
|
|
262
|
+
...overrides,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
test("TokenAccount emits the post balance for a watched mint", () => {
|
|
268
|
+
const out = run(makeFetcher(), 11, { transactions: [tokenTx([tokenBalance()])] }, [
|
|
269
|
+
tokenEntry,
|
|
270
|
+
]);
|
|
271
|
+
expect(out.length).toBe(1);
|
|
272
|
+
expect(out[0].primitive).toBe("WatchedToken");
|
|
273
|
+
expect(out[0].output.payloadType).toBe("solana:token-balance");
|
|
274
|
+
expect(out[0].output.payload).toEqual({
|
|
275
|
+
tokenAccount: TOKEN_ACCOUNT,
|
|
276
|
+
mint: MINT,
|
|
277
|
+
owner: OWNER,
|
|
278
|
+
// Raw u64 as a string: 1.5 tokens at 9 decimals does not survive a JS number
|
|
279
|
+
// once amounts get large, so the grammar carries the base units.
|
|
280
|
+
amount: "1500000000",
|
|
281
|
+
decimals: 9,
|
|
282
|
+
slot: 11,
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("TokenAccount resolves lookup-table token accounts (security fix B3)", () => {
|
|
287
|
+
// `accountIndex` indexes the resolved list — static keys, then ALT writable, then
|
|
288
|
+
// ALT readonly — so a token account reached through an ALT is at an index past
|
|
289
|
+
// the end of message.accountKeys. Without resolution this reads undefined and
|
|
290
|
+
// drops the balance silently.
|
|
291
|
+
// Two static keys (indices 0-1), then ALT writable (2), then ALT readonly (3),
|
|
292
|
+
// so the watched token account sits at index 3 — past the end of accountKeys.
|
|
293
|
+
const tx = tokenTx([tokenBalance({ accountIndex: 3 })], {
|
|
294
|
+
loadedAddresses: {
|
|
295
|
+
writable: ["W1111111111111111111111111111111111111111111"],
|
|
296
|
+
readonly: [TOKEN_ACCOUNT],
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
tx.transaction.message.accountKeys = [OTHER_PROGRAM, WATCHED_PROGRAM];
|
|
300
|
+
const out = run(makeFetcher(), 12, { transactions: [tx] }, [tokenEntry]);
|
|
301
|
+
expect(out.length).toBe(1);
|
|
302
|
+
expect(out[0].output.payload.tokenAccount).toBe(TOKEN_ACCOUNT);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("TokenAccount SKIPS a reverted transaction (security fix B2)", () => {
|
|
306
|
+
const tx = tokenTx([tokenBalance()], { err: { InstructionError: [0, "Custom"] } });
|
|
307
|
+
expect(run(makeFetcher(), 13, { transactions: [tx] }, [tokenEntry])).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("TokenAccount ignores balances for a different mint", () => {
|
|
311
|
+
const tx = tokenTx([tokenBalance({ mint: OTHER_MINT })]);
|
|
312
|
+
expect(run(makeFetcher(), 14, { transactions: [tx] }, [tokenEntry])).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("TokenAccount narrows by owner", () => {
|
|
316
|
+
const entry = {
|
|
317
|
+
syncProtocol: "parallelSolanaRPC",
|
|
318
|
+
primitive: {
|
|
319
|
+
name: "WatchedToken",
|
|
320
|
+
type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
|
|
321
|
+
mint: MINT,
|
|
322
|
+
owner: OWNER,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
expect(
|
|
326
|
+
run(makeFetcher(), 15, { transactions: [tokenTx([tokenBalance()])] }, [entry]),
|
|
327
|
+
).toHaveLength(1);
|
|
328
|
+
expect(
|
|
329
|
+
run(
|
|
330
|
+
makeFetcher(),
|
|
331
|
+
15,
|
|
332
|
+
{ transactions: [tokenTx([tokenBalance({ owner: "someone-else" })])] },
|
|
333
|
+
[entry],
|
|
334
|
+
),
|
|
335
|
+
).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("TokenAccount narrows by tokenProgramId, separating Token-2022", () => {
|
|
339
|
+
const entry = {
|
|
340
|
+
syncProtocol: "parallelSolanaRPC",
|
|
341
|
+
primitive: {
|
|
342
|
+
name: "WatchedToken",
|
|
343
|
+
type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
|
|
344
|
+
mint: MINT,
|
|
345
|
+
tokenProgramId: TOKEN_2022_PROGRAM,
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
// Same mint, classic SPL Token program — must not match a Token-2022 filter.
|
|
349
|
+
expect(
|
|
350
|
+
run(makeFetcher(), 16, { transactions: [tokenTx([tokenBalance()])] }, [entry]),
|
|
351
|
+
).toEqual([]);
|
|
352
|
+
expect(
|
|
353
|
+
run(
|
|
354
|
+
makeFetcher(),
|
|
355
|
+
16,
|
|
356
|
+
{ transactions: [tokenTx([tokenBalance({ programId: TOKEN_2022_PROGRAM })])] },
|
|
357
|
+
[entry],
|
|
358
|
+
),
|
|
359
|
+
).toHaveLength(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("TokenAccount narrows by tokenAccount", () => {
|
|
363
|
+
const entry = {
|
|
364
|
+
syncProtocol: "parallelSolanaRPC",
|
|
365
|
+
primitive: {
|
|
366
|
+
name: "WatchedToken",
|
|
367
|
+
type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
|
|
368
|
+
tokenAccount: TOKEN_ACCOUNT,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
expect(
|
|
372
|
+
run(makeFetcher(), 17, { transactions: [tokenTx([tokenBalance()])] }, [entry]),
|
|
373
|
+
).toHaveLength(1);
|
|
374
|
+
// accountIndex 1 resolves to WATCHED_PROGRAM, not the watched token account.
|
|
375
|
+
expect(
|
|
376
|
+
run(
|
|
377
|
+
makeFetcher(),
|
|
378
|
+
17,
|
|
379
|
+
{ transactions: [tokenTx([tokenBalance({ accountIndex: 1 })])] },
|
|
380
|
+
[entry],
|
|
381
|
+
),
|
|
382
|
+
).toEqual([]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("TokenAccount emits one primitive per matching balance record", () => {
|
|
386
|
+
const tx = tokenTx([
|
|
387
|
+
tokenBalance(),
|
|
388
|
+
tokenBalance({ accountIndex: 1, owner: "second-owner" }),
|
|
389
|
+
tokenBalance({ mint: OTHER_MINT, accountIndex: 1 }),
|
|
390
|
+
]);
|
|
391
|
+
const out = run(makeFetcher(), 18, { transactions: [tx] }, [tokenEntry]);
|
|
392
|
+
expect(out.length).toBe(2);
|
|
393
|
+
expect(out.map((p) => p.output.payload.owner)).toEqual([OWNER, "second-owner"]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("TokenAccount tolerates a transaction with no token balances", () => {
|
|
397
|
+
expect(run(makeFetcher(), 19, { transactions: [invokingTx()] }, [tokenEntry]))
|
|
398
|
+
.toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("a TokenAccount entry is never treated as an AccountBalance", () => {
|
|
402
|
+
// The regression the type dispatch exists for. `SolanaPrimitive` is a flat bag of
|
|
403
|
+
// optionals, so a config can set `address` on a TokenAccount entry — under the old
|
|
404
|
+
// field-truthy dispatch (`if (prim.address)` first) that emitted a lamport-balance
|
|
405
|
+
// event under the token primitive's name.
|
|
406
|
+
const entry = {
|
|
407
|
+
syncProtocol: "parallelSolanaRPC",
|
|
408
|
+
primitive: {
|
|
409
|
+
name: "WatchedToken",
|
|
410
|
+
type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
|
|
411
|
+
mint: MINT,
|
|
412
|
+
address: TOKEN_ACCOUNT,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
const out = run(makeFetcher(), 20, { transactions: [tokenTx([tokenBalance()])] }, [
|
|
416
|
+
entry,
|
|
417
|
+
]);
|
|
418
|
+
expect(out.length).toBe(1);
|
|
419
|
+
expect(out[0].output.payloadType).toBe("solana:token-balance");
|
|
420
|
+
expect(out[0].output.payload).not.toHaveProperty("lamports");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("an unsupported primitive type is ignored rather than misrouted", () => {
|
|
424
|
+
const entry = {
|
|
425
|
+
syncProtocol: "parallelSolanaRPC",
|
|
426
|
+
primitive: {
|
|
427
|
+
name: "FromTheFuture",
|
|
428
|
+
type: "SOLANA:NotImplementedYet",
|
|
429
|
+
address: WATCHED_ADDRESS,
|
|
430
|
+
programId: WATCHED_PROGRAM,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
expect(run(makeFetcher(), 21, { transactions: [invokingTx()] }, [entry]))
|
|
434
|
+
.toEqual([]);
|
|
435
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { decodeZswapEvent } from "../src/sync-protocols/midnight/zswap-decoder.ts";
|
|
4
|
+
|
|
5
|
+
// Synthetic events built per the real midnight-ledger 8.x wire format:
|
|
6
|
+
// "midnight:event[v9]:" tag + TopoSortedNodes envelope wrapping the event
|
|
7
|
+
// struct. Both blobs round-trip through the native ledger-v8 deserializer.
|
|
8
|
+
|
|
9
|
+
function compactU32(n: number): Buffer {
|
|
10
|
+
if (n < 64) return Buffer.from([n << 2]);
|
|
11
|
+
if (n < 16384) {
|
|
12
|
+
const v = (n << 2) | 0b01;
|
|
13
|
+
return Buffer.from([v & 0xff, (v >> 8) & 0xff]);
|
|
14
|
+
}
|
|
15
|
+
throw new Error("value too large for this helper");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TX_HASH = Buffer.alloc(32, 0xaa);
|
|
19
|
+
const BODY32 = Buffer.alloc(32, 0xbb);
|
|
20
|
+
// logical=3, physical=1
|
|
21
|
+
const SEGMENTS = Buffer.from([0x03, 0x00, 0x01, 0x00]);
|
|
22
|
+
const CONTRACT_NONE = Buffer.from([0x01]);
|
|
23
|
+
|
|
24
|
+
function wrapEnvelope(data: Buffer): string {
|
|
25
|
+
return Buffer.concat([
|
|
26
|
+
Buffer.from("midnight:event[v9]:", "ascii"),
|
|
27
|
+
compactU32(1), // node count
|
|
28
|
+
compactU32(0), // child count of the single node
|
|
29
|
+
compactU32(data.length),
|
|
30
|
+
data,
|
|
31
|
+
]).toString("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("decodeZswapEvent", () => {
|
|
35
|
+
test("decodes a ZswapInput (nullifier) event", () => {
|
|
36
|
+
const raw = wrapEnvelope(Buffer.concat([
|
|
37
|
+
TX_HASH,
|
|
38
|
+
SEGMENTS,
|
|
39
|
+
Buffer.from([0x00]), // variant: ZswapInput
|
|
40
|
+
BODY32, // nullifier
|
|
41
|
+
CONTRACT_NONE,
|
|
42
|
+
]));
|
|
43
|
+
expect(decodeZswapEvent(raw)).toEqual({
|
|
44
|
+
kind: "nullifier",
|
|
45
|
+
nullifier: "bb".repeat(32),
|
|
46
|
+
txHash: "aa".repeat(32),
|
|
47
|
+
logicalSegment: 3,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("decodes a ZswapOutput (commitment) event", () => {
|
|
52
|
+
const raw = wrapEnvelope(Buffer.concat([
|
|
53
|
+
TX_HASH,
|
|
54
|
+
SEGMENTS,
|
|
55
|
+
Buffer.from([0x01]), // variant: ZswapOutput
|
|
56
|
+
BODY32, // commitment
|
|
57
|
+
Buffer.from([0x02]), // ZswapPreimageEvidence::None
|
|
58
|
+
CONTRACT_NONE,
|
|
59
|
+
compactU32(7), // mt_index
|
|
60
|
+
]));
|
|
61
|
+
expect(decodeZswapEvent(raw)).toEqual({
|
|
62
|
+
kind: "commitment",
|
|
63
|
+
commitment: "bb".repeat(32),
|
|
64
|
+
mtIndex: "7",
|
|
65
|
+
txHash: "aa".repeat(32),
|
|
66
|
+
logicalSegment: 3,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns null for undecodable input", () => {
|
|
71
|
+
expect(decodeZswapEvent("deadbeef")).toBeNull();
|
|
72
|
+
expect(decodeZswapEvent("")).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|