agentex-creator-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/packages/contracts/src/deployer-investigation.d.ts +1015 -0
- package/dist/packages/contracts/src/deployer-investigation.js +101 -0
- package/dist/packages/contracts/src/index.d.ts +1701 -0
- package/dist/packages/contracts/src/index.js +380 -0
- package/dist/packages/contracts/src/indexed-activity.d.ts +684 -0
- package/dist/packages/contracts/src/indexed-activity.js +71 -0
- package/dist/packages/contracts/src/indexed-agents.d.ts +299 -0
- package/dist/packages/contracts/src/indexed-agents.js +131 -0
- package/dist/packages/contracts/src/inspection.d.ts +906 -0
- package/dist/packages/contracts/src/inspection.js +114 -0
- package/dist/packages/contracts/src/kinds.d.ts +5398 -0
- package/dist/packages/contracts/src/kinds.js +156 -0
- package/dist/packages/contracts/src/report-presentation.d.ts +346 -0
- package/dist/packages/contracts/src/report-presentation.js +120 -0
- package/dist/packages/contracts/src/solana-inspection.d.ts +451 -0
- package/dist/packages/contracts/src/solana-inspection.js +94 -0
- package/dist/packages/contracts/src/token-market.d.ts +193 -0
- package/dist/packages/contracts/src/token-market.js +335 -0
- package/dist/packages/contracts/src/wallet-analysis.d.ts +866 -0
- package/dist/packages/contracts/src/wallet-analysis.js +89 -0
- package/dist/packages/contracts/src/watchtower.d.ts +1141 -0
- package/dist/packages/contracts/src/watchtower.js +196 -0
- package/dist/packages/contracts/src/workflow.d.ts +1568 -0
- package/dist/packages/contracts/src/workflow.js +651 -0
- package/dist/packages/inspector/src/decode.d.ts +23 -0
- package/dist/packages/inspector/src/decode.js +150 -0
- package/dist/packages/inspector/src/scope.d.ts +87 -0
- package/dist/packages/inspector/src/scope.js +64 -0
- package/dist/packages/model/src/analysis.d.ts +149 -0
- package/dist/packages/model/src/analysis.js +387 -0
- package/dist/packages/model/src/pricing.d.ts +38 -0
- package/dist/packages/model/src/pricing.js +49 -0
- package/dist/packages/model/src/retry.d.ts +20 -0
- package/dist/packages/model/src/retry.js +31 -0
- package/dist/packages/model/src/schema.d.ts +10 -0
- package/dist/packages/model/src/schema.js +51 -0
- package/dist/packages/model/src/summary.d.ts +91 -0
- package/dist/packages/model/src/summary.js +177 -0
- package/dist/packages/model/src/types.d.ts +81 -0
- package/dist/packages/model/src/types.js +19 -0
- package/dist/packages/monitoring/src/delivery.d.ts +32 -0
- package/dist/packages/monitoring/src/delivery.js +53 -0
- package/dist/packages/providers/src/chain-transport.d.ts +42 -0
- package/dist/packages/providers/src/chain-transport.js +57 -0
- package/dist/packages/providers/src/coverage.d.ts +105 -0
- package/dist/packages/providers/src/coverage.js +260 -0
- package/dist/packages/providers/src/health.d.ts +273 -0
- package/dist/packages/providers/src/health.js +505 -0
- package/dist/packages/providers/src/keyed.d.ts +96 -0
- package/dist/packages/providers/src/keyed.js +240 -0
- package/dist/packages/providers/src/snapshot.d.ts +61 -0
- package/dist/packages/providers/src/snapshot.js +77 -0
- package/dist/packages/publication/src/fixtures.d.ts +45 -0
- package/dist/packages/publication/src/fixtures.js +350 -0
- package/dist/packages/research/src/index.d.ts +188 -0
- package/dist/packages/research/src/index.js +829 -0
- package/dist/packages/runtime/src/checkpoints.d.ts +65 -0
- package/dist/packages/runtime/src/checkpoints.js +214 -0
- package/dist/packages/runtime/src/policy.d.ts +57 -0
- package/dist/packages/runtime/src/policy.js +296 -0
- package/dist/packages/sdk/src/bin/agentex-buyer.d.ts +2 -0
- package/dist/packages/sdk/src/bin/agentex-buyer.js +4 -0
- package/dist/packages/sdk/src/bin/agentex.d.ts +2 -0
- package/dist/packages/sdk/src/bin/agentex.js +3 -0
- package/dist/packages/sdk/src/buyer-cli.d.ts +10 -0
- package/dist/packages/sdk/src/buyer-cli.js +210 -0
- package/dist/packages/sdk/src/buyer.d.ts +534 -0
- package/dist/packages/sdk/src/buyer.js +441 -0
- package/dist/packages/sdk/src/cli.d.ts +14 -0
- package/dist/packages/sdk/src/cli.js +149 -0
- package/dist/packages/sdk/src/errors.d.ts +31 -0
- package/dist/packages/sdk/src/errors.js +24 -0
- package/dist/packages/sdk/src/index.d.ts +236 -0
- package/dist/packages/sdk/src/index.js +150 -0
- package/dist/packages/sdk/src/local.d.ts +11 -0
- package/dist/packages/sdk/src/local.js +110 -0
- package/dist/packages/sdk/src/rails.d.ts +59 -0
- package/dist/packages/sdk/src/rails.js +96 -0
- package/dist/packages/sdk/src/report.d.ts +121 -0
- package/dist/packages/sdk/src/report.js +114 -0
- package/dist/packages/sdk/src/version.d.ts +2 -0
- package/dist/packages/sdk/src/version.js +2 -0
- package/dist/packages/watchtower/src/index.d.ts +150 -0
- package/dist/packages/watchtower/src/index.js +786 -0
- package/dist/packages/workflow/src/registry.d.ts +61 -0
- package/dist/packages/workflow/src/registry.js +76 -0
- package/examples/README.md +34 -0
- package/examples/cli-usage.sh +30 -0
- package/examples/fixtures/base-weth-input.json +4 -0
- package/examples/focused-researcher.json +127 -0
- package/examples/pay-with-eth-robinhood.mts +37 -0
- package/examples/pay-with-usdc.mts +41 -0
- package/examples/quickstart.mts +73 -0
- package/package.json +50 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { createPublicClient, getAddress, http, keccak256, toEventSelector, toHex } from 'viem';
|
|
2
|
+
import { AgentManifestSchema, contentHash } from '../../contracts/src/index.js';
|
|
3
|
+
import { CONTROL_EVENTS, GOVERNANCE_EVENTS, POOL_EVENTS, SupportedWatchtowerManifestSchema, WATCHTOWER_MANIFEST, WATCH_MAX_LOGS, WATCH_RPC_CEILING, WATCH_TOOL, WatchEvidenceItemSchema, WatchtowerWindowInputSchema, WatchtowerWindowReportSchema, watchRuleRefusal } from '../../contracts/src/watchtower.js';
|
|
4
|
+
import { EvmReadFailure, FAILURE_MESSAGES, categorizeRpcFailure, servedProviderLabel, parseBlockHeader, parseQuantity, resolveChainConfiguration } from '../../research/src/index.js';
|
|
5
|
+
const RPC_DOCUMENTATION = 'https://ethereum.org/en/developers/docs/apis/json-rpc/';
|
|
6
|
+
const OUTPUT_CEILING = 262144;
|
|
7
|
+
const HEX32 = /^0x[a-fA-F0-9]{64}$/;
|
|
8
|
+
const MAX_UINT256 = 2n ** 256n - 1n;
|
|
9
|
+
export const WATCH_TOPICS = {
|
|
10
|
+
Transfer: toEventSelector('Transfer(address,address,uint256)'),
|
|
11
|
+
Approval: toEventSelector('Approval(address,address,uint256)'),
|
|
12
|
+
Upgraded: toEventSelector('Upgraded(address)'),
|
|
13
|
+
AdminChanged: toEventSelector('AdminChanged(address,address)'),
|
|
14
|
+
BeaconUpgraded: toEventSelector('BeaconUpgraded(address)'),
|
|
15
|
+
OwnershipTransferred: toEventSelector('OwnershipTransferred(address,address)'),
|
|
16
|
+
};
|
|
17
|
+
/** 0.3.0 pool events. Uniswap V2 forks, Uniswap V3 forks and Aerodrome (Solidly) pools use different Swap and Burn layouts. */
|
|
18
|
+
export const POOL_TOPICS = {
|
|
19
|
+
'uniswap-v2': { Swap: toEventSelector('Swap(address,uint256,uint256,uint256,uint256,address)'), Mint: toEventSelector('Mint(address,uint256,uint256)'), Burn: toEventSelector('Burn(address,uint256,uint256,address)') },
|
|
20
|
+
'uniswap-v3': { Swap: toEventSelector('Swap(address,address,int256,int256,uint160,uint128,int24)'), Mint: toEventSelector('Mint(address,address,int24,int24,uint128,uint256,uint256)'), Burn: toEventSelector('Burn(address,int24,int24,uint128,uint256,uint256)') },
|
|
21
|
+
aerodrome: { Swap: toEventSelector('Swap(address,address,uint256,uint256,uint256,uint256)'), Mint: toEventSelector('Mint(address,uint256,uint256)'), Burn: toEventSelector('Burn(address,address,uint256,uint256)') },
|
|
22
|
+
};
|
|
23
|
+
/** 0.3.0 governor, OpenZeppelin TimelockController and Compound Timelock events. */
|
|
24
|
+
export const GOVERNANCE_TOPICS = Object.freeze({
|
|
25
|
+
ProposalCreated: toEventSelector('ProposalCreated(uint256,address,address[],uint256[],string[],bytes[],uint256,uint256,string)'), ProposalQueued: toEventSelector('ProposalQueued(uint256,uint256)'),
|
|
26
|
+
ProposalExecuted: toEventSelector('ProposalExecuted(uint256)'), ProposalCanceled: toEventSelector('ProposalCanceled(uint256)'),
|
|
27
|
+
CallScheduled: toEventSelector('CallScheduled(bytes32,uint256,address,uint256,bytes,bytes32,uint256)'), CallExecuted: toEventSelector('CallExecuted(bytes32,uint256,address,uint256,bytes)'),
|
|
28
|
+
Cancelled: toEventSelector('Cancelled(bytes32)'), MinDelayChange: toEventSelector('MinDelayChange(uint256,uint256)'),
|
|
29
|
+
QueueTransaction: toEventSelector('QueueTransaction(bytes32,address,uint256,string,bytes,uint256)'), ExecuteTransaction: toEventSelector('ExecuteTransaction(bytes32,address,uint256,string,bytes,uint256)'),
|
|
30
|
+
CancelTransaction: toEventSelector('CancelTransaction(bytes32,address,uint256,string,bytes,uint256)'), NewDelay: toEventSelector('NewDelay(uint256)'), NewAdmin: toEventSelector('NewAdmin(address)'), NewPendingAdmin: toEventSelector('NewPendingAdmin(address)'),
|
|
31
|
+
});
|
|
32
|
+
/** 0.3.0 OpenZeppelin AccessControl and Pausable events. */
|
|
33
|
+
export const CONTROL_TOPICS = Object.freeze({
|
|
34
|
+
RoleGranted: toEventSelector('RoleGranted(bytes32,address,address)'), RoleRevoked: toEventSelector('RoleRevoked(bytes32,address,address)'), RoleAdminChanged: toEventSelector('RoleAdminChanged(bytes32,bytes32,bytes32)'),
|
|
35
|
+
Paused: toEventSelector('Paused(address)'), Unpaused: toEventSelector('Unpaused(address)'),
|
|
36
|
+
});
|
|
37
|
+
/** Names for widely used AccessControl role ids, used only to explain an event. An unknown role is shown by its id. */
|
|
38
|
+
export const KNOWN_ROLES = Object.freeze(Object.fromEntries([[`0x${'0'.repeat(64)}`, 'DEFAULT_ADMIN_ROLE'],
|
|
39
|
+
...['MINTER_ROLE', 'BURNER_ROLE', 'PAUSER_ROLE', 'UPGRADER_ROLE', 'OPERATOR_ROLE', 'ADMIN_ROLE', 'PROPOSER_ROLE', 'EXECUTOR_ROLE', 'CANCELLER_ROLE', 'TIMELOCK_ADMIN_ROLE', 'GUARDIAN_ROLE', 'MANAGER_ROLE', 'RESCUER_ROLE', 'BLACKLISTER_ROLE', 'FREEZER_ROLE', 'ORACLE_ROLE', 'KEEPER_ROLE']
|
|
40
|
+
.map((name) => [keccak256(toHex(name)), name])]));
|
|
41
|
+
export const SHARED_READ_PROVIDER_SUFFIX = '-shared-public-read';
|
|
42
|
+
export function sharedReadKey(chain, chainId, rule, input, fromBlockHash, toBlockHash) {
|
|
43
|
+
const filter = logFilter(rule, input);
|
|
44
|
+
const edges = { fromBlockHash: fromBlockHash.toLowerCase(), toBlockHash: toBlockHash.toLowerCase() };
|
|
45
|
+
return { key: contentHash({ kind: 'agentex.watchtower.shared-logs.v1', chainId, filter, ...edges }), chain, chainId, fromBlock: input.fromBlock, toBlock: input.toBlock, ...edges, address: Array.isArray(filter.address) ? filter.address.join(',') : filter.address, topics: filter.topics };
|
|
46
|
+
}
|
|
47
|
+
export const blockTag = (block) => `0x${BigInt(block).toString(16)}`;
|
|
48
|
+
const topicOf = (address) => `0x${'0'.repeat(24)}${address.slice(2).toLowerCase()}`;
|
|
49
|
+
/** Contracts whose logs a rule reads: one contract, or the token list of a 0.3.0 wallet-token-flow rule. */
|
|
50
|
+
const ruleAddresses = (rule) => rule.type === 'wallet-token-flow' ? rule.tokens.map((token) => token.toLowerCase())
|
|
51
|
+
: [(rule.type === 'erc20-transfer' || rule.type === 'erc20-approval' ? rule.token : rule.type === 'pool-activity' ? rule.pool : rule.contract).toLowerCase()];
|
|
52
|
+
const selectedPoolEvents = (rule) => rule.events ?? [...POOL_EVENTS];
|
|
53
|
+
/** The exact, deterministic eth_getLogs filter for a rule. Receipt validation checks saved requests against these filters. */
|
|
54
|
+
export function logFilter(rule, input) {
|
|
55
|
+
const topics = rule.type === 'erc20-transfer' ? [WATCH_TOPICS.Transfer, rule.from ? topicOf(rule.from) : null, rule.to ? topicOf(rule.to) : null]
|
|
56
|
+
: rule.type === 'erc20-approval' ? [WATCH_TOPICS.Approval, topicOf(rule.owner), rule.spender ? topicOf(rule.spender) : null]
|
|
57
|
+
: rule.type === 'wallet-token-flow' ? (rule.direction === 'out' ? [WATCH_TOPICS.Transfer, topicOf(rule.wallet)] : [WATCH_TOPICS.Transfer, null, topicOf(rule.wallet)])
|
|
58
|
+
: rule.type === 'pool-activity' ? [selectedPoolEvents(rule).map((name) => POOL_TOPICS[rule.dex][name])]
|
|
59
|
+
: rule.type === 'governance-change' ? [(rule.events ?? [...GOVERNANCE_EVENTS]).map((name) => GOVERNANCE_TOPICS[name])]
|
|
60
|
+
: rule.type === 'control-change' ? [(rule.events ?? [...CONTROL_EVENTS]).map((name) => CONTROL_TOPICS[name])]
|
|
61
|
+
: [(rule.events ?? Object.keys(WATCH_TOPICS).filter((name) => !['Transfer', 'Approval'].includes(name))).map((name) => WATCH_TOPICS[name])];
|
|
62
|
+
while (topics.length > 1 && topics.at(-1) === null)
|
|
63
|
+
topics.pop();
|
|
64
|
+
const addresses = ruleAddresses(rule);
|
|
65
|
+
return { address: rule.type === 'wallet-token-flow' ? addresses : addresses[0], fromBlock: blockTag(input.fromBlock), toBlock: blockTag(input.toBlock), topics };
|
|
66
|
+
}
|
|
67
|
+
/** Wallet flows name a participant and read an address list, so they are never shared across monitors (R01). */
|
|
68
|
+
const shareableRule = (rule) => rule.type !== 'wallet-token-flow';
|
|
69
|
+
export const watchRequests = {
|
|
70
|
+
logs: (rule, input) => ({ method: 'eth_getLogs', params: [logFilter(rule, input)] }),
|
|
71
|
+
header: (tag) => ({ method: 'eth_getBlockByNumber', params: [tag, false] }),
|
|
72
|
+
};
|
|
73
|
+
export const requestKey = (request) => contentHash({ method: request.method, params: request.params });
|
|
74
|
+
/** Method, request and snapshot scope for a Watchtower receipt. Schema, chain identity and hash checks stay with the caller. */
|
|
75
|
+
export function watchReceiptInScope(receipt, input) {
|
|
76
|
+
const { method } = receipt.source;
|
|
77
|
+
const params = receipt.request;
|
|
78
|
+
if (method === 'eth_chainId')
|
|
79
|
+
return params.length === 0 && receipt.snapshot === null;
|
|
80
|
+
if (method === 'eth_getBlockByNumber') {
|
|
81
|
+
if (params.length !== 2 || params[1] !== false || typeof params[0] !== 'string' || receipt.snapshot !== null)
|
|
82
|
+
return false;
|
|
83
|
+
// `latest` is the instant-read head; `finalized` only admits receipts checkpointed before that rule.
|
|
84
|
+
if (params[0] === 'latest' || params[0] === 'finalized')
|
|
85
|
+
return true;
|
|
86
|
+
if (!/^0x(?:0|[1-9a-fA-F][a-fA-F0-9]*)$/.test(params[0]))
|
|
87
|
+
return false;
|
|
88
|
+
const block = BigInt(params[0]);
|
|
89
|
+
return block >= BigInt(input.fromBlock) && block <= BigInt(input.toBlock);
|
|
90
|
+
}
|
|
91
|
+
if (method !== 'eth_getLogs' || !receipt.snapshot || (receipt.finality !== 'sequencer-confirmed' && receipt.finality !== 'finalized') || receipt.snapshot.blockNumber !== input.toBlock || params.length !== 1)
|
|
92
|
+
return false;
|
|
93
|
+
const planned = new Set(input.rules.map((rule) => requestKey(JSON.parse(JSON.stringify(watchRequests.logs(rule, input))))));
|
|
94
|
+
return planned.has(requestKey({ method, params }));
|
|
95
|
+
}
|
|
96
|
+
const invalid = (message) => new EvmReadFailure('invalid-response', message);
|
|
97
|
+
export function parseLogs(raw, input) {
|
|
98
|
+
if (!Array.isArray(raw) || raw.length > 100000)
|
|
99
|
+
throw invalid('eth_getLogs did not return a bounded log array.');
|
|
100
|
+
const from = BigInt(input.fromBlock);
|
|
101
|
+
const to = BigInt(input.toBlock);
|
|
102
|
+
return raw.map((item) => {
|
|
103
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
104
|
+
throw invalid('Malformed log.');
|
|
105
|
+
const log = item;
|
|
106
|
+
if (typeof log.address !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(log.address) || !Array.isArray(log.topics) || log.topics.length > 4 || log.topics.some((topic) => typeof topic !== 'string' || !HEX32.test(topic))
|
|
107
|
+
|| typeof log.data !== 'string' || !/^0x(?:[a-fA-F0-9]{2})*$/.test(log.data) || log.data.length > 2 + 2 * 4096 || typeof log.blockHash !== 'string' || !HEX32.test(log.blockHash)
|
|
108
|
+
|| typeof log.transactionHash !== 'string' || !HEX32.test(log.transactionHash) || (log.removed !== undefined && typeof log.removed !== 'boolean'))
|
|
109
|
+
throw invalid('Malformed log fields.');
|
|
110
|
+
const blockNumber = parseQuantity(log.blockNumber);
|
|
111
|
+
const logIndex = parseQuantity(log.logIndex);
|
|
112
|
+
if (blockNumber < from || blockNumber > to)
|
|
113
|
+
throw invalid('A returned log lies outside the requested window.');
|
|
114
|
+
if (logIndex > 1000000n)
|
|
115
|
+
throw invalid('Log index out of range.');
|
|
116
|
+
return { address: log.address.toLowerCase(), topics: log.topics.map((topic) => topic.toLowerCase()), data: log.data.toLowerCase(), blockNumber, blockHash: log.blockHash.toLowerCase(),
|
|
117
|
+
transactionHash: log.transactionHash.toLowerCase(), logIndex: Number(logIndex), removed: log.removed === true, raw: item };
|
|
118
|
+
}).sort((a, b) => a.blockNumber === b.blockNumber ? a.logIndex - b.logIndex : a.blockNumber < b.blockNumber ? -1 : 1);
|
|
119
|
+
}
|
|
120
|
+
/** Keeps whole blocks only: if the cap falls inside a block, that block and later blocks are reported as not examined. */
|
|
121
|
+
export function capLogs(logs, capacity, input) {
|
|
122
|
+
if (logs.length <= capacity)
|
|
123
|
+
return { kept: logs, coveredThrough: BigInt(input.toBlock), truncated: false };
|
|
124
|
+
const coveredThrough = logs[capacity].blockNumber - 1n;
|
|
125
|
+
return { kept: logs.filter((log) => log.blockNumber <= coveredThrough), coveredThrough, truncated: true };
|
|
126
|
+
}
|
|
127
|
+
const addressFromTopic = (topic) => topic.slice(2, 26) === '0'.repeat(24) ? getAddress(`0x${topic.slice(26)}`) : null;
|
|
128
|
+
const addressFromWord = (data, index) => { const word = data.slice(2 + index * 64, 2 + (index + 1) * 64); return word.length === 64 && word.slice(0, 24) === '0'.repeat(24) ? getAddress(`0x${word.slice(24)}`) : null; };
|
|
129
|
+
/** Rules fire only on their stated conditions. The provider filter is not trusted: address, topic layout, data size and every filter are re-checked here. */
|
|
130
|
+
// --------------------------------------------------------------------------------------------------- 0.3.0 rule matchers
|
|
131
|
+
const TOPIC_MISMATCH = { fired: false, reason: 'topic-mismatch' };
|
|
132
|
+
const DATA_MISMATCH = { fired: false, reason: 'data-mismatch' };
|
|
133
|
+
const wordCount = (data) => (data.length - 2) / 64;
|
|
134
|
+
const uintAt = (data, index) => { const word = data.slice(2 + index * 64, 2 + (index + 1) * 64); return word.length === 64 ? BigInt(`0x${word}`) : null; };
|
|
135
|
+
const intAt = (data, index) => { const value = uintAt(data, index); return value === null ? null : value >= 2n ** 255n ? value - 2n ** 256n : value; };
|
|
136
|
+
const magnitude = (value) => value < 0n ? -value : value;
|
|
137
|
+
/** Dynamic ABI bytes whose head offset sits in word `index`. Offsets must be word-aligned and inside the data; returns null otherwise. */
|
|
138
|
+
function dynamicAt(data, index, maximumBytes) {
|
|
139
|
+
const offset = uintAt(data, index);
|
|
140
|
+
const size = (data.length - 2) / 2;
|
|
141
|
+
if (offset === null || offset % 32n !== 0n || offset + 32n > BigInt(size))
|
|
142
|
+
return null;
|
|
143
|
+
const start = 2 + Number(offset) * 2;
|
|
144
|
+
const length = BigInt(`0x${data.slice(start, start + 64)}`);
|
|
145
|
+
if (length > BigInt(size) - offset - 32n)
|
|
146
|
+
return null;
|
|
147
|
+
return { hex: data.slice(start + 64, start + 64 + Math.min(Number(length), maximumBytes) * 2), length: Number(length) };
|
|
148
|
+
}
|
|
149
|
+
/** Untrusted on-chain text reduced to a short single printable line; never interpreted. */
|
|
150
|
+
/** Control, invisible and bidirectional-override characters in untrusted event text. */
|
|
151
|
+
const UNSAFE_TEXT = new RegExp(String.raw `[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]+`, 'g');
|
|
152
|
+
const shortText = (hex, maximum) => Buffer.from(hex, 'hex').toString('utf8').replace(UNSAFE_TEXT, ' ').replace(/^[#\s]+/, '').replace(/\s+/g, ' ').trim().slice(0, maximum);
|
|
153
|
+
const roleName = (role) => KNOWN_ROLES[role] ?? `role ${role.slice(0, 10)}…`;
|
|
154
|
+
function matchWalletFlow(rule, log) {
|
|
155
|
+
if (log.topics[0] !== WATCH_TOPICS.Transfer || log.topics.length !== 3)
|
|
156
|
+
return TOPIC_MISMATCH;
|
|
157
|
+
if (log.data.length !== 66)
|
|
158
|
+
return DATA_MISMATCH;
|
|
159
|
+
const from = addressFromTopic(log.topics[1]);
|
|
160
|
+
const to = addressFromTopic(log.topics[2]);
|
|
161
|
+
if (!from || !to)
|
|
162
|
+
return TOPIC_MISMATCH;
|
|
163
|
+
const wallet = rule.wallet.toLowerCase();
|
|
164
|
+
if ((rule.direction === 'in' ? to : from).toLowerCase() !== wallet)
|
|
165
|
+
return { fired: false, reason: 'filter-mismatch' };
|
|
166
|
+
const amount = BigInt(log.data);
|
|
167
|
+
const token = getAddress(log.address);
|
|
168
|
+
const counterparty = rule.direction === 'in' ? from : to;
|
|
169
|
+
return { fired: true, event: 'Transfer', fields: { token, from, to, amountAtomic: amount.toString(), direction: rule.direction },
|
|
170
|
+
explanation: `Rule ${rule.id}: ${getAddress(rule.wallet)} ${rule.direction === 'in' ? 'received' : 'sent'} ${amount} atomic units of ${token} ${rule.direction === 'in' ? 'from' : 'to'} ${counterparty}.` };
|
|
171
|
+
}
|
|
172
|
+
function matchPool(rule, log) {
|
|
173
|
+
const selected = selectedPoolEvents(rule);
|
|
174
|
+
const topics = POOL_TOPICS[rule.dex];
|
|
175
|
+
const name = Object.entries(topics).find(([, value]) => value === log.topics[0])?.[0];
|
|
176
|
+
if (!name || !selected.includes(name))
|
|
177
|
+
return TOPIC_MISMATCH;
|
|
178
|
+
const v3 = rule.dex === 'uniswap-v3';
|
|
179
|
+
const words = wordCount(log.data);
|
|
180
|
+
const pool = getAddress(log.address);
|
|
181
|
+
let fields;
|
|
182
|
+
let amount0;
|
|
183
|
+
let amount1;
|
|
184
|
+
let explanation;
|
|
185
|
+
if (name === 'Swap') {
|
|
186
|
+
if (log.topics.length !== 3)
|
|
187
|
+
return TOPIC_MISMATCH;
|
|
188
|
+
const sender = addressFromTopic(log.topics[1]);
|
|
189
|
+
const recipient = addressFromTopic(log.topics[2]);
|
|
190
|
+
if (!sender || !recipient)
|
|
191
|
+
return TOPIC_MISMATCH;
|
|
192
|
+
if (v3) {
|
|
193
|
+
if (words !== 5)
|
|
194
|
+
return DATA_MISMATCH;
|
|
195
|
+
const a0 = intAt(log.data, 0);
|
|
196
|
+
const a1 = intAt(log.data, 1);
|
|
197
|
+
const sqrtPrice = uintAt(log.data, 2);
|
|
198
|
+
const tick = intAt(log.data, 4);
|
|
199
|
+
if (sqrtPrice >= 2n ** 160n || tick < -887272n || tick > 887272n)
|
|
200
|
+
return DATA_MISMATCH;
|
|
201
|
+
amount0 = magnitude(a0);
|
|
202
|
+
amount1 = magnitude(a1);
|
|
203
|
+
fields = { sender, recipient, amount0Atomic: a0.toString(), amount1Atomic: a1.toString(), sqrtPriceX96: sqrtPrice.toString(), tick: tick.toString() };
|
|
204
|
+
explanation = `Rule ${rule.id}: swap on ${pool}: token0 ${a0 >= 0n ? 'into' : 'out of'} the pool ${amount0}, token1 ${a1 >= 0n ? 'into' : 'out of'} the pool ${amount1} (atomic units); recipient ${recipient}.`;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
if (words !== 4)
|
|
208
|
+
return DATA_MISMATCH;
|
|
209
|
+
const [in0, in1, out0, out1] = [0, 1, 2, 3].map((index) => uintAt(log.data, index));
|
|
210
|
+
amount0 = in0 > out0 ? in0 : out0;
|
|
211
|
+
amount1 = in1 > out1 ? in1 : out1;
|
|
212
|
+
fields = { sender, recipient, amount0InAtomic: in0.toString(), amount1InAtomic: in1.toString(), amount0OutAtomic: out0.toString(), amount1OutAtomic: out1.toString() };
|
|
213
|
+
explanation = `Rule ${rule.id}: swap on ${pool}: ${in0} token0 and ${in1} token1 in, ${out0} token0 and ${out1} token1 out (atomic units); recipient ${recipient}.`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (v3) {
|
|
217
|
+
if (log.topics.length !== 4)
|
|
218
|
+
return TOPIC_MISMATCH;
|
|
219
|
+
if (words !== (name === 'Mint' ? 4 : 3))
|
|
220
|
+
return DATA_MISMATCH;
|
|
221
|
+
const owner = addressFromTopic(log.topics[1]);
|
|
222
|
+
const lower = BigInt.asIntN(24, BigInt(log.topics[2]));
|
|
223
|
+
const upper = BigInt.asIntN(24, BigInt(log.topics[3]));
|
|
224
|
+
if (!owner)
|
|
225
|
+
return TOPIC_MISMATCH;
|
|
226
|
+
const offset = name === 'Mint' ? 1 : 0;
|
|
227
|
+
const liquidity = uintAt(log.data, offset);
|
|
228
|
+
amount0 = uintAt(log.data, offset + 1);
|
|
229
|
+
amount1 = uintAt(log.data, offset + 2);
|
|
230
|
+
fields = { owner, tickLower: lower.toString(), tickUpper: upper.toString(), liquidity: liquidity.toString(), amount0Atomic: amount0.toString(), amount1Atomic: amount1.toString() };
|
|
231
|
+
explanation = `Rule ${rule.id}: ${name === 'Mint' ? 'liquidity added to' : 'liquidity removed from'} ${pool} by ${owner}: ${amount0} token0 and ${amount1} token1 (atomic units), ticks ${lower} to ${upper}.`;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
if (log.topics.length !== (name === 'Mint' ? 2 : 3))
|
|
235
|
+
return TOPIC_MISMATCH;
|
|
236
|
+
if (words !== 2)
|
|
237
|
+
return DATA_MISMATCH;
|
|
238
|
+
const sender = addressFromTopic(log.topics[1]);
|
|
239
|
+
const to = name === 'Burn' ? addressFromTopic(log.topics[2]) : null;
|
|
240
|
+
if (!sender || (name === 'Burn' && !to))
|
|
241
|
+
return TOPIC_MISMATCH;
|
|
242
|
+
amount0 = uintAt(log.data, 0);
|
|
243
|
+
amount1 = uintAt(log.data, 1);
|
|
244
|
+
fields = { sender, ...(to ? { to } : {}), amount0Atomic: amount0.toString(), amount1Atomic: amount1.toString() };
|
|
245
|
+
explanation = `Rule ${rule.id}: ${name === 'Mint' ? 'liquidity added to' : 'liquidity removed from'} ${pool}: ${amount0} token0 and ${amount1} token1 (atomic units)${to ? `, sent to ${to}` : ''}.`;
|
|
246
|
+
}
|
|
247
|
+
const min0 = rule.minimumAmount0Atomic === undefined ? null : BigInt(rule.minimumAmount0Atomic);
|
|
248
|
+
const min1 = rule.minimumAmount1Atomic === undefined ? null : BigInt(rule.minimumAmount1Atomic);
|
|
249
|
+
if ((min0 !== null || min1 !== null) && !((min0 !== null && amount0 >= min0) || (min1 !== null && amount1 >= min1)))
|
|
250
|
+
return { fired: false, reason: 'below-minimum' };
|
|
251
|
+
return { fired: true, event: name, fields, explanation };
|
|
252
|
+
}
|
|
253
|
+
function matchGovernance(rule, log) {
|
|
254
|
+
const name = Object.entries(GOVERNANCE_TOPICS).find(([, value]) => value === log.topics[0])?.[0];
|
|
255
|
+
if (!name || !(rule.events ?? GOVERNANCE_EVENTS).includes(name))
|
|
256
|
+
return TOPIC_MISMATCH;
|
|
257
|
+
const contract = getAddress(log.address);
|
|
258
|
+
const words = wordCount(log.data);
|
|
259
|
+
const topics = log.topics.length;
|
|
260
|
+
const fired = (fields, explanation) => ({ fired: true, event: name, fields, explanation: `Rule ${rule.id}: ${explanation}` });
|
|
261
|
+
switch (name) {
|
|
262
|
+
case 'ProposalCreated': {
|
|
263
|
+
if (topics !== 1)
|
|
264
|
+
return TOPIC_MISMATCH;
|
|
265
|
+
if (words < 9)
|
|
266
|
+
return DATA_MISMATCH;
|
|
267
|
+
const proposer = addressFromWord(log.data, 1);
|
|
268
|
+
const targets = dynamicAt(log.data, 2, 0);
|
|
269
|
+
const description = dynamicAt(log.data, 8, 4096);
|
|
270
|
+
if (!proposer || !targets || !description)
|
|
271
|
+
return DATA_MISMATCH;
|
|
272
|
+
const id = uintAt(log.data, 0);
|
|
273
|
+
const title = shortText(description.hex, 140);
|
|
274
|
+
return fired({ proposalId: id.toString(), proposer, actions: String(targets.length), voteStart: uintAt(log.data, 6).toString(), voteEnd: uintAt(log.data, 7).toString(), ...(title ? { title } : {}) }, `${contract} created proposal ${id} with ${targets.length} action${targets.length === 1 ? '' : 's'}, proposed by ${proposer}${title ? `: "${title}"` : ''}.`);
|
|
275
|
+
}
|
|
276
|
+
case 'ProposalQueued':
|
|
277
|
+
if (topics !== 1)
|
|
278
|
+
return TOPIC_MISMATCH;
|
|
279
|
+
if (words !== 2)
|
|
280
|
+
return DATA_MISMATCH;
|
|
281
|
+
return fired({ proposalId: uintAt(log.data, 0).toString(), eta: uintAt(log.data, 1).toString() }, `${contract} queued proposal ${uintAt(log.data, 0)} for execution at timestamp ${uintAt(log.data, 1)}.`);
|
|
282
|
+
case 'ProposalExecuted':
|
|
283
|
+
case 'ProposalCanceled':
|
|
284
|
+
if (topics !== 1)
|
|
285
|
+
return TOPIC_MISMATCH;
|
|
286
|
+
if (words !== 1)
|
|
287
|
+
return DATA_MISMATCH;
|
|
288
|
+
return fired({ proposalId: uintAt(log.data, 0).toString() }, `${contract} ${name === 'ProposalExecuted' ? 'executed' : 'cancelled'} proposal ${uintAt(log.data, 0)}.`);
|
|
289
|
+
case 'CallScheduled':
|
|
290
|
+
case 'CallExecuted': {
|
|
291
|
+
if (topics !== 3)
|
|
292
|
+
return TOPIC_MISMATCH;
|
|
293
|
+
if (words < (name === 'CallScheduled' ? 5 : 3))
|
|
294
|
+
return DATA_MISMATCH;
|
|
295
|
+
const target = addressFromWord(log.data, 0);
|
|
296
|
+
const call = dynamicAt(log.data, 2, 4);
|
|
297
|
+
if (!target || !call)
|
|
298
|
+
return DATA_MISMATCH;
|
|
299
|
+
const selector = call.length >= 4 ? `0x${call.hex.slice(0, 8)}` : 'none';
|
|
300
|
+
const value = uintAt(log.data, 1);
|
|
301
|
+
const fields = { operationId: log.topics[1], index: BigInt(log.topics[2]).toString(), target, valueAtomic: value.toString(), selector };
|
|
302
|
+
if (name === 'CallScheduled')
|
|
303
|
+
fields.delaySeconds = uintAt(log.data, 4).toString();
|
|
304
|
+
return fired(fields, `${contract} ${name === 'CallScheduled' ? `scheduled a call (delay ${fields.delaySeconds} s)` : 'executed a call'} to ${target}, selector ${selector}, value ${value} wei; operation ${log.topics[1].slice(0, 18)}….`);
|
|
305
|
+
}
|
|
306
|
+
case 'Cancelled':
|
|
307
|
+
if (topics !== 2)
|
|
308
|
+
return TOPIC_MISMATCH;
|
|
309
|
+
if (words !== 0)
|
|
310
|
+
return DATA_MISMATCH;
|
|
311
|
+
return fired({ operationId: log.topics[1] }, `${contract} cancelled timelock operation ${log.topics[1].slice(0, 18)}….`);
|
|
312
|
+
case 'MinDelayChange':
|
|
313
|
+
if (topics !== 1)
|
|
314
|
+
return TOPIC_MISMATCH;
|
|
315
|
+
if (words !== 2)
|
|
316
|
+
return DATA_MISMATCH;
|
|
317
|
+
return fired({ oldDelaySeconds: uintAt(log.data, 0).toString(), newDelaySeconds: uintAt(log.data, 1).toString() }, `${contract} changed its minimum delay from ${uintAt(log.data, 0)} to ${uintAt(log.data, 1)} seconds.`);
|
|
318
|
+
case 'QueueTransaction':
|
|
319
|
+
case 'ExecuteTransaction':
|
|
320
|
+
case 'CancelTransaction': {
|
|
321
|
+
if (topics !== 3)
|
|
322
|
+
return TOPIC_MISMATCH;
|
|
323
|
+
const target = addressFromTopic(log.topics[2]);
|
|
324
|
+
const signature = words >= 4 ? dynamicAt(log.data, 1, 100) : null;
|
|
325
|
+
if (!target)
|
|
326
|
+
return TOPIC_MISMATCH;
|
|
327
|
+
if (!signature)
|
|
328
|
+
return DATA_MISMATCH;
|
|
329
|
+
const text = shortText(signature.hex, 100) || 'raw calldata';
|
|
330
|
+
const eta = uintAt(log.data, 3);
|
|
331
|
+
const verb = name === 'QueueTransaction' ? 'queued' : name === 'ExecuteTransaction' ? 'executed' : 'cancelled';
|
|
332
|
+
return fired({ transactionId: log.topics[1], target, valueAtomic: uintAt(log.data, 0).toString(), signature: text, eta: eta.toString() }, `${contract} ${verb} ${text} on ${target} (eta ${eta}).`);
|
|
333
|
+
}
|
|
334
|
+
case 'NewDelay':
|
|
335
|
+
if (topics !== 2)
|
|
336
|
+
return TOPIC_MISMATCH;
|
|
337
|
+
if (words !== 0)
|
|
338
|
+
return DATA_MISMATCH;
|
|
339
|
+
return fired({ newDelaySeconds: BigInt(log.topics[1]).toString() }, `${contract} set its delay to ${BigInt(log.topics[1])} seconds.`);
|
|
340
|
+
case 'NewAdmin':
|
|
341
|
+
case 'NewPendingAdmin': {
|
|
342
|
+
if (topics !== 2)
|
|
343
|
+
return TOPIC_MISMATCH;
|
|
344
|
+
if (words !== 0)
|
|
345
|
+
return DATA_MISMATCH;
|
|
346
|
+
const admin = addressFromTopic(log.topics[1]);
|
|
347
|
+
if (!admin)
|
|
348
|
+
return TOPIC_MISMATCH;
|
|
349
|
+
return fired({ [name === 'NewAdmin' ? 'newAdmin' : 'newPendingAdmin']: admin }, `${contract} ${name === 'NewAdmin' ? 'accepted a new admin' : 'proposed a new pending admin'}: ${admin}.`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function matchControl(rule, log) {
|
|
354
|
+
const name = Object.entries(CONTROL_TOPICS).find(([, value]) => value === log.topics[0])?.[0];
|
|
355
|
+
if (!name || !(rule.events ?? CONTROL_EVENTS).includes(name))
|
|
356
|
+
return TOPIC_MISMATCH;
|
|
357
|
+
const contract = getAddress(log.address);
|
|
358
|
+
if (name === 'Paused' || name === 'Unpaused') {
|
|
359
|
+
if (log.topics.length !== 1)
|
|
360
|
+
return TOPIC_MISMATCH;
|
|
361
|
+
const account = log.data.length === 66 ? addressFromWord(log.data, 0) : null;
|
|
362
|
+
if (!account)
|
|
363
|
+
return DATA_MISMATCH;
|
|
364
|
+
return { fired: true, event: name, fields: { account }, explanation: `Rule ${rule.id}: ${contract} was ${name === 'Paused' ? 'paused' : 'unpaused'} by ${account}.` };
|
|
365
|
+
}
|
|
366
|
+
if (log.topics.length !== 4)
|
|
367
|
+
return TOPIC_MISMATCH;
|
|
368
|
+
if (log.data !== '0x')
|
|
369
|
+
return DATA_MISMATCH;
|
|
370
|
+
const role = log.topics[1];
|
|
371
|
+
if (name === 'RoleAdminChanged')
|
|
372
|
+
return { fired: true, event: name, fields: { role, previousAdminRole: log.topics[2], newAdminRole: log.topics[3] },
|
|
373
|
+
explanation: `Rule ${rule.id}: on ${contract}, the admin of ${roleName(role)} changed from ${roleName(log.topics[2])} to ${roleName(log.topics[3])}.` };
|
|
374
|
+
const account = addressFromTopic(log.topics[2]);
|
|
375
|
+
const sender = addressFromTopic(log.topics[3]);
|
|
376
|
+
if (!account || !sender)
|
|
377
|
+
return TOPIC_MISMATCH;
|
|
378
|
+
return { fired: true, event: name, fields: { role, account, sender }, explanation: `Rule ${rule.id}: on ${contract}, ${sender} ${name === 'RoleGranted' ? 'granted' : 'revoked'} ${roleName(role)} ${name === 'RoleGranted' ? 'to' : 'from'} ${account}.` };
|
|
379
|
+
}
|
|
380
|
+
export function matchLog(rule, log) {
|
|
381
|
+
if (log.removed)
|
|
382
|
+
return { fired: false, reason: 'removed' };
|
|
383
|
+
if (!ruleAddresses(rule).includes(log.address))
|
|
384
|
+
return { fired: false, reason: 'address-mismatch' };
|
|
385
|
+
const [topic0] = log.topics;
|
|
386
|
+
if (rule.type === 'wallet-token-flow')
|
|
387
|
+
return matchWalletFlow(rule, log);
|
|
388
|
+
if (rule.type === 'pool-activity')
|
|
389
|
+
return matchPool(rule, log);
|
|
390
|
+
if (rule.type === 'governance-change')
|
|
391
|
+
return matchGovernance(rule, log);
|
|
392
|
+
if (rule.type === 'control-change')
|
|
393
|
+
return matchControl(rule, log);
|
|
394
|
+
if (rule.type === 'erc20-transfer' || rule.type === 'erc20-approval') {
|
|
395
|
+
const expected = rule.type === 'erc20-transfer' ? WATCH_TOPICS.Transfer : WATCH_TOPICS.Approval;
|
|
396
|
+
// ERC-721 Transfer/Approval share topic0 but index the token id as a fourth topic.
|
|
397
|
+
if (topic0 !== expected || log.topics.length !== 3)
|
|
398
|
+
return { fired: false, reason: 'topic-mismatch' };
|
|
399
|
+
if (log.data.length !== 66)
|
|
400
|
+
return { fired: false, reason: 'data-mismatch' };
|
|
401
|
+
const first = addressFromTopic(log.topics[1]);
|
|
402
|
+
const second = addressFromTopic(log.topics[2]);
|
|
403
|
+
if (!first || !second)
|
|
404
|
+
return { fired: false, reason: 'topic-mismatch' };
|
|
405
|
+
const amount = BigInt(log.data);
|
|
406
|
+
if (rule.type === 'erc20-transfer') {
|
|
407
|
+
if ((rule.from && rule.from.toLowerCase() !== first.toLowerCase()) || (rule.to && rule.to.toLowerCase() !== second.toLowerCase()))
|
|
408
|
+
return { fired: false, reason: 'filter-mismatch' };
|
|
409
|
+
if (rule.minimumAtomic !== undefined && amount < BigInt(rule.minimumAtomic))
|
|
410
|
+
return { fired: false, reason: 'below-minimum' };
|
|
411
|
+
const matched = [rule.from ? 'sender' : '', rule.to ? 'recipient' : '', rule.minimumAtomic !== undefined ? `minimum ${rule.minimumAtomic}` : ''].filter(Boolean);
|
|
412
|
+
return { fired: true, event: 'Transfer', fields: { from: first, to: second, amountAtomic: amount.toString() },
|
|
413
|
+
explanation: `Rule ${rule.id}: Transfer of ${amount} atomic units on ${getAddress(log.address)} from ${first} to ${second}${matched.length ? ` (matched ${matched.join(', ')})` : ''}.` };
|
|
414
|
+
}
|
|
415
|
+
if (rule.owner.toLowerCase() !== first.toLowerCase() || (rule.spender && rule.spender.toLowerCase() !== second.toLowerCase()))
|
|
416
|
+
return { fired: false, reason: 'filter-mismatch' };
|
|
417
|
+
return { fired: true, event: 'Approval', fields: { owner: first, spender: second, amountAtomic: amount.toString() },
|
|
418
|
+
explanation: `Rule ${rule.id}: ${first} approved ${second} for ${amount} atomic units${amount === MAX_UINT256 ? ' (maximum uint256)' : ''} on ${getAddress(log.address)}.` };
|
|
419
|
+
}
|
|
420
|
+
const selected = rule.events ?? ['Upgraded', 'AdminChanged', 'BeaconUpgraded', 'OwnershipTransferred'];
|
|
421
|
+
const name = Object.entries(WATCH_TOPICS).find(([, topic]) => topic === topic0)?.[0];
|
|
422
|
+
if (!name || name === 'Transfer' || name === 'Approval' || !selected.includes(name))
|
|
423
|
+
return { fired: false, reason: 'topic-mismatch' };
|
|
424
|
+
const contract = getAddress(log.address);
|
|
425
|
+
if (name === 'Upgraded' || name === 'BeaconUpgraded') {
|
|
426
|
+
const value = log.topics.length === 2 && log.data === '0x' ? addressFromTopic(log.topics[1]) : null;
|
|
427
|
+
if (!value)
|
|
428
|
+
return { fired: false, reason: 'topic-mismatch' };
|
|
429
|
+
return name === 'Upgraded' ? { fired: true, event: name, fields: { implementation: value }, explanation: `Rule ${rule.id}: ${contract} emitted Upgraded; its implementation is now ${value}.` }
|
|
430
|
+
: { fired: true, event: name, fields: { beacon: value }, explanation: `Rule ${rule.id}: ${contract} emitted BeaconUpgraded; its beacon is now ${value}.` };
|
|
431
|
+
}
|
|
432
|
+
if (name === 'AdminChanged') {
|
|
433
|
+
if (log.topics.length !== 1)
|
|
434
|
+
return { fired: false, reason: 'topic-mismatch' };
|
|
435
|
+
const previous = addressFromWord(log.data, 0);
|
|
436
|
+
const next = addressFromWord(log.data, 1);
|
|
437
|
+
if (log.data.length !== 130 || !previous || !next)
|
|
438
|
+
return { fired: false, reason: 'data-mismatch' };
|
|
439
|
+
return { fired: true, event: name, fields: { previousAdmin: previous, newAdmin: next }, explanation: `Rule ${rule.id}: ${contract} emitted AdminChanged from ${previous} to ${next}.` };
|
|
440
|
+
}
|
|
441
|
+
const previous = log.topics.length === 3 && log.data === '0x' ? addressFromTopic(log.topics[1]) : null;
|
|
442
|
+
const next = log.topics.length === 3 ? addressFromTopic(log.topics[2]) : null;
|
|
443
|
+
if (!previous || !next)
|
|
444
|
+
return { fired: false, reason: 'topic-mismatch' };
|
|
445
|
+
return { fired: true, event: 'OwnershipTransferred', fields: { previousOwner: previous, newOwner: next }, explanation: `Rule ${rule.id}: ownership of ${contract} was transferred from ${previous} to ${next}.` };
|
|
446
|
+
}
|
|
447
|
+
export const eventKey = (chain, transactionHash, logIndex, ruleId) => contentHash({ chain, transactionHash: transactionHash.toLowerCase(), logIndex, ruleId });
|
|
448
|
+
export function createWatchTransport(url) {
|
|
449
|
+
const client = createPublicClient({ transport: http(url, { retryCount: 0, timeout: 10000, maxResponseBodySize: 1_048_576, fetchOptions: { redirect: 'error' } }), cacheTime: 0 });
|
|
450
|
+
return { request: (method, params, signal) => client.request({ method, params }, { signal, retryCount: 0 }) };
|
|
451
|
+
}
|
|
452
|
+
class RuntimeInterruption extends Error {
|
|
453
|
+
original;
|
|
454
|
+
constructor(original) {
|
|
455
|
+
super('The runtime refused or could not persist this operation.');
|
|
456
|
+
this.original = original;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const fatal = (category) => ['cancelled', 'timeout'].includes(category);
|
|
460
|
+
/**
|
|
461
|
+
* Read-only scan of one window at or below the latest head (instant reads; no finality wait). Truncation, provider failure and reorgs
|
|
462
|
+
* are reported; none becomes "nothing happened". A reorg of the window end fails the window, and monitors retract and redo it.
|
|
463
|
+
*/
|
|
464
|
+
export async function watchWindow(rawInput, options = {}) {
|
|
465
|
+
const input = WatchtowerWindowInputSchema.parse(rawInput);
|
|
466
|
+
const manifest = AgentManifestSchema.parse(options.manifest ?? WATCHTOWER_MANIFEST);
|
|
467
|
+
const config = resolveChainConfiguration(input.chain, options.env ?? process.env);
|
|
468
|
+
const now = options.now ?? (() => new Date());
|
|
469
|
+
const started = now().getTime();
|
|
470
|
+
const deadline = AbortSignal.timeout(Math.min(60000, manifest.limits.maximumDurationMs));
|
|
471
|
+
const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
|
|
472
|
+
const from = BigInt(input.fromBlock);
|
|
473
|
+
const to = BigInt(input.toBlock);
|
|
474
|
+
const report = {
|
|
475
|
+
schemaVersion: 'agentex.watchtower-window.v1', agentVersion: manifest.version, manifestHash: contentHash(manifest), input, status: 'failed', generatedAt: now().toISOString(),
|
|
476
|
+
chainId: Number.isSafeInteger(config.chainId) && config.chainId > 0 ? config.chainId : null,
|
|
477
|
+
window: { fromBlock: input.fromBlock, toBlock: input.toBlock, fromBlockHash: null, toBlockHash: null, toBlockTimestamp: null, finality: 'sequencer-confirmed', canonicality: 'unchecked', coverage: 'unavailable',
|
|
478
|
+
coveredThroughBlock: null, coveredThroughBlockHash: null, finalizedBlock: null, finalityLagBlocks: null, dataLagSeconds: null, headBlock: null, headLagBlocks: null },
|
|
479
|
+
rules: input.rules.map((rule) => ({ id: rule.id, type: rule.type, coverage: 'not-read', logsReturned: 0, logsMatched: 0, ignored: {}, coveredThroughBlock: null, reason: 'Not read.', evidenceIds: [] })),
|
|
480
|
+
events: [], unknowns: [], errors: [], evidence: [],
|
|
481
|
+
limitations: [watchRuleRefusal(manifest.version, [{ id: 'probe', type: 'pool-activity' }]) === null
|
|
482
|
+
? 'Only the declared standard events emitted by the declared contracts are watched: ERC-20 Transfer/Approval, EIP-1967/Ownable authority, AccessControl/Pausable, Governor and timelock events, and Uniswap V2/V3 or Aerodrome pool Swap/Mint/Burn. Custom events, native transfers, internal calls and pool price or depth are not observed.'
|
|
483
|
+
: 'Only ERC-20 Transfer/Approval events and EIP-1967/Ownable authority events emitted by the declared contracts are watched. Other permission or upgrade mechanisms, native transfers and internal calls are not observed.',
|
|
484
|
+
'Rules match by event signature and topic layout; a contract emitting a standard event is not verified to implement the standard.',
|
|
485
|
+
'The window is read at or below the latest block (sequencer-confirmed), not finalized blocks. Its end-block hash is rechecked at completion; earlier blocks rely on that ancestry. A later reorg is detected on the next window and the affected events are retracted and redone.',
|
|
486
|
+
'eth_getLogs results are accepted as complete for the requested filter. A provider that silently omits logs cannot be detected from its response alone.',
|
|
487
|
+
'Amounts are exact atomic units. Token decimals, prices and identities are not read or inferred.',
|
|
488
|
+
'Events are observations, not a verdict about intent, fraud or safety.'],
|
|
489
|
+
usage: { rpcCalls: 0, maximumRpcCalls: Math.min(WATCH_RPC_CEILING, manifest.limits.maximumRpcCalls, ...manifest.tools.map((tool) => tool.maximumCalls)), elapsedMs: 0, modelTokens: 0, providerCostAtomic: null, providerCostStatus: 'unpriced' },
|
|
490
|
+
};
|
|
491
|
+
let transport;
|
|
492
|
+
let snapshot = null;
|
|
493
|
+
const restoredReads = new Map();
|
|
494
|
+
let restoredSnapshot = null;
|
|
495
|
+
const addError = (category, step, evidenceId) => { report.errors.push({ category, message: FAILURE_MESSAGES[category], step, ...(evidenceId ? { evidenceId } : {}) }); };
|
|
496
|
+
const interrupted = () => new EvmReadFailure(signal.reason?.name === 'TimeoutError' ? 'timeout' : 'cancelled', 'Watch window interrupted.');
|
|
497
|
+
const progress = async (step, message) => { try {
|
|
498
|
+
await options.onProgress?.({ step, message });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
throw new RuntimeInterruption(error);
|
|
502
|
+
} };
|
|
503
|
+
/** `capture` decides what is stored as evidence; over-cap log responses keep only the examined whole blocks plus a hash of the full response. */
|
|
504
|
+
const call = async ({ method, params }, step, capture) => {
|
|
505
|
+
if (signal.aborted)
|
|
506
|
+
throw interrupted();
|
|
507
|
+
const cached = snapshot ? restoredReads.get(requestKey({ method, params })) : undefined;
|
|
508
|
+
if (cached) {
|
|
509
|
+
await progress(step, `Reusing checkpointed ${step} at the verified original window end.`);
|
|
510
|
+
const id = `rpc-${report.evidence.length + 1}`;
|
|
511
|
+
report.evidence.push({ ...structuredClone(cached), id, canonicality: 'unchecked' });
|
|
512
|
+
return { result: structuredClone(cached.result), id };
|
|
513
|
+
}
|
|
514
|
+
if (report.usage.rpcCalls >= report.usage.maximumRpcCalls)
|
|
515
|
+
throw new EvmReadFailure('limit-exceeded', 'RPC call budget exhausted.');
|
|
516
|
+
await progress(step, `Reading ${step}.`);
|
|
517
|
+
if (signal.aborted)
|
|
518
|
+
throw interrupted();
|
|
519
|
+
try {
|
|
520
|
+
await options.beforeRpc?.({ method, step, maximumRpcCalls: report.usage.maximumRpcCalls });
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
throw new RuntimeInterruption(error);
|
|
524
|
+
}
|
|
525
|
+
if (signal.aborted)
|
|
526
|
+
throw interrupted();
|
|
527
|
+
report.usage.rpcCalls++;
|
|
528
|
+
const id = `rpc-${report.evidence.length + 1}`;
|
|
529
|
+
let result;
|
|
530
|
+
let stored;
|
|
531
|
+
let complete = true;
|
|
532
|
+
let errorCategory;
|
|
533
|
+
let captureFailure;
|
|
534
|
+
try {
|
|
535
|
+
result = await transport.request(method, params, signal);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
errorCategory = categorizeRpcFailure(error, signal);
|
|
539
|
+
result = { error: errorCategory };
|
|
540
|
+
}
|
|
541
|
+
stored = result ?? null;
|
|
542
|
+
if (!errorCategory && capture) {
|
|
543
|
+
try {
|
|
544
|
+
const captured = capture(result);
|
|
545
|
+
stored = captured.stored;
|
|
546
|
+
complete = captured.complete;
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
captureFailure = error;
|
|
550
|
+
errorCategory = categorizeRpcFailure(error, signal);
|
|
551
|
+
stored = { error: errorCategory };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const resultJson = JSON.parse(JSON.stringify(stored));
|
|
555
|
+
const evidence = { id, tool: WATCH_TOOL, source: { kind: 'rpc', provider: servedProviderLabel(input.chain, `${input.chain}-configured-rpc`, errorCategory ? null : transport.servedBy?.()), method, documentationUrl: RPC_DOCUMENTATION }, chain: input.chain, chainId: config.chainId,
|
|
556
|
+
request: JSON.parse(JSON.stringify(params)), snapshot: method === 'eth_getLogs' ? snapshot : null, observedAt: now().toISOString(),
|
|
557
|
+
finality: method === 'eth_getLogs' && snapshot ? 'sequencer-confirmed' : 'unverified', canonicality: 'unchecked', completeness: errorCategory || !complete ? 'error' : 'complete-for-request', result: resultJson, contentHash: contentHash(resultJson), privacy: 'public-chain-data' };
|
|
558
|
+
report.evidence.push(evidence);
|
|
559
|
+
try {
|
|
560
|
+
await options.onEvidence?.(structuredClone(evidence));
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
throw new RuntimeInterruption(error);
|
|
564
|
+
}
|
|
565
|
+
if (errorCategory) {
|
|
566
|
+
addError(errorCategory, step, id);
|
|
567
|
+
throw captureFailure ?? new EvmReadFailure(errorCategory, FAILURE_MESSAGES[errorCategory]);
|
|
568
|
+
}
|
|
569
|
+
return { result, id };
|
|
570
|
+
};
|
|
571
|
+
const header = async (block, step) => parseBlockHeader((await call(watchRequests.header(block === 'latest' ? 'latest' : blockTag(block)), step)).result);
|
|
572
|
+
const candidates = [];
|
|
573
|
+
try {
|
|
574
|
+
if (!SupportedWatchtowerManifestSchema.safeParse(manifest).success || !manifest.chains.includes(input.chain))
|
|
575
|
+
throw new EvmReadFailure('unsupported', 'This version asks for monitoring steps the Watchtower does not support.');
|
|
576
|
+
const refusal = watchRuleRefusal(manifest.version, input.rules);
|
|
577
|
+
if (refusal) {
|
|
578
|
+
report.unknowns.push(refusal);
|
|
579
|
+
throw new EvmReadFailure('unsupported', refusal);
|
|
580
|
+
}
|
|
581
|
+
if (!config.configured || !config.rpcUrl)
|
|
582
|
+
throw new EvmReadFailure('unconfigured', 'No verified data provider is configured on this server for this chain.');
|
|
583
|
+
if (options.restore) {
|
|
584
|
+
if (options.restore.manifestHash !== report.manifestHash || options.restore.inputHash !== contentHash(input) || !Array.isArray(options.restore.evidence) || options.restore.evidence.length > WATCH_RPC_CEILING * 2)
|
|
585
|
+
throw invalid('Checkpoint identity or size is invalid.');
|
|
586
|
+
for (const raw of options.restore.evidence) {
|
|
587
|
+
const checked = WatchEvidenceItemSchema.safeParse(raw);
|
|
588
|
+
if (!checked.success)
|
|
589
|
+
throw invalid('Checkpoint receipt schema is invalid.');
|
|
590
|
+
const item = checked.data;
|
|
591
|
+
if (item.chain !== input.chain || item.chainId !== config.chainId || item.contentHash !== contentHash(item.result) || item.canonicality === 'changed' || !watchReceiptInScope(item, input))
|
|
592
|
+
throw invalid('Checkpoint receipt integrity or scope is invalid.');
|
|
593
|
+
if (item.source.method !== 'eth_getLogs' || !item.snapshot)
|
|
594
|
+
continue;
|
|
595
|
+
if (restoredSnapshot && item.snapshot.blockHash.toLowerCase() !== restoredSnapshot.blockHash.toLowerCase())
|
|
596
|
+
throw invalid('Checkpoint receipts do not share one window end.');
|
|
597
|
+
restoredSnapshot = item.snapshot;
|
|
598
|
+
if (item.completeness === 'complete-for-request')
|
|
599
|
+
restoredReads.set(requestKey({ method: item.source.method, params: item.request }), item);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
transport = options.transport ?? createWatchTransport(config.rpcUrl);
|
|
603
|
+
if (parseQuantity((await call({ method: 'eth_chainId', params: [] }, 'chain-identity')).result) !== BigInt(config.chainId))
|
|
604
|
+
throw new EvmReadFailure('chain-mismatch', 'RPC chain mismatch.');
|
|
605
|
+
const head = await header('latest', 'latest-head');
|
|
606
|
+
report.window.headBlock = head.blockNumber;
|
|
607
|
+
if (to > BigInt(head.blockNumber))
|
|
608
|
+
throw new EvmReadFailure('unsupported', 'The window extends beyond the provider\'s latest block.');
|
|
609
|
+
report.window.headLagBlocks = Number(BigInt(head.blockNumber) - to);
|
|
610
|
+
const fromHeader = await header(from, 'window-start');
|
|
611
|
+
const toHeader = from === to ? fromHeader : await header(to, 'window-end');
|
|
612
|
+
if (fromHeader.blockNumber !== input.fromBlock || toHeader.blockNumber !== input.toBlock)
|
|
613
|
+
throw invalid('Returned window header height does not match the request.');
|
|
614
|
+
report.window.fromBlockHash = fromHeader.blockHash.toLowerCase();
|
|
615
|
+
report.window.toBlockHash = toHeader.blockHash.toLowerCase();
|
|
616
|
+
report.window.toBlockTimestamp = toHeader.timestamp;
|
|
617
|
+
if (restoredSnapshot && restoredSnapshot.blockHash.toLowerCase() !== report.window.toBlockHash)
|
|
618
|
+
throw new EvmReadFailure('snapshot-changed', 'The checkpointed window end changed before recovery.');
|
|
619
|
+
if (restoredSnapshot)
|
|
620
|
+
report.limitations.push('Checkpointed log reads retain their original observation times; the window end was revalidated before reuse and again at completion.');
|
|
621
|
+
snapshot = { blockNumber: input.toBlock, blockHash: report.window.toBlockHash };
|
|
622
|
+
report.status = 'partial';
|
|
623
|
+
let capacity = input.maximumLogs;
|
|
624
|
+
let coveredThrough = to;
|
|
625
|
+
const checkHeaders = (log) => {
|
|
626
|
+
if ((log.blockNumber === to && log.blockHash !== report.window.toBlockHash) || (log.blockNumber === from && log.blockHash !== report.window.fromBlockHash))
|
|
627
|
+
throw new EvmReadFailure('snapshot-changed', 'A log block hash differs from the window headers.');
|
|
628
|
+
};
|
|
629
|
+
/** A shared read is used only after this run's own window headers are read; the capture recheck binds its logs to those headers. */
|
|
630
|
+
const reuseShared = async (rule, request, step, capture) => {
|
|
631
|
+
if (!options.sharedReads || !snapshot || !report.window.fromBlockHash || !report.window.toBlockHash || restoredReads.has(requestKey(request)))
|
|
632
|
+
return null;
|
|
633
|
+
const key = sharedReadKey(input.chain, config.chainId, rule, input, report.window.fromBlockHash, report.window.toBlockHash);
|
|
634
|
+
let hit;
|
|
635
|
+
try {
|
|
636
|
+
hit = await options.sharedReads.get(key);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
if (!hit || signal.aborted || !Number.isFinite(Date.parse(hit.observedAt)))
|
|
642
|
+
return null;
|
|
643
|
+
let captured;
|
|
644
|
+
try {
|
|
645
|
+
captured = capture(hit.result);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
await progress(step, `Reusing a shared public read for ${step}.`);
|
|
651
|
+
const id = `rpc-${report.evidence.length + 1}`;
|
|
652
|
+
const resultJson = JSON.parse(JSON.stringify(captured.stored));
|
|
653
|
+
report.evidence.push({ id, tool: WATCH_TOOL, source: { kind: 'rpc', provider: `${input.chain}${SHARED_READ_PROVIDER_SUFFIX}`, method: request.method, documentationUrl: RPC_DOCUMENTATION }, chain: input.chain, chainId: config.chainId,
|
|
654
|
+
request: JSON.parse(JSON.stringify(request.params)), snapshot, observedAt: new Date(hit.observedAt).toISOString(), finality: 'sequencer-confirmed', canonicality: 'unchecked',
|
|
655
|
+
completeness: captured.complete ? 'complete-for-request' : 'error', result: resultJson, contentHash: contentHash(resultJson), privacy: 'public-chain-data' });
|
|
656
|
+
if (!report.limitations.some((line) => line.startsWith('Shared public read')))
|
|
657
|
+
report.limitations.push('Shared public read: at least one log read reused the raw public response another run captured for the same chain, block range, window-edge hashes, contract and topic filter. It was rechecked against this window\'s own headers; events, notifications, deliveries and charges are computed for this run only.');
|
|
658
|
+
return { result: hit.result, id };
|
|
659
|
+
};
|
|
660
|
+
const pendingShares = [];
|
|
661
|
+
for (const [index, rule] of input.rules.entries()) {
|
|
662
|
+
const coverage = report.rules[index];
|
|
663
|
+
const step = `logs-${rule.id}`;
|
|
664
|
+
try {
|
|
665
|
+
const remaining = capacity;
|
|
666
|
+
const capture = (raw) => {
|
|
667
|
+
const logs = parseLogs(raw, input);
|
|
668
|
+
const plan = capLogs(logs, remaining, input);
|
|
669
|
+
plan.kept.forEach(checkHeaders);
|
|
670
|
+
return plan.truncated ? { stored: { error: 'limit-exceeded', logsReturned: logs.length, retainedLogs: plan.kept.map((log) => log.raw), coveredThroughBlock: plan.coveredThrough.toString(), fullResultHash: contentHash(raw) }, complete: false } : { stored: raw, complete: true };
|
|
671
|
+
};
|
|
672
|
+
const request = watchRequests.logs(rule, input);
|
|
673
|
+
const shared = shareableRule(rule) ? await reuseShared(rule, request, step, capture) : null;
|
|
674
|
+
const response = shared ?? await call(request, step, capture);
|
|
675
|
+
if (!shared && options.sharedReads && shareableRule(rule) && Array.isArray(response.result) && response.result.length <= WATCH_MAX_LOGS && report.window.fromBlockHash && report.window.toBlockHash) {
|
|
676
|
+
const observedAt = report.evidence.find((item) => item.id === response.id)?.observedAt ?? now().toISOString();
|
|
677
|
+
pendingShares.push({ key: sharedReadKey(input.chain, config.chainId, rule, input, report.window.fromBlockHash, report.window.toBlockHash), result: response.result, observedAt, logs: response.result.length });
|
|
678
|
+
}
|
|
679
|
+
const logs = parseLogs(response.result, input);
|
|
680
|
+
const plan = capLogs(logs, remaining, input);
|
|
681
|
+
plan.kept.forEach(checkHeaders);
|
|
682
|
+
coverage.evidenceIds = [response.id];
|
|
683
|
+
coverage.logsReturned = logs.length;
|
|
684
|
+
coverage.coverage = plan.truncated ? 'truncated' : 'complete';
|
|
685
|
+
coverage.coveredThroughBlock = plan.coveredThrough >= from ? plan.coveredThrough.toString() : null;
|
|
686
|
+
coverage.reason = plan.truncated ? `Returned ${logs.length} logs, more than the remaining cap of ${remaining}; only whole blocks through ${plan.coveredThrough >= from ? plan.coveredThrough : 'none'} were examined.` : 'Every returned log in the window was examined.';
|
|
687
|
+
if (plan.truncated) {
|
|
688
|
+
coveredThrough = plan.coveredThrough < coveredThrough ? plan.coveredThrough : coveredThrough;
|
|
689
|
+
report.unknowns.push(`Rule ${rule.id} returned ${logs.length} logs, above the remaining cap of ${remaining}. Blocks ${plan.coveredThrough + 1n}-${input.toBlock} were not examined for any rule in this window.`);
|
|
690
|
+
}
|
|
691
|
+
capacity -= plan.kept.length;
|
|
692
|
+
for (const log of plan.kept) {
|
|
693
|
+
const match = matchLog(rule, log);
|
|
694
|
+
if (!match.fired) {
|
|
695
|
+
coverage.ignored[match.reason] = (coverage.ignored[match.reason] ?? 0) + 1;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
coverage.logsMatched++;
|
|
699
|
+
candidates.push({ eventKey: eventKey(input.chain, log.transactionHash, log.logIndex, rule.id), ruleId: rule.id, ruleType: rule.type, event: match.event, blockNumber: log.blockNumber.toString(), blockHash: log.blockHash,
|
|
700
|
+
transactionHash: log.transactionHash, logIndex: log.logIndex, address: getAddress(log.address), fields: match.fields, explanation: match.explanation, evidenceIds: [response.id] });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (error instanceof RuntimeInterruption)
|
|
705
|
+
throw error;
|
|
706
|
+
const category = categorizeRpcFailure(error, signal);
|
|
707
|
+
if (fatal(category) || category === 'snapshot-changed' || category === 'limit-exceeded' && report.usage.rpcCalls >= report.usage.maximumRpcCalls)
|
|
708
|
+
throw error;
|
|
709
|
+
if (!report.errors.some((item) => item.step === step))
|
|
710
|
+
addError(category, step);
|
|
711
|
+
coverage.coverage = 'unavailable';
|
|
712
|
+
coverage.reason = FAILURE_MESSAGES[category];
|
|
713
|
+
coverage.evidenceIds = report.errors.filter((item) => item.step === step && item.evidenceId).map((item) => item.evidenceId);
|
|
714
|
+
report.unknowns.push(`Rule ${rule.id} could not be read (${category}). This window makes no claim that the watched activity did not happen.`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const unavailable = report.rules.some((rule) => rule.coverage === 'unavailable');
|
|
718
|
+
if (unavailable)
|
|
719
|
+
coveredThrough = from - 1n;
|
|
720
|
+
for (const event of candidates) {
|
|
721
|
+
if (BigInt(event.blockNumber) <= coveredThrough) {
|
|
722
|
+
report.events.push(event);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const rule = report.rules.find((item) => item.id === event.ruleId);
|
|
726
|
+
rule.logsMatched--;
|
|
727
|
+
rule.ignored['outside-coverage'] = (rule.ignored['outside-coverage'] ?? 0) + 1;
|
|
728
|
+
}
|
|
729
|
+
if (coveredThrough >= from && coveredThrough < to) {
|
|
730
|
+
report.window.coveredThroughBlockHash = coveredThrough === from ? report.window.fromBlockHash : (await header(coveredThrough, 'covered-through')).blockHash.toLowerCase();
|
|
731
|
+
}
|
|
732
|
+
else if (coveredThrough === to)
|
|
733
|
+
report.window.coveredThroughBlockHash = report.window.toBlockHash;
|
|
734
|
+
report.window.coveredThroughBlock = coveredThrough >= from ? coveredThrough.toString() : null;
|
|
735
|
+
report.window.coverage = unavailable ? 'unavailable' : coveredThrough === to ? 'complete' : 'partial';
|
|
736
|
+
if (!unavailable && coveredThrough < from)
|
|
737
|
+
report.unknowns.push(`Block ${input.fromBlock} alone exceeded the log cap of ${input.maximumLogs}; no block in this window was fully examined.`);
|
|
738
|
+
const ending = await header(to, 'window-end-canonicality');
|
|
739
|
+
if (ending.blockHash.toLowerCase() !== report.window.toBlockHash) {
|
|
740
|
+
report.window.canonicality = 'changed';
|
|
741
|
+
for (const item of report.evidence)
|
|
742
|
+
if (item.snapshot)
|
|
743
|
+
item.canonicality = 'changed';
|
|
744
|
+
throw new EvmReadFailure('snapshot-changed', 'The window end changed during the scan.');
|
|
745
|
+
}
|
|
746
|
+
report.window.canonicality = 'verified-at-completion';
|
|
747
|
+
for (const item of report.evidence)
|
|
748
|
+
if (item.snapshot)
|
|
749
|
+
item.canonicality = 'verified-at-completion';
|
|
750
|
+
// Only reads whose window end was rechecked as canonical become shareable. Sharing is an optimization and never fails the window.
|
|
751
|
+
for (const share of pendingShares) {
|
|
752
|
+
try {
|
|
753
|
+
await options.sharedReads.put(share.key, { result: share.result, observedAt: share.observedAt, logs: share.logs });
|
|
754
|
+
}
|
|
755
|
+
catch { /* not shared */ }
|
|
756
|
+
}
|
|
757
|
+
report.status = report.window.coverage === 'unavailable' ? 'failed' : report.window.coverage === 'complete' && !report.errors.length ? 'complete' : 'partial';
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
if (error instanceof RuntimeInterruption)
|
|
761
|
+
throw error.original;
|
|
762
|
+
const category = categorizeRpcFailure(error, signal);
|
|
763
|
+
if (!report.errors.some((item) => item.category === category))
|
|
764
|
+
addError(category, 'watch-window');
|
|
765
|
+
report.status = 'failed';
|
|
766
|
+
}
|
|
767
|
+
if (report.status === 'failed') {
|
|
768
|
+
// A failed window never carries events or coverage claims.
|
|
769
|
+
report.window.coverage = 'unavailable';
|
|
770
|
+
report.window.coveredThroughBlock = null;
|
|
771
|
+
report.window.coveredThroughBlockHash = null;
|
|
772
|
+
report.events = [];
|
|
773
|
+
if (!report.unknowns.some((item) => item.includes('no claim')))
|
|
774
|
+
report.unknowns.push('The window could not be completed. It makes no claim that the watched activity did not happen.');
|
|
775
|
+
}
|
|
776
|
+
report.generatedAt = now().toISOString();
|
|
777
|
+
report.usage.elapsedMs = Math.max(0, now().getTime() - started);
|
|
778
|
+
if (report.window.toBlockTimestamp !== null)
|
|
779
|
+
report.window.dataLagSeconds = Math.max(0, Math.floor(now().getTime() / 1000) - Number(report.window.toBlockTimestamp));
|
|
780
|
+
report.unknowns = report.unknowns.slice(0, 32);
|
|
781
|
+
if (Buffer.byteLength(JSON.stringify(report)) > Math.min(OUTPUT_CEILING, manifest.limits.maximumOutputBytes))
|
|
782
|
+
throw new EvmReadFailure('limit-exceeded', 'The captured window exceeds the configured output budget; no truncated report is accepted.');
|
|
783
|
+
if (report.events.length > WATCH_MAX_LOGS)
|
|
784
|
+
throw new EvmReadFailure('limit-exceeded', 'Event count exceeds the Watchtower bound.');
|
|
785
|
+
return WatchtowerWindowReportSchema.parse(report);
|
|
786
|
+
}
|