@effectstream/sync 0.100.11 → 0.100.13
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/celestia/CelestiaClient.ts +4 -1
- package/src/sync-protocols/midnight/MidnightClient.ts +16 -1
- package/src/sync-protocols/midnight/fetcher.ts +40 -1
- package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +286 -0
- package/src/sync-protocols/utxorpc/fetcher.ts +65 -31
- package/src/sync-protocols/utxorpc/types.ts +8 -0
- package/src/sync-protocols/utxorpc/utils.ts +82 -2
- package/src/syncProtocolFactory.ts +39 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/sync",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.13",
|
|
4
4
|
"license": "MIT",
|
|
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.100.
|
|
17
|
-
"@effectstream/db": "0.100.
|
|
18
|
-
"@effectstream/log": "0.100.
|
|
19
|
-
"@effectstream/utils": "0.100.
|
|
16
|
+
"@effectstream/config": "0.100.13",
|
|
17
|
+
"@effectstream/db": "0.100.13",
|
|
18
|
+
"@effectstream/log": "0.100.13",
|
|
19
|
+
"@effectstream/utils": "0.100.13",
|
|
20
20
|
"@midnight-ntwrk/ledger-v8": "8.0.3",
|
|
21
21
|
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
|
|
22
22
|
"@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
|
|
@@ -53,6 +53,7 @@ const CELESTIA_NODE_URL = ENV.getString(
|
|
|
53
53
|
"CELESTIA_NODE_URL",
|
|
54
54
|
"http://localhost:26658",
|
|
55
55
|
);
|
|
56
|
+
const CELESTIA_AUTH_TOKEN = ENV.getString("CELESTIA_AUTH_TOKEN", "");
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
59
|
* Converts a hex-encoded Celestia namespace to a base64-encoded 29-byte array.
|
|
@@ -81,9 +82,11 @@ export class CelestiaClient {
|
|
|
81
82
|
private async rpc<T>(method: string, params: unknown[]): Promise<T | null> {
|
|
82
83
|
let res: Response;
|
|
83
84
|
try {
|
|
85
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
86
|
+
if (CELESTIA_AUTH_TOKEN) headers["Authorization"] = `Bearer ${CELESTIA_AUTH_TOKEN}`;
|
|
84
87
|
res = await fetch(this.rpcUrl, {
|
|
85
88
|
method: "POST",
|
|
86
|
-
headers
|
|
89
|
+
headers,
|
|
87
90
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
88
91
|
});
|
|
89
92
|
} catch (err: unknown) {
|
|
@@ -27,6 +27,11 @@ export interface MidnightGqlBlockState {
|
|
|
27
27
|
raw: string;
|
|
28
28
|
maxId: number;
|
|
29
29
|
}[];
|
|
30
|
+
unshieldedSpentOutputs?: {
|
|
31
|
+
intentHash: string;
|
|
32
|
+
outputIndex: number;
|
|
33
|
+
owner: string;
|
|
34
|
+
}[];
|
|
30
35
|
}[];
|
|
31
36
|
};
|
|
32
37
|
};
|
|
@@ -36,6 +41,8 @@ export interface BlockFetchOptions {
|
|
|
36
41
|
contractActions?: boolean;
|
|
37
42
|
/** Include zswapLedgerEvents in the transaction fields (needed for MidnightNullifierPrimitive). Default: true */
|
|
38
43
|
zswapLedgerEvents?: boolean;
|
|
44
|
+
/** Include unshieldedSpentOutputs (needed for MidnightUnshieldedSpendPrimitive). Default: false */
|
|
45
|
+
unshieldedSpentOutputs?: boolean;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
type PublicDataProvider = ReturnType<typeof indexerPublicDataProvider>;
|
|
@@ -157,13 +164,20 @@ export class MidnightClient {
|
|
|
157
164
|
options: BlockFetchOptions = {},
|
|
158
165
|
signal?: AbortSignal,
|
|
159
166
|
): Promise<MidnightGqlBlockState> {
|
|
160
|
-
const {
|
|
167
|
+
const {
|
|
168
|
+
contractActions = true,
|
|
169
|
+
zswapLedgerEvents = true,
|
|
170
|
+
unshieldedSpentOutputs = false,
|
|
171
|
+
} = options;
|
|
161
172
|
const contractActionsField = contractActions
|
|
162
173
|
? `contractActions { address state }`
|
|
163
174
|
: "";
|
|
164
175
|
const zswapField = zswapLedgerEvents
|
|
165
176
|
? `zswapLedgerEvents { id raw maxId }`
|
|
166
177
|
: "";
|
|
178
|
+
const unshieldedSpentField = unshieldedSpentOutputs
|
|
179
|
+
? `unshieldedSpentOutputs { intentHash outputIndex owner }`
|
|
180
|
+
: "";
|
|
167
181
|
const query = `query {
|
|
168
182
|
block(offset: { height: ${blockHeight} }) {
|
|
169
183
|
hash
|
|
@@ -177,6 +191,7 @@ export class MidnightClient {
|
|
|
177
191
|
hash
|
|
178
192
|
${contractActionsField}
|
|
179
193
|
${zswapField}
|
|
194
|
+
${unshieldedSpentField}
|
|
180
195
|
}
|
|
181
196
|
}
|
|
182
197
|
}`;
|
|
@@ -82,11 +82,16 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
82
82
|
);
|
|
83
83
|
const blockFetchOptions: BlockFetchOptions = {
|
|
84
84
|
contractActions: this.config.primitives.some(
|
|
85
|
-
(p) =>
|
|
85
|
+
(p) =>
|
|
86
|
+
p.primitive.type !== "Midnight:Nullifier" &&
|
|
87
|
+
p.primitive.type !== "Midnight:UnshieldedSpend",
|
|
86
88
|
),
|
|
87
89
|
zswapLedgerEvents: this.config.primitives.some(
|
|
88
90
|
(p) => p.primitive.type === "Midnight:Nullifier",
|
|
89
91
|
),
|
|
92
|
+
unshieldedSpentOutputs: this.config.primitives.some(
|
|
93
|
+
(p) => p.primitive.type === "Midnight:UnshieldedSpend",
|
|
94
|
+
),
|
|
90
95
|
};
|
|
91
96
|
const self = this;
|
|
92
97
|
const heights = Array.from(
|
|
@@ -146,6 +151,8 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
146
151
|
for (const primitiveEntry of primitiveEntries) {
|
|
147
152
|
if (primitiveEntry.primitive.type === "Midnight:Nullifier") {
|
|
148
153
|
syncResults.push(...this.fetchNullifiers(height, primitiveEntry, block));
|
|
154
|
+
} else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
|
|
155
|
+
syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
|
|
149
156
|
} else {
|
|
150
157
|
asyncOps.push(
|
|
151
158
|
this.fetchContractState(height, client, primitiveEntry, block),
|
|
@@ -191,6 +198,38 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
191
198
|
return results;
|
|
192
199
|
}
|
|
193
200
|
|
|
201
|
+
@bound
|
|
202
|
+
fetchUnshieldedSpends(
|
|
203
|
+
height: number,
|
|
204
|
+
primitiveEntry: PrimitiveEntryType,
|
|
205
|
+
block: MidnightGqlBlockState,
|
|
206
|
+
): PrimitiveType[] {
|
|
207
|
+
const results: PrimitiveType[] = [];
|
|
208
|
+
for (const tx of block.block.transactions) {
|
|
209
|
+
for (const spend of tx.unshieldedSpentOutputs ?? []) {
|
|
210
|
+
results.push({
|
|
211
|
+
syncProtocol: {
|
|
212
|
+
name: primitiveEntry.syncProtocol,
|
|
213
|
+
blockNumber: height,
|
|
214
|
+
transactionHash: tx.hash,
|
|
215
|
+
contractAddress: "",
|
|
216
|
+
},
|
|
217
|
+
primitive: primitiveEntry.primitive.name,
|
|
218
|
+
output: {
|
|
219
|
+
payloadType: "midnight-unshielded-spend",
|
|
220
|
+
payload: {
|
|
221
|
+
owner: spend.owner,
|
|
222
|
+
intentHash: spend.intentHash,
|
|
223
|
+
outputIndex: spend.outputIndex,
|
|
224
|
+
txHash: tx.hash,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return results;
|
|
231
|
+
}
|
|
232
|
+
|
|
194
233
|
@bound
|
|
195
234
|
*fetchContractState(
|
|
196
235
|
height: number,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { type CardanoSyncClient, CardanoWatchClient, type CardanoTxEvent, type ClientBuilderOptions } from "@utxorpc/sdk";
|
|
2
|
+
import { cardano } from "@utxorpc/spec";
|
|
3
|
+
import type { watch } from "@utxorpc/spec";
|
|
4
|
+
import type { OutputAndCleanup } from "../base/state.ts";
|
|
5
|
+
import Deque from "denque";
|
|
6
|
+
import type { BlockNumber } from "@effectstream/utils";
|
|
7
|
+
import { conditionVariable, hexStringToUint8Array } from "@effectstream/utils";
|
|
8
|
+
import type { ChainPoint, CompletedBlock, PrimitiveEntryType } from "./types.ts";
|
|
9
|
+
import type { UtxorpcTxPredicate } from "@effectstream/config";
|
|
10
|
+
import { configPredicateToSdkPredicate, isEffectiveServerPredicate, predicateKey } from "./utils.ts";
|
|
11
|
+
import { Buffer } from "node:buffer";
|
|
12
|
+
|
|
13
|
+
type WatcherPendingBlock = {
|
|
14
|
+
txs: cardano.Tx[];
|
|
15
|
+
block: cardano.Block | undefined;
|
|
16
|
+
idle: boolean;
|
|
17
|
+
idleRef: watch.BlockRef | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type WatcherState = {
|
|
21
|
+
predicateKey: string;
|
|
22
|
+
sdkPredicate: ReturnType<typeof configPredicateToSdkPredicate>;
|
|
23
|
+
pendingBlocks: Map<number, WatcherPendingBlock>;
|
|
24
|
+
highestSeenHeight: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class WatchMultiplexer {
|
|
28
|
+
private readonly watchers: Map<string, WatcherState> = new Map();
|
|
29
|
+
private readonly completedBuffer: Deque<OutputAndCleanup<CompletedBlock>> = new Deque();
|
|
30
|
+
private readonly newDataCondVar = conditionVariable<void>();
|
|
31
|
+
private highestConfirmedHeight: number | undefined = undefined;
|
|
32
|
+
private lowestIncompleteHeight: number = 0;
|
|
33
|
+
private slotOriginTimestamp: bigint | undefined = undefined;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly clientOptions: ClientBuilderOptions,
|
|
37
|
+
primitivesByPredicate: Map<string, { predicate: UtxorpcTxPredicate; primitiveEntries: PrimitiveEntryType[] }>,
|
|
38
|
+
private readonly finality: BlockNumber,
|
|
39
|
+
private readonly syncClient: CardanoSyncClient,
|
|
40
|
+
) {
|
|
41
|
+
for (const [key, { predicate }] of primitivesByPredicate) {
|
|
42
|
+
const effective = isEffectiveServerPredicate(predicate);
|
|
43
|
+
// TODO(dolos): when Dolos implements has_certificate, remove this fallback
|
|
44
|
+
const sdkPredicate = effective
|
|
45
|
+
? configPredicateToSdkPredicate(predicate)
|
|
46
|
+
: {};
|
|
47
|
+
|
|
48
|
+
if (!effective) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`[WatchMultiplexer] Predicate for key "${key}" cannot be server-side filtered ` +
|
|
51
|
+
`(Dolos doesn't support has_certificate). Falling back to match-all with client-side filtering.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.watchers.set(key, {
|
|
56
|
+
predicateKey: key,
|
|
57
|
+
sdkPredicate,
|
|
58
|
+
pendingBlocks: new Map(),
|
|
59
|
+
highestSeenHeight: 0,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public async resolveOrigin(): Promise<ChainPoint> {
|
|
65
|
+
const blocks = await this.syncClient.fetchHistory(undefined, 1);
|
|
66
|
+
if (!blocks.length) throw new Error("No blocks found on chain");
|
|
67
|
+
const header = blocks[0].parsedBlock.header!;
|
|
68
|
+
return {
|
|
69
|
+
slot: Number(header.slot),
|
|
70
|
+
hash: Buffer.from(header.hash).toString("hex"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public async start(point?: ChainPoint): Promise<void> {
|
|
75
|
+
if (this.watchers.size === 0) {
|
|
76
|
+
throw new Error("No watchers configured");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const intersect = point ? [point] : [];
|
|
80
|
+
const promises: Promise<void>[] = [];
|
|
81
|
+
for (const [key, watcher] of this.watchers) {
|
|
82
|
+
const watchClient = new CardanoWatchClient(this.clientOptions);
|
|
83
|
+
promises.push(this.runWatcher(key, watcher, watchClient, intersect));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(`[WatchMultiplexer] Started ${this.watchers.size} watcher(s)`);
|
|
87
|
+
await Promise.all(promises);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async runWatcher(
|
|
91
|
+
key: string,
|
|
92
|
+
watcher: WatcherState,
|
|
93
|
+
watchClient: CardanoWatchClient,
|
|
94
|
+
intersect: ChainPoint[],
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const stream = watchClient.watchTxByPredicate(watcher.sdkPredicate, intersect);
|
|
97
|
+
for await (const event of stream) {
|
|
98
|
+
this.handleEvent(key, watcher, event);
|
|
99
|
+
}
|
|
100
|
+
console.warn(`[WatchMultiplexer] Watcher "${key}" stream ended unexpectedly`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private handleEvent(
|
|
104
|
+
key: string,
|
|
105
|
+
watcher: WatcherState,
|
|
106
|
+
event: CardanoTxEvent,
|
|
107
|
+
): void {
|
|
108
|
+
if (event.action === "apply") {
|
|
109
|
+
const height = Number(event.Block.header!.height);
|
|
110
|
+
let pending = watcher.pendingBlocks.get(height);
|
|
111
|
+
if (!pending) {
|
|
112
|
+
pending = { txs: [], block: undefined, idle: false, idleRef: undefined };
|
|
113
|
+
watcher.pendingBlocks.set(height, pending);
|
|
114
|
+
}
|
|
115
|
+
pending.txs.push(event.Tx);
|
|
116
|
+
pending.block = event.Block;
|
|
117
|
+
pending.idle = false;
|
|
118
|
+
if (this.slotOriginTimestamp === undefined) {
|
|
119
|
+
const blk = event.Block;
|
|
120
|
+
this.slotOriginTimestamp = blk.timestamp - blk.header!.slot;
|
|
121
|
+
}
|
|
122
|
+
if (height > watcher.highestSeenHeight) {
|
|
123
|
+
watcher.highestSeenHeight = height;
|
|
124
|
+
}
|
|
125
|
+
this.tryCompleteBlocks();
|
|
126
|
+
} else if (event.action === "idle") {
|
|
127
|
+
const height = Number(event.BlockRef.height);
|
|
128
|
+
let pending = watcher.pendingBlocks.get(height);
|
|
129
|
+
if (!pending) {
|
|
130
|
+
pending = { txs: [], block: undefined, idle: true, idleRef: event.BlockRef };
|
|
131
|
+
watcher.pendingBlocks.set(height, pending);
|
|
132
|
+
} else {
|
|
133
|
+
pending.idle = true;
|
|
134
|
+
pending.idleRef = event.BlockRef;
|
|
135
|
+
}
|
|
136
|
+
if (height > watcher.highestSeenHeight) {
|
|
137
|
+
watcher.highestSeenHeight = height;
|
|
138
|
+
}
|
|
139
|
+
this.tryCompleteBlocks();
|
|
140
|
+
} else if (event.action === "undo") {
|
|
141
|
+
const height = Number(event.Block.header!.height);
|
|
142
|
+
this.handleUndo(height);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private handleUndo(fromHeight: number): void {
|
|
147
|
+
// Pop all completed blocks at or above the undo height
|
|
148
|
+
while (this.completedBuffer.length > 0) {
|
|
149
|
+
const last = this.completedBuffer.peekBack()!;
|
|
150
|
+
if (last.output.height < fromHeight) break;
|
|
151
|
+
this.completedBuffer.pop();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Clear pending blocks at or above the undo height for all watchers
|
|
155
|
+
for (const [, watcher] of this.watchers) {
|
|
156
|
+
for (const [h] of watcher.pendingBlocks) {
|
|
157
|
+
if (h >= fromHeight) {
|
|
158
|
+
watcher.pendingBlocks.delete(h);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (watcher.highestSeenHeight >= fromHeight) {
|
|
162
|
+
watcher.highestSeenHeight = fromHeight - 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Reset confirmed height
|
|
167
|
+
if (this.highestConfirmedHeight != null && this.highestConfirmedHeight >= fromHeight) {
|
|
168
|
+
this.highestConfirmedHeight = fromHeight - 1;
|
|
169
|
+
}
|
|
170
|
+
if (this.lowestIncompleteHeight >= fromHeight) {
|
|
171
|
+
this.lowestIncompleteHeight = fromHeight;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private tryCompleteBlocks(): void {
|
|
176
|
+
const minHighest = Math.min(...[...this.watchers.values()].map(w => w.highestSeenHeight));
|
|
177
|
+
if (minHighest <= 0) return;
|
|
178
|
+
|
|
179
|
+
let height = this.lowestIncompleteHeight;
|
|
180
|
+
if (height === 0) {
|
|
181
|
+
let lowest = Infinity;
|
|
182
|
+
for (const [, watcher] of this.watchers) {
|
|
183
|
+
for (const [h] of watcher.pendingBlocks) {
|
|
184
|
+
if (h < lowest) lowest = h;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (lowest === Infinity) return;
|
|
188
|
+
height = lowest;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A block at `height` is only safe to process once every watcher has
|
|
192
|
+
// advanced past it (received an event for height+1 or later). This
|
|
193
|
+
// guarantees all Apply events for that height have been delivered —
|
|
194
|
+
// the Watch protocol can send multiple Apply events per block.
|
|
195
|
+
while (height < minHighest) {
|
|
196
|
+
const completed = this.buildCompletedBlock(height);
|
|
197
|
+
if (completed) {
|
|
198
|
+
const finalityThreshold = minHighest - this.finality;
|
|
199
|
+
if (height <= finalityThreshold) {
|
|
200
|
+
this.completedBuffer.push({
|
|
201
|
+
output: completed,
|
|
202
|
+
cleanup: () => this.completedBuffer.shift(),
|
|
203
|
+
});
|
|
204
|
+
this.highestConfirmedHeight = height;
|
|
205
|
+
this.newDataCondVar.wake();
|
|
206
|
+
} else {
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const [, watcher] of this.watchers) {
|
|
212
|
+
watcher.pendingBlocks.delete(height);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
height++;
|
|
216
|
+
}
|
|
217
|
+
this.lowestIncompleteHeight = height;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private buildCompletedBlock(height: number): CompletedBlock | null {
|
|
221
|
+
let block: cardano.Block | undefined;
|
|
222
|
+
let slot = 0;
|
|
223
|
+
let hash = "";
|
|
224
|
+
const matchedTxsByPredicate = new Map<string, cardano.Tx[]>();
|
|
225
|
+
|
|
226
|
+
for (const [key, watcher] of this.watchers) {
|
|
227
|
+
const pending = watcher.pendingBlocks.get(height);
|
|
228
|
+
if (!pending) return null;
|
|
229
|
+
|
|
230
|
+
if (pending.block) {
|
|
231
|
+
block = pending.block;
|
|
232
|
+
slot = Number(pending.block.header!.slot);
|
|
233
|
+
hash = Buffer.from(pending.block.header!.hash).toString("hex");
|
|
234
|
+
} else if (pending.idleRef && !block) {
|
|
235
|
+
slot = Number(pending.idleRef.slot);
|
|
236
|
+
hash = Buffer.from(pending.idleRef.hash).toString("hex");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (pending.txs.length > 0) {
|
|
240
|
+
matchedTxsByPredicate.set(key, pending.txs);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!block) {
|
|
245
|
+
const idleSlot = BigInt(slot);
|
|
246
|
+
const idleHash = hexStringToUint8Array(hash);
|
|
247
|
+
const estimatedTimestamp = this.slotOriginTimestamp != null
|
|
248
|
+
? this.slotOriginTimestamp + idleSlot
|
|
249
|
+
: idleSlot;
|
|
250
|
+
block = new cardano.Block({
|
|
251
|
+
header: new cardano.BlockHeader({
|
|
252
|
+
slot: idleSlot,
|
|
253
|
+
hash: idleHash,
|
|
254
|
+
height: BigInt(height),
|
|
255
|
+
}),
|
|
256
|
+
body: new cardano.BlockBody({ tx: [] }),
|
|
257
|
+
timestamp: estimatedTimestamp,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { height, slot, hash, block, matchedTxsByPredicate };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
public fetchBlocks(
|
|
265
|
+
from: BlockNumber,
|
|
266
|
+
to: BlockNumber,
|
|
267
|
+
): OutputAndCleanup<CompletedBlock>[] {
|
|
268
|
+
const blocks: OutputAndCleanup<CompletedBlock>[] = [];
|
|
269
|
+
for (let i = 0; i < this.completedBuffer.length; i++) {
|
|
270
|
+
const entry = this.completedBuffer.peekAt(i)!;
|
|
271
|
+
if (entry.output.height >= from) {
|
|
272
|
+
blocks.push(entry);
|
|
273
|
+
}
|
|
274
|
+
if (entry.output.height >= to) {
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return blocks;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
public lastHeight(): bigint | undefined {
|
|
282
|
+
if (this.highestConfirmedHeight == null) return undefined;
|
|
283
|
+
return BigInt(this.highestConfirmedHeight);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
}
|
|
@@ -2,31 +2,30 @@ import { BaseDataFetcher, type DataFetched } from "../base/fetcher.ts";
|
|
|
2
2
|
import type { RootOutput, RootPage } from "../types.ts";
|
|
3
3
|
import type { Operation } from "effection";
|
|
4
4
|
import { bound, uint8ArrayToHexString } from "@effectstream/utils";
|
|
5
|
-
import type { ChainPoint, ConfigType, Input, Output, Page, PrimitiveType } from "./types.ts";
|
|
5
|
+
import type { ChainPoint, CompletedBlock, ConfigType, Input, Output, Page, PrimitiveType } from "./types.ts";
|
|
6
6
|
import type { OutputAndCleanup, RootConversion } from "../base/state.ts";
|
|
7
|
-
import type {
|
|
8
|
-
import { Buffer } from "node:buffer";
|
|
7
|
+
import type { WatchMultiplexer } from "./WatchMultiplexer.ts";
|
|
9
8
|
import type { cardano } from "@utxorpc/spec";
|
|
10
|
-
import {
|
|
9
|
+
import { matchesPredicate, predicateKey } from "./utils.ts";
|
|
11
10
|
|
|
12
11
|
export class UtxoRpcFetcher
|
|
13
|
-
extends BaseDataFetcher<Input, Output, RootOutput, Page, RootPage>
|
|
12
|
+
extends BaseDataFetcher<Input, Output, RootOutput, Page, RootPage>
|
|
14
13
|
{
|
|
15
14
|
constructor(
|
|
16
15
|
readonly config: ConfigType,
|
|
17
|
-
readonly
|
|
16
|
+
readonly multiplexer: WatchMultiplexer,
|
|
18
17
|
) {
|
|
19
18
|
super(config.syncProtocol.name);
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
@bound
|
|
23
22
|
async startAsync(point?: ChainPoint) {
|
|
24
|
-
await this.
|
|
23
|
+
await this.multiplexer.start(point);
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
@bound
|
|
28
27
|
async resolveOrigin(): Promise<ChainPoint> {
|
|
29
|
-
return this.
|
|
28
|
+
return this.multiplexer.resolveOrigin();
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
@bound
|
|
@@ -40,16 +39,16 @@ export class UtxoRpcFetcher
|
|
|
40
39
|
}`,
|
|
41
40
|
);
|
|
42
41
|
const outputs: OutputAndCleanup<Output>[] = [];
|
|
43
|
-
const blocks = this.
|
|
44
|
-
for (const
|
|
42
|
+
const blocks = this.multiplexer.fetchBlocks(data.from, data.to);
|
|
43
|
+
for (const entry of blocks) {
|
|
44
|
+
const completed = entry.output;
|
|
45
45
|
outputs.push({
|
|
46
46
|
output: {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
primitives: this.findPrimitives(block.output),
|
|
47
|
+
blockHashes: [completed.hash],
|
|
48
|
+
raw: completed.block,
|
|
49
|
+
primitives: this.findPrimitivesFromWatchData(completed),
|
|
51
50
|
},
|
|
52
|
-
cleanup:
|
|
51
|
+
cleanup: entry.cleanup,
|
|
53
52
|
});
|
|
54
53
|
}
|
|
55
54
|
return {
|
|
@@ -63,14 +62,11 @@ export class UtxoRpcFetcher
|
|
|
63
62
|
height: Number(
|
|
64
63
|
outputs[outputs.length - 1].output.raw.header!.height,
|
|
65
64
|
),
|
|
66
|
-
hash:
|
|
67
|
-
outputs[outputs.length - 1].output.raw.header!.hash,
|
|
68
|
-
)
|
|
69
|
-
.toString("hex"),
|
|
65
|
+
hash: outputs[outputs.length - 1].output.blockHashes[0],
|
|
70
66
|
},
|
|
71
67
|
root: rootConversion.toRootPage({
|
|
72
68
|
blockHashes: [],
|
|
73
|
-
primitives: [],
|
|
69
|
+
primitives: [],
|
|
74
70
|
raw: outputs[outputs.length - 1].output.raw,
|
|
75
71
|
}),
|
|
76
72
|
},
|
|
@@ -78,25 +74,42 @@ export class UtxoRpcFetcher
|
|
|
78
74
|
}
|
|
79
75
|
|
|
80
76
|
@bound
|
|
81
|
-
|
|
77
|
+
findPrimitivesFromWatchData(completed: CompletedBlock): PrimitiveType[] {
|
|
82
78
|
const primitiveResponses: PrimitiveType[] = [];
|
|
83
|
-
|
|
79
|
+
const block = completed.block;
|
|
80
|
+
const txIndexCache = new Map<string, number>();
|
|
81
|
+
|
|
82
|
+
for (const primitiveEntry of this.config.primitives) {
|
|
83
|
+
const pKey = predicateKey(primitiveEntry.primitive.predicate);
|
|
84
|
+
const matchedTxs = completed.matchedTxsByPredicate.get(pKey)
|
|
85
|
+
?? this.findFallbackTxs(completed, primitiveEntry.primitive.predicate);
|
|
86
|
+
|
|
84
87
|
let logIndex = 0;
|
|
85
|
-
for (const
|
|
86
|
-
if (!matchesPredicate(tx,
|
|
88
|
+
for (const tx of matchedTxs) {
|
|
89
|
+
if (!matchesPredicate(tx, primitiveEntry.primitive.predicate)) {
|
|
87
90
|
continue;
|
|
88
91
|
}
|
|
89
|
-
|
|
92
|
+
|
|
93
|
+
const txHashHex = uint8ArrayToHexString(tx.hash);
|
|
94
|
+
let transactionIndex = txIndexCache.get(txHashHex);
|
|
95
|
+
if (transactionIndex === undefined) {
|
|
96
|
+
transactionIndex = block.body?.tx.findIndex(t =>
|
|
97
|
+
t.hash.length === tx.hash.length && t.hash.every((b, i) => b === tx.hash[i])
|
|
98
|
+
) ?? -1;
|
|
99
|
+
txIndexCache.set(txHashHex, transactionIndex);
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
primitiveResponses.push({
|
|
91
103
|
syncProtocol: {
|
|
92
|
-
name:
|
|
104
|
+
name: primitiveEntry.syncProtocol,
|
|
93
105
|
blockNumber: Number(block.header!.height),
|
|
94
|
-
transactionHash:
|
|
106
|
+
transactionHash: txHashHex,
|
|
95
107
|
transactionIndex,
|
|
96
108
|
contractAddress: '',
|
|
97
109
|
logIndex,
|
|
110
|
+
absoluteSlot: Number(block.header!.slot),
|
|
98
111
|
},
|
|
99
|
-
primitive:
|
|
112
|
+
primitive: primitiveEntry.primitive.name,
|
|
100
113
|
output: {
|
|
101
114
|
payloadType: 'utxorpc-response',
|
|
102
115
|
payload: { tx }
|
|
@@ -108,9 +121,30 @@ export class UtxoRpcFetcher
|
|
|
108
121
|
return primitiveResponses;
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
/**
|
|
125
|
+
* When a primitive's predicate key doesn't exactly match any watcher key
|
|
126
|
+
* (e.g., has_certificate primitives merged into match-all watcher),
|
|
127
|
+
* fall back to scanning all matched txs across all watchers.
|
|
128
|
+
*/
|
|
129
|
+
private findFallbackTxs(
|
|
130
|
+
completed: CompletedBlock,
|
|
131
|
+
_predicate: unknown,
|
|
132
|
+
): cardano.Tx[] {
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
const txs: cardano.Tx[] = [];
|
|
135
|
+
for (const [, watcherTxs] of completed.matchedTxsByPredicate) {
|
|
136
|
+
for (const tx of watcherTxs) {
|
|
137
|
+
const hash = uint8ArrayToHexString(tx.hash);
|
|
138
|
+
if (!seen.has(hash)) {
|
|
139
|
+
seen.add(hash);
|
|
140
|
+
txs.push(tx);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return txs;
|
|
145
|
+
}
|
|
146
|
+
|
|
111
147
|
lastHeight(): bigint | undefined {
|
|
112
|
-
|
|
113
|
-
// returns the hash and slot, but not the block height
|
|
114
|
-
return this.client.lastHeight();
|
|
148
|
+
return this.multiplexer.lastHeight();
|
|
115
149
|
}
|
|
116
150
|
}
|
|
@@ -51,3 +51,11 @@ export type ConfigType = Extract<
|
|
|
51
51
|
SyncProtocolWithNetwork,
|
|
52
52
|
{ syncProtocolType: ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL }
|
|
53
53
|
>;
|
|
54
|
+
|
|
55
|
+
export type CompletedBlock = {
|
|
56
|
+
height: number;
|
|
57
|
+
slot: number;
|
|
58
|
+
hash: string;
|
|
59
|
+
block: cardano.Block;
|
|
60
|
+
matchedTxsByPredicate: Map<string, cardano.Tx[]>;
|
|
61
|
+
};
|
|
@@ -3,6 +3,8 @@ import type { UtxorpcAddressPattern, UtxorpcAnyChainTxPattern, UtxorpcAssetPatte
|
|
|
3
3
|
import { hexStringToUint8Array } from "@effectstream/utils";
|
|
4
4
|
import { Cardano } from '@cardano-sdk/core';
|
|
5
5
|
import { HexBlob } from '@cardano-sdk/util';
|
|
6
|
+
import type { CardanoTxPredicate as TxPredicate } from "@utxorpc/sdk";
|
|
7
|
+
import stableStringify from "json-stable-stringify";
|
|
6
8
|
|
|
7
9
|
export function hashEqual(lhs: Uint8Array, rhs: Uint8Array): boolean {
|
|
8
10
|
if (lhs.length !== rhs.length) {
|
|
@@ -38,9 +40,10 @@ function matchesPattern(tx: cardano.Tx, pattern: UtxorpcAnyChainTxPattern): bool
|
|
|
38
40
|
const outputs = tx.outputs;
|
|
39
41
|
const inputs = tx.inputs.map(input => input.asOutput!).filter(x => x);
|
|
40
42
|
const matchHasAddress = !cardanoPattern.has_address || matchesAddress(outputs, cardanoPattern.has_address) || matchesAddress(inputs, cardanoPattern.has_address);
|
|
41
|
-
const matchMovesAsset = !cardanoPattern.moves_asset || matchesAsset(inputs, cardanoPattern.moves_asset);
|
|
43
|
+
const matchMovesAsset = !cardanoPattern.moves_asset || matchesAsset(outputs, cardanoPattern.moves_asset) || matchesAsset(inputs, cardanoPattern.moves_asset);
|
|
42
44
|
const matchMintsAsset = !cardanoPattern.mints_asset || matchesAsset(outputs, cardanoPattern.mints_asset);
|
|
43
|
-
|
|
45
|
+
const matchHasCertificate = !cardanoPattern.has_certificate || tx.certificates.length > 0;
|
|
46
|
+
return matchHasAddress && matchMovesAsset && matchMintsAsset && matchHasCertificate;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
function matchesOutputPattern(outputs: cardano.TxOutput[], pattern: UtxorpcTxOutputPattern): boolean {
|
|
@@ -77,4 +80,81 @@ function matchesAsset(outputs: cardano.TxOutput[], pattern: UtxorpcAssetPattern)
|
|
|
77
80
|
}
|
|
78
81
|
return ma.assets.some(a => !assetName || hashEqual(a.name, assetName));
|
|
79
82
|
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================
|
|
86
|
+
// Config → SDK predicate conversion
|
|
87
|
+
// =============================================
|
|
88
|
+
|
|
89
|
+
function convertAddressPattern(pattern: UtxorpcAddressPattern): Record<string, Uint8Array> {
|
|
90
|
+
const result: Record<string, Uint8Array> = {};
|
|
91
|
+
if (pattern.exact_address) {
|
|
92
|
+
result.exactAddress = Uint8Array.from(atob(pattern.exact_address), c => c.charCodeAt(0));
|
|
93
|
+
}
|
|
94
|
+
if (pattern.payment_part) {
|
|
95
|
+
result.paymentPart = hexStringToUint8Array(pattern.payment_part);
|
|
96
|
+
}
|
|
97
|
+
if (pattern.delegation_part) {
|
|
98
|
+
result.delegationPart = hexStringToUint8Array(pattern.delegation_part);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function convertAssetPattern(pattern: UtxorpcAssetPattern): Record<string, Uint8Array> {
|
|
104
|
+
const result: Record<string, Uint8Array> = {};
|
|
105
|
+
if (pattern.policy_id) {
|
|
106
|
+
result.policyId = hexStringToUint8Array(pattern.policy_id);
|
|
107
|
+
}
|
|
108
|
+
if (pattern.asset_name) {
|
|
109
|
+
result.assetName = hexStringToUint8Array(pattern.asset_name);
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Convert a config-layer UtxorpcTxPredicate (snake_case) to the SDK's TxPredicate (camelCase).
|
|
116
|
+
*
|
|
117
|
+
* NOTE: `has_certificate` is in the UTxORPC spec but Dolos does NOT implement server-side
|
|
118
|
+
* filtering for it. Predicates using has_certificate will be sent to the server but silently
|
|
119
|
+
* ignored — client-side filtering in matchesPredicate() still catches them.
|
|
120
|
+
*/
|
|
121
|
+
// TODO(dolos): when Dolos implements has_certificate filtering, this conversion will just work
|
|
122
|
+
export function configPredicateToSdkPredicate(config: UtxorpcTxPredicate): TxPredicate {
|
|
123
|
+
const result: TxPredicate = {};
|
|
124
|
+
|
|
125
|
+
if (config.match?.cardano) {
|
|
126
|
+
const c = config.match.cardano;
|
|
127
|
+
const match: Record<string, unknown> = {};
|
|
128
|
+
if (c.has_address) match.hasAddress = convertAddressPattern(c.has_address);
|
|
129
|
+
if (c.moves_asset) match.movesAsset = convertAssetPattern(c.moves_asset);
|
|
130
|
+
if (c.mints_asset) match.mintsAsset = convertAssetPattern(c.mints_asset);
|
|
131
|
+
if (c.has_certificate) match.hasCertificate = {};
|
|
132
|
+
result.match = match as TxPredicate["match"];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (config.not) result.not = config.not.map(configPredicateToSdkPredicate);
|
|
136
|
+
if (config.all_of) result.allOf = config.all_of.map(configPredicateToSdkPredicate);
|
|
137
|
+
if (config.any_of) result.anyOf = config.any_of.map(configPredicateToSdkPredicate);
|
|
138
|
+
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Deterministic string key for a predicate, used to deduplicate watchers.
|
|
144
|
+
* Predicates that produce the same key share a single WatchClient stream.
|
|
145
|
+
*/
|
|
146
|
+
export function predicateKey(predicate: UtxorpcTxPredicate): string {
|
|
147
|
+
return stableStringify(predicate) ?? "{}";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns true if this predicate can be effectively filtered server-side by Dolos.
|
|
152
|
+
* Currently Dolos Watch ignores `has_certificate`, so predicates relying solely on it
|
|
153
|
+
* are not effective and should fall back to match-all.
|
|
154
|
+
*/
|
|
155
|
+
// TODO(dolos): remove this when Dolos implements has_certificate filtering
|
|
156
|
+
export function isEffectiveServerPredicate(predicate: UtxorpcTxPredicate): boolean {
|
|
157
|
+
if (!predicate.match?.cardano) return false;
|
|
158
|
+
const c = predicate.match.cardano;
|
|
159
|
+
return !!(c.has_address || c.moves_asset || c.mints_asset);
|
|
80
160
|
}
|
|
@@ -11,9 +11,12 @@ import {
|
|
|
11
11
|
getViemNetwork,
|
|
12
12
|
} from "@effectstream/config";
|
|
13
13
|
import { CardanoSyncClient } from "@utxorpc/sdk";
|
|
14
|
-
import {
|
|
14
|
+
import { WatchMultiplexer } from "./sync-protocols/utxorpc/WatchMultiplexer.ts";
|
|
15
15
|
import { UtxoRpcFetcher } from "./sync-protocols/utxorpc/fetcher.ts";
|
|
16
16
|
import { UtxoRpcSyncState } from "./sync-protocols/utxorpc/state.ts";
|
|
17
|
+
import { isEffectiveServerPredicate, predicateKey } from "./sync-protocols/utxorpc/utils.ts";
|
|
18
|
+
import type { UtxorpcTxPredicate } from "@effectstream/config";
|
|
19
|
+
import type { PrimitiveEntryType } from "./sync-protocols/utxorpc/types.ts";
|
|
17
20
|
import { MidnightFetcher, MidnightSyncState } from "@effectstream/sync";
|
|
18
21
|
import { AvailFetcher } from "./sync-protocols/avail/fetcher.ts";
|
|
19
22
|
import { AvailSyncState } from "./sync-protocols/avail/state.ts";
|
|
@@ -67,17 +70,23 @@ export function* genSyncProtocols(
|
|
|
67
70
|
) {
|
|
68
71
|
throw new Error("CARP not supported yet");
|
|
69
72
|
}
|
|
70
|
-
const
|
|
73
|
+
const clientOptions = {
|
|
71
74
|
uri: entry.syncProtocol.rpcUrl,
|
|
72
75
|
headers: entry.syncProtocol.headers,
|
|
73
|
-
}
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
+
};
|
|
77
|
+
const syncClient = new CardanoSyncClient(clientOptions);
|
|
78
|
+
const primitivesByPredicate = groupPrimitivesByPredicate(
|
|
79
|
+
entry.primitives as PrimitiveEntryType[],
|
|
80
|
+
);
|
|
81
|
+
const multiplexer = new WatchMultiplexer(
|
|
82
|
+
clientOptions,
|
|
83
|
+
primitivesByPredicate,
|
|
76
84
|
entry.syncProtocol.confirmationDepth,
|
|
85
|
+
syncClient,
|
|
77
86
|
);
|
|
78
87
|
const fetcher = new UtxoRpcFetcher(
|
|
79
88
|
entry,
|
|
80
|
-
|
|
89
|
+
multiplexer,
|
|
81
90
|
);
|
|
82
91
|
const state = yield* UtxoRpcSyncState.restoreState(
|
|
83
92
|
dbConn,
|
|
@@ -147,3 +156,27 @@ export function* genSyncProtocols(
|
|
|
147
156
|
|
|
148
157
|
return result;
|
|
149
158
|
}
|
|
159
|
+
|
|
160
|
+
function groupPrimitivesByPredicate(
|
|
161
|
+
primitives: PrimitiveEntryType[],
|
|
162
|
+
): Map<string, { predicate: UtxorpcTxPredicate; primitiveEntries: PrimitiveEntryType[] }> {
|
|
163
|
+
const groups = new Map<string, { predicate: UtxorpcTxPredicate; primitiveEntries: PrimitiveEntryType[] }>();
|
|
164
|
+
const emptyKey = predicateKey({});
|
|
165
|
+
|
|
166
|
+
for (const entry of primitives) {
|
|
167
|
+
const predicate = entry.primitive.predicate;
|
|
168
|
+
// TODO(dolos): when Dolos implements has_certificate, remove this fallback to empty predicate
|
|
169
|
+
const effective = isEffectiveServerPredicate(predicate);
|
|
170
|
+
const key = effective ? predicateKey(predicate) : emptyKey;
|
|
171
|
+
const effectivePredicate = effective ? predicate : {};
|
|
172
|
+
|
|
173
|
+
let group = groups.get(key);
|
|
174
|
+
if (!group) {
|
|
175
|
+
group = { predicate: effectivePredicate, primitiveEntries: [] };
|
|
176
|
+
groups.set(key, group);
|
|
177
|
+
}
|
|
178
|
+
group.primitiveEntries.push(entry);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return groups;
|
|
182
|
+
}
|