@effectstream/sync 0.100.12 → 0.100.14
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/avail/fetcher.ts +23 -14
- package/src/sync-protocols/bitcoin/fetcher.ts +18 -11
- package/src/sync-protocols/celestia/CelestiaClient.ts +4 -1
- package/src/sync-protocols/celestia/fetcher.ts +23 -16
- package/src/sync-protocols/evm/fetcher.ts +19 -14
- package/src/sync-protocols/midnight/MidnightClient.ts +16 -1
- package/src/sync-protocols/midnight/fetcher.ts +59 -7
- package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +300 -0
- package/src/sync-protocols/utxorpc/fetcher.ts +78 -42
- 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.14",
|
|
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.14",
|
|
17
|
+
"@effectstream/db": "0.100.14",
|
|
18
|
+
"@effectstream/log": "0.100.14",
|
|
19
|
+
"@effectstream/utils": "0.100.14",
|
|
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",
|
|
@@ -48,11 +48,16 @@ export class AvailFetcher extends BaseDataFetcher<
|
|
|
48
48
|
lastPage: LastPage<Page, RootPage> | undefined,
|
|
49
49
|
): Operation<DataFetched<Output, Page, RootPage>> {
|
|
50
50
|
const outputs: OutputAndCleanup<Output>[] = [];
|
|
51
|
+
const fetchAllBlocks = this.config.primitives.some(
|
|
52
|
+
(p) => p.primitive.getAllBlockHeaders,
|
|
53
|
+
);
|
|
51
54
|
console.log(
|
|
52
55
|
`[Avail] Fetching blocks from ${data.from} to ${data.to}. ${
|
|
53
56
|
data.isPresync ? "[presync]" : ""
|
|
54
57
|
}`,
|
|
55
58
|
);
|
|
59
|
+
|
|
60
|
+
let lastHeader: Output["raw"] | undefined;
|
|
56
61
|
for (let height = data.from; height <= data.to; height++) {
|
|
57
62
|
let header;
|
|
58
63
|
try {
|
|
@@ -60,26 +65,28 @@ export class AvailFetcher extends BaseDataFetcher<
|
|
|
60
65
|
this.client.getBlockHeaderFromHeight(height)
|
|
61
66
|
);
|
|
62
67
|
} catch (_e) {
|
|
63
|
-
// Likely reached the tip; stop here
|
|
64
68
|
break;
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
lastHeader = header;
|
|
67
72
|
const primitives = yield* this.readPrimitives(
|
|
68
73
|
height,
|
|
69
74
|
header,
|
|
70
75
|
this.config.primitives,
|
|
71
76
|
);
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
if (fetchAllBlocks || primitives.length > 0 || height === data.to) {
|
|
79
|
+
outputs.push({
|
|
80
|
+
output: {
|
|
81
|
+
raw: header,
|
|
82
|
+
primitives,
|
|
83
|
+
},
|
|
84
|
+
cleanup: () => {},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
if (
|
|
89
|
+
if (!lastHeader) {
|
|
83
90
|
if (!lastPage) {
|
|
84
91
|
throw new Error(
|
|
85
92
|
`Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
|
|
@@ -91,16 +98,18 @@ export class AvailFetcher extends BaseDataFetcher<
|
|
|
91
98
|
};
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
const lastOutput = outputs[outputs.length - 1].output;
|
|
95
101
|
return {
|
|
96
102
|
output: outputs,
|
|
97
103
|
lastPage: {
|
|
98
|
-
ownBlockNumber:
|
|
104
|
+
ownBlockNumber: lastHeader.number,
|
|
99
105
|
own: {
|
|
100
|
-
height:
|
|
101
|
-
hash:
|
|
106
|
+
height: lastHeader.number,
|
|
107
|
+
hash: lastHeader.hash,
|
|
102
108
|
},
|
|
103
|
-
root: rootConversion.toRootPage(
|
|
109
|
+
root: rootConversion.toRootPage({
|
|
110
|
+
raw: lastHeader,
|
|
111
|
+
primitives: [],
|
|
112
|
+
}),
|
|
104
113
|
},
|
|
105
114
|
};
|
|
106
115
|
}
|
|
@@ -111,24 +111,31 @@ export class BitcoinFetcher
|
|
|
111
111
|
);
|
|
112
112
|
const groupedByPage = this.groupByPage(primitives);
|
|
113
113
|
|
|
114
|
+
const fetchAllBlocks = this.config.primitives.some(
|
|
115
|
+
(p) => p.primitive.getAllBlockHeaders,
|
|
116
|
+
);
|
|
117
|
+
|
|
114
118
|
const outputs: OutputAndCleanup<Output>[] = [];
|
|
119
|
+
let lastBlock: BitcoinBlock | undefined;
|
|
115
120
|
for (let height = Number(data.from); height <= Number(data.to); height++) {
|
|
116
121
|
const blockHeight = height as Page;
|
|
117
122
|
const block = yield* call(() => pageFetcher(blockHeight));
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
lastBlock = block;
|
|
124
|
+
const blockPrimitives = groupedByPage[String(height)] ?? [];
|
|
125
|
+
if (fetchAllBlocks || blockPrimitives.length > 0 || height === Number(data.to)) {
|
|
126
|
+
outputs.push({
|
|
127
|
+
output: {
|
|
128
|
+
raw: block,
|
|
129
|
+
primitives: blockPrimitives,
|
|
130
|
+
blockHashes: [block.hash],
|
|
131
|
+
},
|
|
132
|
+
cleanup: () => {},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
126
135
|
}
|
|
127
|
-
|
|
128
|
-
if (!lastOutput) {
|
|
136
|
+
if (!lastBlock) {
|
|
129
137
|
throw new Error("BitcoinFetcher: no block data fetched for interval");
|
|
130
138
|
}
|
|
131
|
-
const lastBlock = lastOutput.output.raw;
|
|
132
139
|
const lastPageRef = this.asPage(Number(data.to));
|
|
133
140
|
return {
|
|
134
141
|
output: outputs,
|
|
@@ -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) {
|
|
@@ -47,6 +47,9 @@ export class CelestiaFetcher extends BaseDataFetcher<
|
|
|
47
47
|
lastPage: LastPage<Page, RootPage> | undefined,
|
|
48
48
|
): Operation<DataFetched<Output, Page, RootPage>> {
|
|
49
49
|
const outputs: OutputAndCleanup<Output>[] = [];
|
|
50
|
+
const fetchAllBlocks = this.config.primitives.some(
|
|
51
|
+
(p) => p.primitive.getAllBlockHeaders,
|
|
52
|
+
);
|
|
50
53
|
|
|
51
54
|
console.log(
|
|
52
55
|
`[Celestia] Fetching blocks from ${data.from} to ${data.to}.${
|
|
@@ -54,6 +57,7 @@ export class CelestiaFetcher extends BaseDataFetcher<
|
|
|
54
57
|
}`,
|
|
55
58
|
);
|
|
56
59
|
|
|
60
|
+
let lastFetchedOutput: Output | undefined;
|
|
57
61
|
for (let height = data.from; height <= data.to; height++) {
|
|
58
62
|
let extHeader;
|
|
59
63
|
try {
|
|
@@ -61,7 +65,6 @@ export class CelestiaFetcher extends BaseDataFetcher<
|
|
|
61
65
|
this.client.getHeaderAtHeight(height)
|
|
62
66
|
);
|
|
63
67
|
} catch (_e) {
|
|
64
|
-
// Likely reached the chain tip; stop here
|
|
65
68
|
break;
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -73,18 +76,23 @@ export class CelestiaFetcher extends BaseDataFetcher<
|
|
|
73
76
|
this.config.primitives,
|
|
74
77
|
);
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
const output: Output = {
|
|
80
|
+
timestamp: extHeader.header.time,
|
|
81
|
+
height,
|
|
82
|
+
hash: blockHash,
|
|
83
|
+
primitives,
|
|
84
|
+
};
|
|
85
|
+
lastFetchedOutput = output;
|
|
86
|
+
|
|
87
|
+
if (fetchAllBlocks || primitives.length > 0 || height === data.to) {
|
|
88
|
+
outputs.push({
|
|
89
|
+
output,
|
|
90
|
+
cleanup: () => {},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
if (
|
|
95
|
+
if (!lastFetchedOutput) {
|
|
88
96
|
if (!lastPage) {
|
|
89
97
|
throw new Error(
|
|
90
98
|
`[Celestia] Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
|
|
@@ -96,16 +104,15 @@ export class CelestiaFetcher extends BaseDataFetcher<
|
|
|
96
104
|
};
|
|
97
105
|
}
|
|
98
106
|
|
|
99
|
-
const lastOutput = outputs[outputs.length - 1].output;
|
|
100
107
|
return {
|
|
101
108
|
output: outputs,
|
|
102
109
|
lastPage: {
|
|
103
|
-
ownBlockNumber:
|
|
110
|
+
ownBlockNumber: lastFetchedOutput.height,
|
|
104
111
|
own: {
|
|
105
|
-
height:
|
|
106
|
-
hash:
|
|
112
|
+
height: lastFetchedOutput.height,
|
|
113
|
+
hash: lastFetchedOutput.hash,
|
|
107
114
|
},
|
|
108
|
-
root: rootConversion.toRootPage(
|
|
115
|
+
root: rootConversion.toRootPage(lastFetchedOutput),
|
|
109
116
|
},
|
|
110
117
|
};
|
|
111
118
|
}
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
import { PageSchema } from "./types.ts";
|
|
26
26
|
import {
|
|
27
27
|
fetchNewestPage,
|
|
28
|
-
|
|
28
|
+
genOnDemandPageRequests,
|
|
29
29
|
type PageRange,
|
|
30
30
|
type PageRequest,
|
|
31
31
|
} from "../base/page.ts";
|
|
@@ -62,15 +62,17 @@ export class EvmFetcher
|
|
|
62
62
|
data: Input,
|
|
63
63
|
rootConversion: RootConversion<Output, RootOutput, RootPage>,
|
|
64
64
|
): Operation<DataFetched<Output, Page, RootPage>> {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const fetchAllBlocks = this.config.primitives.some(
|
|
66
|
+
(p) => p.primitive.getAllBlockHeaders,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const pageFetcher = genOnDemandPageRequests(
|
|
70
|
+
data.from,
|
|
71
|
+
data.to,
|
|
69
72
|
(page) => this.client.getBlock({ blockNumber: BigInt(page) }),
|
|
73
|
+
blockNumberRelation,
|
|
70
74
|
);
|
|
71
75
|
|
|
72
|
-
// TODO: if we expect multiple primitives per block
|
|
73
|
-
// it can, depending on the chain, be faster to just download the full block and parse it locally
|
|
74
76
|
const primitives = yield* this.readPrimitives(
|
|
75
77
|
data,
|
|
76
78
|
pageFetcher,
|
|
@@ -92,40 +94,43 @@ export class EvmFetcher
|
|
|
92
94
|
};
|
|
93
95
|
}),
|
|
94
96
|
),
|
|
95
|
-
// we always need
|
|
96
|
-
// so we can use it for pagination
|
|
97
|
+
// we always need the last page for pagination
|
|
97
98
|
call(() => pageFetcher(Number(data.to))),
|
|
98
99
|
],
|
|
99
100
|
);
|
|
100
101
|
|
|
101
|
-
// Build an output with all page info, as we need all the hashes to build the effectstream-block-hash
|
|
102
|
-
// All blocks are already pre-fetched in parallel by genImmediatePageRequests above
|
|
103
102
|
const allOutputs: Output[] = [];
|
|
104
103
|
for (let page = data.from; page <= data.to; page++) {
|
|
105
104
|
const _output = output.find((o) => o.raw.number === BigInt(page));
|
|
106
105
|
if (_output) {
|
|
107
106
|
allOutputs.push(_output);
|
|
108
|
-
} else {
|
|
107
|
+
} else if (fetchAllBlocks) {
|
|
109
108
|
const raw = yield* call(() => pageFetcher(page));
|
|
110
109
|
allOutputs.push({
|
|
111
110
|
raw,
|
|
112
111
|
primitives: [],
|
|
113
112
|
blockHashes: [raw.hash] as EvmBlockHash[],
|
|
114
113
|
});
|
|
114
|
+
} else if (page === data.to) {
|
|
115
|
+
allOutputs.push({
|
|
116
|
+
raw: lastPage,
|
|
117
|
+
primitives: [],
|
|
118
|
+
blockHashes: [lastPage.hash] as EvmBlockHash[],
|
|
119
|
+
});
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
return {
|
|
119
124
|
output: allOutputs.map((o) => ({
|
|
120
125
|
output: o,
|
|
121
|
-
cleanup: () => {},
|
|
126
|
+
cleanup: () => {},
|
|
122
127
|
})),
|
|
123
128
|
lastPage: {
|
|
124
129
|
own: Number(data.to),
|
|
125
130
|
ownBlockNumber: Number(data.to),
|
|
126
131
|
root: rootConversion.toRootPage({
|
|
127
132
|
blockHashes: [lastPage.hash] as EvmBlockHash[],
|
|
128
|
-
primitives: [],
|
|
133
|
+
primitives: [],
|
|
129
134
|
raw: lastPage,
|
|
130
135
|
}),
|
|
131
136
|
},
|
|
@@ -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,22 +82,27 @@ 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(
|
|
93
98
|
{ length: data.to - data.from + 1 },
|
|
94
99
|
(_, i) => i + data.from,
|
|
95
100
|
);
|
|
101
|
+
const fetchAllBlocks = this.config.primitives.some(
|
|
102
|
+
(p) => p.primitive.getAllBlockHeaders,
|
|
103
|
+
);
|
|
104
|
+
|
|
96
105
|
const fetched = yield* all(
|
|
97
|
-
// The endpoint does not support range requests,
|
|
98
|
-
// so we fetch each block individually in parallel.
|
|
99
|
-
// useAbortSignal() ties each fetch's lifetime to its Effection scope,
|
|
100
|
-
// so cancellation by all() cleanly aborts the underlying HTTP request.
|
|
101
106
|
heights.map(function* (height) {
|
|
102
107
|
const signal = yield* useAbortSignal();
|
|
103
108
|
const result: MidnightGqlBlockState = yield* call(() =>
|
|
@@ -117,9 +122,22 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
117
122
|
};
|
|
118
123
|
}),
|
|
119
124
|
);
|
|
120
|
-
outputs.push(...fetched);
|
|
121
125
|
|
|
122
|
-
const
|
|
126
|
+
const lastFetched = fetched[fetched.length - 1];
|
|
127
|
+
if (fetchAllBlocks) {
|
|
128
|
+
outputs.push(...fetched);
|
|
129
|
+
} else {
|
|
130
|
+
for (const item of fetched) {
|
|
131
|
+
if (item.output.primitives.length > 0) {
|
|
132
|
+
outputs.push(item);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (outputs.length === 0 || outputs[outputs.length - 1] !== lastFetched) {
|
|
136
|
+
outputs.push(lastFetched);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const lastOutput = lastFetched.output;
|
|
123
141
|
return {
|
|
124
142
|
output: outputs,
|
|
125
143
|
lastPage: {
|
|
@@ -146,6 +164,8 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
146
164
|
for (const primitiveEntry of primitiveEntries) {
|
|
147
165
|
if (primitiveEntry.primitive.type === "Midnight:Nullifier") {
|
|
148
166
|
syncResults.push(...this.fetchNullifiers(height, primitiveEntry, block));
|
|
167
|
+
} else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
|
|
168
|
+
syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
|
|
149
169
|
} else {
|
|
150
170
|
asyncOps.push(
|
|
151
171
|
this.fetchContractState(height, client, primitiveEntry, block),
|
|
@@ -191,6 +211,38 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
191
211
|
return results;
|
|
192
212
|
}
|
|
193
213
|
|
|
214
|
+
@bound
|
|
215
|
+
fetchUnshieldedSpends(
|
|
216
|
+
height: number,
|
|
217
|
+
primitiveEntry: PrimitiveEntryType,
|
|
218
|
+
block: MidnightGqlBlockState,
|
|
219
|
+
): PrimitiveType[] {
|
|
220
|
+
const results: PrimitiveType[] = [];
|
|
221
|
+
for (const tx of block.block.transactions) {
|
|
222
|
+
for (const spend of tx.unshieldedSpentOutputs ?? []) {
|
|
223
|
+
results.push({
|
|
224
|
+
syncProtocol: {
|
|
225
|
+
name: primitiveEntry.syncProtocol,
|
|
226
|
+
blockNumber: height,
|
|
227
|
+
transactionHash: tx.hash,
|
|
228
|
+
contractAddress: "",
|
|
229
|
+
},
|
|
230
|
+
primitive: primitiveEntry.primitive.name,
|
|
231
|
+
output: {
|
|
232
|
+
payloadType: "midnight-unshielded-spend",
|
|
233
|
+
payload: {
|
|
234
|
+
owner: spend.owner,
|
|
235
|
+
intentHash: spend.intentHash,
|
|
236
|
+
outputIndex: spend.outputIndex,
|
|
237
|
+
txHash: tx.hash,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return results;
|
|
244
|
+
}
|
|
245
|
+
|
|
194
246
|
@bound
|
|
195
247
|
*fetchContractState(
|
|
196
248
|
height: number,
|
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
if (this.slotOriginTimestamp === undefined) {
|
|
80
|
+
try {
|
|
81
|
+
const blocks = await this.syncClient.fetchHistory(undefined, 1);
|
|
82
|
+
if (blocks.length > 0) {
|
|
83
|
+
const blk = blocks[0].parsedBlock;
|
|
84
|
+
if (blk.timestamp && blk.header?.slot != null) {
|
|
85
|
+
this.slotOriginTimestamp = blk.timestamp - blk.header.slot;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Chain may not be ready yet — slotOriginTimestamp will be set on the first Apply event
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const intersect = point ? [point] : [];
|
|
94
|
+
const promises: Promise<void>[] = [];
|
|
95
|
+
for (const [key, watcher] of this.watchers) {
|
|
96
|
+
const watchClient = new CardanoWatchClient(this.clientOptions);
|
|
97
|
+
promises.push(this.runWatcher(key, watcher, watchClient, intersect));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(`[WatchMultiplexer] Started ${this.watchers.size} watcher(s)`);
|
|
101
|
+
await Promise.all(promises);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async runWatcher(
|
|
105
|
+
key: string,
|
|
106
|
+
watcher: WatcherState,
|
|
107
|
+
watchClient: CardanoWatchClient,
|
|
108
|
+
intersect: ChainPoint[],
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const stream = watchClient.watchTxByPredicate(watcher.sdkPredicate, intersect);
|
|
111
|
+
for await (const event of stream) {
|
|
112
|
+
this.handleEvent(key, watcher, event);
|
|
113
|
+
}
|
|
114
|
+
console.warn(`[WatchMultiplexer] Watcher "${key}" stream ended unexpectedly`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private handleEvent(
|
|
118
|
+
key: string,
|
|
119
|
+
watcher: WatcherState,
|
|
120
|
+
event: CardanoTxEvent,
|
|
121
|
+
): void {
|
|
122
|
+
if (event.action === "apply") {
|
|
123
|
+
const height = Number(event.Block.header!.height);
|
|
124
|
+
let pending = watcher.pendingBlocks.get(height);
|
|
125
|
+
if (!pending) {
|
|
126
|
+
pending = { txs: [], block: undefined, idle: false, idleRef: undefined };
|
|
127
|
+
watcher.pendingBlocks.set(height, pending);
|
|
128
|
+
}
|
|
129
|
+
pending.txs.push(event.Tx);
|
|
130
|
+
pending.block = event.Block;
|
|
131
|
+
pending.idle = false;
|
|
132
|
+
if (this.slotOriginTimestamp === undefined) {
|
|
133
|
+
const blk = event.Block;
|
|
134
|
+
this.slotOriginTimestamp = blk.timestamp - blk.header!.slot;
|
|
135
|
+
}
|
|
136
|
+
if (height > watcher.highestSeenHeight) {
|
|
137
|
+
watcher.highestSeenHeight = height;
|
|
138
|
+
}
|
|
139
|
+
this.tryCompleteBlocks();
|
|
140
|
+
} else if (event.action === "idle") {
|
|
141
|
+
const height = Number(event.BlockRef.height);
|
|
142
|
+
let pending = watcher.pendingBlocks.get(height);
|
|
143
|
+
if (!pending) {
|
|
144
|
+
pending = { txs: [], block: undefined, idle: true, idleRef: event.BlockRef };
|
|
145
|
+
watcher.pendingBlocks.set(height, pending);
|
|
146
|
+
} else {
|
|
147
|
+
pending.idle = true;
|
|
148
|
+
pending.idleRef = event.BlockRef;
|
|
149
|
+
}
|
|
150
|
+
if (height > watcher.highestSeenHeight) {
|
|
151
|
+
watcher.highestSeenHeight = height;
|
|
152
|
+
}
|
|
153
|
+
this.tryCompleteBlocks();
|
|
154
|
+
} else if (event.action === "undo") {
|
|
155
|
+
const height = Number(event.Block.header!.height);
|
|
156
|
+
this.handleUndo(height);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private handleUndo(fromHeight: number): void {
|
|
161
|
+
// Pop all completed blocks at or above the undo height
|
|
162
|
+
while (this.completedBuffer.length > 0) {
|
|
163
|
+
const last = this.completedBuffer.peekBack()!;
|
|
164
|
+
if (last.output.height < fromHeight) break;
|
|
165
|
+
this.completedBuffer.pop();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Clear pending blocks at or above the undo height for all watchers
|
|
169
|
+
for (const [, watcher] of this.watchers) {
|
|
170
|
+
for (const [h] of watcher.pendingBlocks) {
|
|
171
|
+
if (h >= fromHeight) {
|
|
172
|
+
watcher.pendingBlocks.delete(h);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (watcher.highestSeenHeight >= fromHeight) {
|
|
176
|
+
watcher.highestSeenHeight = fromHeight - 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Reset confirmed height
|
|
181
|
+
if (this.highestConfirmedHeight != null && this.highestConfirmedHeight >= fromHeight) {
|
|
182
|
+
this.highestConfirmedHeight = fromHeight - 1;
|
|
183
|
+
}
|
|
184
|
+
if (this.lowestIncompleteHeight >= fromHeight) {
|
|
185
|
+
this.lowestIncompleteHeight = fromHeight;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private tryCompleteBlocks(): void {
|
|
190
|
+
const minHighest = Math.min(...[...this.watchers.values()].map(w => w.highestSeenHeight));
|
|
191
|
+
if (minHighest <= 0) return;
|
|
192
|
+
|
|
193
|
+
let height = this.lowestIncompleteHeight;
|
|
194
|
+
if (height === 0) {
|
|
195
|
+
let lowest = Infinity;
|
|
196
|
+
for (const [, watcher] of this.watchers) {
|
|
197
|
+
for (const [h] of watcher.pendingBlocks) {
|
|
198
|
+
if (h < lowest) lowest = h;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (lowest === Infinity) return;
|
|
202
|
+
height = lowest;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A block at `height` is only safe to process once every watcher has
|
|
206
|
+
// advanced past it (received an event for height+1 or later). This
|
|
207
|
+
// guarantees all Apply events for that height have been delivered —
|
|
208
|
+
// the Watch protocol can send multiple Apply events per block.
|
|
209
|
+
while (height < minHighest) {
|
|
210
|
+
const completed = this.buildCompletedBlock(height);
|
|
211
|
+
if (completed) {
|
|
212
|
+
const finalityThreshold = minHighest - this.finality;
|
|
213
|
+
if (height <= finalityThreshold) {
|
|
214
|
+
this.completedBuffer.push({
|
|
215
|
+
output: completed,
|
|
216
|
+
cleanup: () => this.completedBuffer.shift(),
|
|
217
|
+
});
|
|
218
|
+
this.highestConfirmedHeight = height;
|
|
219
|
+
this.newDataCondVar.wake();
|
|
220
|
+
} else {
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const [, watcher] of this.watchers) {
|
|
226
|
+
watcher.pendingBlocks.delete(height);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
height++;
|
|
230
|
+
}
|
|
231
|
+
this.lowestIncompleteHeight = height;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private buildCompletedBlock(height: number): CompletedBlock | null {
|
|
235
|
+
let block: cardano.Block | undefined;
|
|
236
|
+
let slot = 0;
|
|
237
|
+
let hash = "";
|
|
238
|
+
const matchedTxsByPredicate = new Map<string, cardano.Tx[]>();
|
|
239
|
+
|
|
240
|
+
for (const [key, watcher] of this.watchers) {
|
|
241
|
+
const pending = watcher.pendingBlocks.get(height);
|
|
242
|
+
if (!pending) return null;
|
|
243
|
+
|
|
244
|
+
if (pending.block) {
|
|
245
|
+
block = pending.block;
|
|
246
|
+
slot = Number(pending.block.header!.slot);
|
|
247
|
+
hash = Buffer.from(pending.block.header!.hash).toString("hex");
|
|
248
|
+
} else if (pending.idleRef && !block) {
|
|
249
|
+
slot = Number(pending.idleRef.slot);
|
|
250
|
+
hash = Buffer.from(pending.idleRef.hash).toString("hex");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (pending.txs.length > 0) {
|
|
254
|
+
matchedTxsByPredicate.set(key, pending.txs);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!block) {
|
|
259
|
+
const idleSlot = BigInt(slot);
|
|
260
|
+
const idleHash = hexStringToUint8Array(hash);
|
|
261
|
+
const estimatedTimestamp = this.slotOriginTimestamp != null
|
|
262
|
+
? this.slotOriginTimestamp + idleSlot
|
|
263
|
+
: idleSlot;
|
|
264
|
+
block = new cardano.Block({
|
|
265
|
+
header: new cardano.BlockHeader({
|
|
266
|
+
slot: idleSlot,
|
|
267
|
+
hash: idleHash,
|
|
268
|
+
height: BigInt(height),
|
|
269
|
+
}),
|
|
270
|
+
body: new cardano.BlockBody({ tx: [] }),
|
|
271
|
+
timestamp: estimatedTimestamp,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { height, slot, hash, block, matchedTxsByPredicate };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public fetchBlocks(
|
|
279
|
+
from: BlockNumber,
|
|
280
|
+
to: BlockNumber,
|
|
281
|
+
): OutputAndCleanup<CompletedBlock>[] {
|
|
282
|
+
const blocks: OutputAndCleanup<CompletedBlock>[] = [];
|
|
283
|
+
for (let i = 0; i < this.completedBuffer.length; i++) {
|
|
284
|
+
const entry = this.completedBuffer.peekAt(i)!;
|
|
285
|
+
if (entry.output.height >= from) {
|
|
286
|
+
blocks.push(entry);
|
|
287
|
+
}
|
|
288
|
+
if (entry.output.height >= to) {
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return blocks;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
public lastHeight(): bigint | undefined {
|
|
296
|
+
if (this.highestConfirmedHeight == null) return undefined;
|
|
297
|
+
return BigInt(this.highestConfirmedHeight);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
}
|
|
@@ -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,63 +39,79 @@ export class UtxoRpcFetcher
|
|
|
40
39
|
}`,
|
|
41
40
|
);
|
|
42
41
|
const outputs: OutputAndCleanup<Output>[] = [];
|
|
43
|
-
const blocks = this.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
const blocks = this.multiplexer.fetchBlocks(data.from, data.to);
|
|
43
|
+
let lastEntry: typeof blocks[number] | undefined;
|
|
44
|
+
for (const entry of blocks) {
|
|
45
|
+
lastEntry = entry;
|
|
46
|
+
const completed = entry.output;
|
|
47
|
+
const primitives = this.findPrimitivesFromWatchData(completed);
|
|
48
|
+
if (primitives.length > 0) {
|
|
49
|
+
outputs.push({
|
|
50
|
+
output: {
|
|
51
|
+
blockHashes: [completed.hash],
|
|
52
|
+
raw: completed.block,
|
|
53
|
+
primitives,
|
|
54
|
+
},
|
|
55
|
+
cleanup: entry.cleanup,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
54
58
|
}
|
|
59
|
+
const lastCompleted = lastEntry!.output;
|
|
55
60
|
return {
|
|
56
61
|
output: outputs,
|
|
57
62
|
lastPage: {
|
|
58
63
|
ownBlockNumber: Number(data.to),
|
|
59
64
|
own: {
|
|
60
|
-
slot: Number(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
height: Number(
|
|
64
|
-
outputs[outputs.length - 1].output.raw.header!.height,
|
|
65
|
-
),
|
|
66
|
-
hash: Buffer.from(
|
|
67
|
-
outputs[outputs.length - 1].output.raw.header!.hash,
|
|
68
|
-
)
|
|
69
|
-
.toString("hex"),
|
|
65
|
+
slot: Number(lastCompleted.block.header!.slot),
|
|
66
|
+
height: Number(lastCompleted.block.header!.height),
|
|
67
|
+
hash: lastCompleted.hash,
|
|
70
68
|
},
|
|
71
69
|
root: rootConversion.toRootPage({
|
|
72
70
|
blockHashes: [],
|
|
73
|
-
primitives: [],
|
|
74
|
-
raw:
|
|
71
|
+
primitives: [],
|
|
72
|
+
raw: lastCompleted.block,
|
|
75
73
|
}),
|
|
76
74
|
},
|
|
77
75
|
};
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
@bound
|
|
81
|
-
|
|
79
|
+
findPrimitivesFromWatchData(completed: CompletedBlock): PrimitiveType[] {
|
|
82
80
|
const primitiveResponses: PrimitiveType[] = [];
|
|
83
|
-
|
|
81
|
+
const block = completed.block;
|
|
82
|
+
const txIndexCache = new Map<string, number>();
|
|
83
|
+
|
|
84
|
+
for (const primitiveEntry of this.config.primitives) {
|
|
85
|
+
const pKey = predicateKey(primitiveEntry.primitive.predicate);
|
|
86
|
+
const matchedTxs = completed.matchedTxsByPredicate.get(pKey)
|
|
87
|
+
?? this.findFallbackTxs(completed, primitiveEntry.primitive.predicate);
|
|
88
|
+
|
|
84
89
|
let logIndex = 0;
|
|
85
|
-
for (const
|
|
86
|
-
if (!matchesPredicate(tx,
|
|
90
|
+
for (const tx of matchedTxs) {
|
|
91
|
+
if (!matchesPredicate(tx, primitiveEntry.primitive.predicate)) {
|
|
87
92
|
continue;
|
|
88
93
|
}
|
|
89
|
-
|
|
94
|
+
|
|
95
|
+
const txHashHex = uint8ArrayToHexString(tx.hash);
|
|
96
|
+
let transactionIndex = txIndexCache.get(txHashHex);
|
|
97
|
+
if (transactionIndex === undefined) {
|
|
98
|
+
transactionIndex = block.body?.tx.findIndex(t =>
|
|
99
|
+
t.hash.length === tx.hash.length && t.hash.every((b, i) => b === tx.hash[i])
|
|
100
|
+
) ?? -1;
|
|
101
|
+
txIndexCache.set(txHashHex, transactionIndex);
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
primitiveResponses.push({
|
|
91
105
|
syncProtocol: {
|
|
92
|
-
name:
|
|
106
|
+
name: primitiveEntry.syncProtocol,
|
|
93
107
|
blockNumber: Number(block.header!.height),
|
|
94
|
-
transactionHash:
|
|
108
|
+
transactionHash: txHashHex,
|
|
95
109
|
transactionIndex,
|
|
96
110
|
contractAddress: '',
|
|
97
111
|
logIndex,
|
|
112
|
+
absoluteSlot: Number(block.header!.slot),
|
|
98
113
|
},
|
|
99
|
-
primitive:
|
|
114
|
+
primitive: primitiveEntry.primitive.name,
|
|
100
115
|
output: {
|
|
101
116
|
payloadType: 'utxorpc-response',
|
|
102
117
|
payload: { tx }
|
|
@@ -108,9 +123,30 @@ export class UtxoRpcFetcher
|
|
|
108
123
|
return primitiveResponses;
|
|
109
124
|
}
|
|
110
125
|
|
|
126
|
+
/**
|
|
127
|
+
* When a primitive's predicate key doesn't exactly match any watcher key
|
|
128
|
+
* (e.g., has_certificate primitives merged into match-all watcher),
|
|
129
|
+
* fall back to scanning all matched txs across all watchers.
|
|
130
|
+
*/
|
|
131
|
+
private findFallbackTxs(
|
|
132
|
+
completed: CompletedBlock,
|
|
133
|
+
_predicate: unknown,
|
|
134
|
+
): cardano.Tx[] {
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
const txs: cardano.Tx[] = [];
|
|
137
|
+
for (const [, watcherTxs] of completed.matchedTxsByPredicate) {
|
|
138
|
+
for (const tx of watcherTxs) {
|
|
139
|
+
const hash = uint8ArrayToHexString(tx.hash);
|
|
140
|
+
if (!seen.has(hash)) {
|
|
141
|
+
seen.add(hash);
|
|
142
|
+
txs.push(tx);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return txs;
|
|
147
|
+
}
|
|
148
|
+
|
|
111
149
|
lastHeight(): bigint | undefined {
|
|
112
|
-
|
|
113
|
-
// returns the hash and slot, but not the block height
|
|
114
|
-
return this.client.lastHeight();
|
|
150
|
+
return this.multiplexer.lastHeight();
|
|
115
151
|
}
|
|
116
152
|
}
|
|
@@ -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
|
+
}
|