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,829 @@
|
|
|
1
|
+
import { createPublicClient, decodeFunctionResult, encodeFunctionData, formatUnits, getAddress, http, keccak256, parseAbi } from 'viem';
|
|
2
|
+
import { assertToolCoverage, CoverageError, coveredChains } from '../../providers/src/coverage.js';
|
|
3
|
+
import { createChainReadTransport } from '../../providers/src/chain-transport.js';
|
|
4
|
+
import { AttemptRefusedError } from '../../providers/src/health.js';
|
|
5
|
+
import { createKeyedTransport, KEYED_PROVIDER_POLICIES, keyedCredentialAvailable, keyedProviderHost, RunUnitBudget } from '../../providers/src/keyed.js';
|
|
6
|
+
import { CONFIRMATION_NOTES, READ_SNAPSHOT_POLICIES, readSnapshotPolicy, snapshotBlockTag } from '../../providers/src/snapshot.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { AgentManifestSchema, ANALYSIS_LABEL, EvidenceItemSchema, ModelUsageSchema, ResearchInputSchema, SupportedTokenResearcherManifestSchema, TOKEN_RESEARCHER_MANIFEST, TokenResearchReportSchema, contentHash, resolveAnalysisPolicy, resolveResearchPolicy } from '../../contracts/src/index.js';
|
|
9
|
+
import { buildMarketSection, decodeAggregate, discoveryPlan, encodeDiscovery, HOLDER_ACTIVITY_DOCUMENTATION, HOLDER_ACTIVITY_METHOD, holderTransferRequest, marketBalancesData, marketCall, marketCallInScope, MARKET_COVERAGE_TOOLS, marketVenuesFor, MAXIMUM_MARKET_POOLS, MULTICALL3_ADDRESS, MULTICALL3_DOCUMENTATION, poolsFromDiscovery, recipientsFromTransfers } from '../../contracts/src/token-market.js';
|
|
10
|
+
import { checkSummaryFindings, groundingStringsOf, runEvidenceSummary } from '../../model/src/summary.js';
|
|
11
|
+
import { DEFAULT_MODEL_ID } from '../../model/src/types.js';
|
|
12
|
+
const ABI = parseAbi(['function name() view returns (string)', 'function symbol() view returns (string)', 'function decimals() view returns (uint8)', 'function totalSupply() view returns (uint256)', 'function owner() view returns (address)']);
|
|
13
|
+
export const EIP1967_SLOTS = {
|
|
14
|
+
implementation: '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc',
|
|
15
|
+
admin: '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103',
|
|
16
|
+
beacon: '0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50',
|
|
17
|
+
};
|
|
18
|
+
const ZERO_ADDRESS = `0x${'0'.repeat(40)}`;
|
|
19
|
+
const RPC_DOCUMENTATION = 'https://ethereum.org/en/developers/docs/apis/json-rpc/';
|
|
20
|
+
const RH_DOCUMENTATION = 'https://docs.robinhood.com/chain/connecting/';
|
|
21
|
+
/**
|
|
22
|
+
* Evidence provider label for an RPC read: the configured label for the configured endpoint, and the actual provider when another one answered
|
|
23
|
+
* (the keyed Alchemy archive endpoint, the chain's official RPC or a public fallback), so a report never credits one provider with another's data.
|
|
24
|
+
*/
|
|
25
|
+
export function servedProviderLabel(chain, configured, served) {
|
|
26
|
+
if (!served || served.id === 'primary' || served.id === 'configured-rpc')
|
|
27
|
+
return configured;
|
|
28
|
+
if (served.id === 'alchemy' || /(^|\.)alchemy\.com$/.test(served.host))
|
|
29
|
+
return `${chain}-alchemy`;
|
|
30
|
+
if (served.id === 'official-rpc')
|
|
31
|
+
return `${chain}-official-rpc`;
|
|
32
|
+
// Evidence provider labels allow only letters, digits, dots, colons and hyphens.
|
|
33
|
+
return `${chain}-fallback-rpc:${served.host.replace(/[^a-zA-Z0-9.:-]/g, '')}`.slice(0, 120);
|
|
34
|
+
}
|
|
35
|
+
/** Model checkpoint node id for the Token Researcher AI-assisted summary. */
|
|
36
|
+
export const RESEARCH_ANALYSIS_NODE_ID = 'token-research-summary';
|
|
37
|
+
const StoredSummarySchema = z.strictObject({
|
|
38
|
+
status: z.enum(['completed', 'unavailable']), stopReason: z.string().min(1).max(40), reason: z.string().min(1).max(500).nullable(), withheld: z.number().int().min(0).max(12),
|
|
39
|
+
findings: z.array(z.strictObject({ claim: z.string().min(1).max(600), evidenceKeys: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(8), confidence: z.enum(['supported', 'uncertain']) })).max(12),
|
|
40
|
+
usage: ModelUsageSchema,
|
|
41
|
+
});
|
|
42
|
+
/** Resolution of an earlier attempt's summary request, attached by the runtime to its one-request refusal (`priorRequest`). */
|
|
43
|
+
const PriorRequestSchema = z.strictObject({ status: z.enum(['unresolved', 'metered']), model: z.string().min(1).max(64), inputTokens: z.number().int().nonnegative(), outputTokens: z.number().int().nonnegative(), costMicrousd: z.number().int().nonnegative() });
|
|
44
|
+
class ResearchFailure extends Error {
|
|
45
|
+
category;
|
|
46
|
+
constructor(category, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.category = category;
|
|
49
|
+
this.name = 'ResearchFailure';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
class RuntimeInterruption extends Error {
|
|
53
|
+
original;
|
|
54
|
+
constructor(original) {
|
|
55
|
+
super('The runtime refused or could not persist this operation.');
|
|
56
|
+
this.original = original;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const ERROR_MESSAGES = {
|
|
60
|
+
'provider-unavailable': 'The configured RPC provider could not complete this request.', 'history-unavailable': 'The RPC provider does not have the state or metadata needed at the pinned historical block.', 'rate-limited': 'The RPC provider rate limited this request.', timeout: 'The bounded research deadline or RPC timeout was reached.', cancelled: 'The run was cancelled; no new reads were started.', unsupported: 'This operation or workflow is not supported by this executor.', 'contract-reverted': 'The contract call reverted or the requested method is not exposed.', 'invalid-response': 'The provider or contract returned data outside the expected schema.', 'chain-mismatch': 'The configured RPC returned a different chain identity.', 'snapshot-changed': 'The pinned block changed during the investigation; findings are not a consistent canonical snapshot.', 'limit-exceeded': 'The immutable execution or output budget was reached.', unconfigured: 'A verified server-side provider configuration is not available for this chain.', 'no-contract': 'No contract bytecode was present at the supplied address and snapshot.',
|
|
61
|
+
};
|
|
62
|
+
function categoryOf(error, signal) {
|
|
63
|
+
if (error instanceof ResearchFailure)
|
|
64
|
+
return error.category;
|
|
65
|
+
if (signal.aborted)
|
|
66
|
+
return signal.reason?.name === 'TimeoutError' ? 'timeout' : 'cancelled';
|
|
67
|
+
const parts = [];
|
|
68
|
+
let current = error;
|
|
69
|
+
for (let i = 0; current && typeof current === 'object' && i < 8; i++) {
|
|
70
|
+
const item = current;
|
|
71
|
+
if (item.status === 429 || item.code === -32005 || item.category === 'rate-limited')
|
|
72
|
+
return 'rate-limited';
|
|
73
|
+
// Keyed provider errors (packages/providers keyed.ts) carry their classification as `category` and the JSON-RPC code as `rpcCode`.
|
|
74
|
+
if (item.code === 3 || item.rpcCode === 3 || item.category === 'execution-result')
|
|
75
|
+
return 'contract-reverted';
|
|
76
|
+
if (item.category === 'history-unavailable')
|
|
77
|
+
return 'history-unavailable';
|
|
78
|
+
if (item.category === 'budget-exhausted' || item.category === 'limit-exceeded')
|
|
79
|
+
return 'limit-exceeded';
|
|
80
|
+
if (item.category === 'credential-required')
|
|
81
|
+
return 'unconfigured';
|
|
82
|
+
if (item.category === 'unsupported' || item.category === 'plan-restricted')
|
|
83
|
+
return 'unsupported';
|
|
84
|
+
parts.push(item.name ?? '', item.message ?? '');
|
|
85
|
+
current = item.cause;
|
|
86
|
+
}
|
|
87
|
+
const text = parts.join(' ').toLowerCase();
|
|
88
|
+
if (/429|rate limit|too many requests/.test(text))
|
|
89
|
+
return 'rate-limited';
|
|
90
|
+
if (/revert|execution reverted/.test(text))
|
|
91
|
+
return 'contract-reverted';
|
|
92
|
+
if (/missing trie|historical state|state.*prun|archive|metadata is not found|header not found/.test(text))
|
|
93
|
+
return 'history-unavailable';
|
|
94
|
+
if (/timeout|timed out/.test(text))
|
|
95
|
+
return 'timeout';
|
|
96
|
+
if (/responsebodysize|response body.*size/.test(text))
|
|
97
|
+
return 'limit-exceeded';
|
|
98
|
+
return 'provider-unavailable';
|
|
99
|
+
}
|
|
100
|
+
function validRpcUrl(value) {
|
|
101
|
+
if (!value)
|
|
102
|
+
return false;
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(value);
|
|
105
|
+
return url.protocol === 'https:' && !url.username && !url.password && !/^(localhost|127\.|0\.0\.0\.0|\[?::1\]?)/i.test(url.hostname);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function chainConfiguration(chain, env) {
|
|
112
|
+
const rpcUrl = env[`${chain.toUpperCase()}_RPC_URL`];
|
|
113
|
+
const chainId = chain === 'ethereum' ? 1 : chain === 'base' ? 8453 : Number(env.ROBINHOOD_CHAIN_ID);
|
|
114
|
+
const rhVerified = chain !== 'robinhood' || ([4663, 46630].includes(chainId) && env.ROBINHOOD_DOCUMENTATION_URL === RH_DOCUMENTATION && Number.isFinite(Date.parse(env.ROBINHOOD_VERIFIED_AT ?? '')) && Date.parse(env.ROBINHOOD_VERIFIED_AT ?? '') <= Date.now());
|
|
115
|
+
// A configured RPC, a server-side Alchemy key for a chain Alchemy serves, or Robinhood's documented official RPC.
|
|
116
|
+
const keyedChain = chain === 'robinhood' && chainId === 46630 ? 'robinhood-testnet' : chain;
|
|
117
|
+
const providerAvailable = validRpcUrl(rpcUrl) || (keyedProviderHost('alchemy', keyedChain) !== null && keyedCredentialAvailable('alchemy', env)) || chain === 'robinhood';
|
|
118
|
+
return { chain, chainId, rpcUrl, configured: providerAvailable && rhVerified };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* E05 enforcement for token research. The executor's reads are covered by these registry tools; every quote, acceptance,
|
|
122
|
+
* run, recording and listing admission refuses a chain unless all of them pass `assertToolCoverage`. This check applies
|
|
123
|
+
* even when a fixture executor is injected, because coverage is a property of the chain, not of the transport.
|
|
124
|
+
*/
|
|
125
|
+
export const RESEARCH_COVERAGE_TOOLS = Object.freeze(['evm.token-metadata@0.1.0', 'evm.contract-inspect@0.1.0', 'evm.permissions@0.1.0']);
|
|
126
|
+
export { coverageSummary } from '../../providers/src/coverage.js';
|
|
127
|
+
export function researchCoverageDenial(chain, options = {}) {
|
|
128
|
+
for (const tool of options.tools ?? RESEARCH_COVERAGE_TOOLS) {
|
|
129
|
+
try {
|
|
130
|
+
assertToolCoverage(chain, tool, { env: options.env ?? process.env });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof CoverageError)
|
|
134
|
+
return `Token research is not covered on ${chain}: ${error.message}`;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const policy = READ_SNAPSHOT_POLICIES[chain];
|
|
139
|
+
const refused = policy ? [...new Set(policy.tags.map((tag) => policy.confirmationOf[tag]))].filter((confirmation) => !researchReportAccepts(confirmation)) : [];
|
|
140
|
+
if (refused.length)
|
|
141
|
+
return `Token research is not covered on ${chain}: the Token Researcher report contract in packages/contracts does not yet accept ${refused.join(' or ')} snapshots, so a report could not state its confirmation level honestly.`;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/** True once the contracts owner's report schema accepts this confirmation level. Admission flips automatically when it does. */
|
|
145
|
+
export function researchReportAccepts(confirmation) {
|
|
146
|
+
const snapshot = TokenResearchReportSchema.shape?.snapshot;
|
|
147
|
+
return Boolean(snapshot?.safeParse({ blockNumber: '1', blockHash: `0x${'0'.repeat(64)}`, timestamp: '1', finality: confirmation, canonicality: 'unchecked' }).success);
|
|
148
|
+
}
|
|
149
|
+
export const researchCoveredChains = (chains, env = process.env) => coveredChains(chains, RESEARCH_COVERAGE_TOOLS, { env });
|
|
150
|
+
export function getResearchCoverage(env = process.env) {
|
|
151
|
+
return ['ethereum', 'base', 'robinhood'].map((chain) => {
|
|
152
|
+
const config = chainConfiguration(chain, env);
|
|
153
|
+
const coverageDenial = researchCoverageDenial(chain, { env });
|
|
154
|
+
return { chain, chainId: Number.isSafeInteger(config.chainId) && config.chainId > 0 ? config.chainId : null, configured: config.configured, covered: coverageDenial === null, coverageDenial, enabled: config.configured && coverageDenial === null, verification: 'identity-checked-on-every-run', capabilities: ['erc20-metadata', 'bytecode', 'eip1967-slots', 'owner-method-probe'], limitations: ['Provider configuration is not live coverage evidence.', ...(chain === 'robinhood' ? ['Robinhood Chain reads the latest block pinned by block hash; the official public RPC served hash-pinned state on 2026-09-14.'] : []), 'Holder and liquidity indexes are not connected.', 'Authority inspection is incomplete.', 'Solana is not an EVM adapter.'] };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function makeTransport(url) {
|
|
158
|
+
const client = createPublicClient({ transport: http(url, { retryCount: 0, timeout: 8000, maxResponseBodySize: 65536, fetchOptions: { redirect: 'error' } }), cacheTime: 0 });
|
|
159
|
+
return { request: (method, params, signal) => client.request({ method, params }, { signal, retryCount: 0 }) };
|
|
160
|
+
}
|
|
161
|
+
const unknown = (reason, evidenceIds = []) => ({ status: 'unknown', reason, evidenceIds });
|
|
162
|
+
const observed = (value, evidenceId, note) => ({ status: 'observed', value, evidenceIds: [evidenceId], ...(note ? { note } : {}) });
|
|
163
|
+
const unsupported = (reason) => ({ status: 'unsupported', reason, evidenceIds: [] });
|
|
164
|
+
const skipped = (control) => ({ status: 'skipped', reason: `Intentionally skipped by the immutable research policy (${control}=false); absence of privileged controls is not established.`, evidenceIds: [] });
|
|
165
|
+
function quantity(value) {
|
|
166
|
+
if (typeof value !== 'string' || !/^0x(?:0|[1-9a-fA-F][a-fA-F0-9]*)$/.test(value) || value.length > 66)
|
|
167
|
+
throw new ResearchFailure('invalid-response', ERROR_MESSAGES['invalid-response']);
|
|
168
|
+
return BigInt(value);
|
|
169
|
+
}
|
|
170
|
+
function hexData(value) {
|
|
171
|
+
if (typeof value !== 'string' || !/^0x(?:[a-fA-F0-9]{2})*$/.test(value))
|
|
172
|
+
throw new ResearchFailure('invalid-response', ERROR_MESSAGES['invalid-response']);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function blockData(value) {
|
|
176
|
+
if (!value || typeof value !== 'object')
|
|
177
|
+
throw new ResearchFailure('invalid-response', ERROR_MESSAGES['invalid-response']);
|
|
178
|
+
const block = value;
|
|
179
|
+
if (typeof block.hash !== 'string' || !/^0x[a-fA-F0-9]{64}$/.test(block.hash))
|
|
180
|
+
throw new ResearchFailure('invalid-response', ERROR_MESSAGES['invalid-response']);
|
|
181
|
+
return { blockNumber: quantity(block.number).toString(), blockHash: block.hash, timestamp: quantity(block.timestamp).toString() };
|
|
182
|
+
}
|
|
183
|
+
/** Shared read-only EVM primitives for other first-party executors (for example the transaction inspector). Behaviour is unchanged. */
|
|
184
|
+
export { categoryOf as categorizeRpcFailure, ERROR_MESSAGES as FAILURE_MESSAGES, chainConfiguration as resolveChainConfiguration, makeTransport as createRpcTransport, ResearchFailure as EvmReadFailure, quantity as parseQuantity, hexData as parseHexData, blockData as parseBlockHeader };
|
|
185
|
+
const ANALYSIS_REPORT_LIMITATION = 'Deterministic observations and findings in this report come only from the recorded RPC evidence. The separately labelled AI-assisted summary is one bounded model request over that same evidence; it makes no chain reads and is not a security review.';
|
|
186
|
+
const SUMMARY_SELECTORS = { '0x06fdde03': 'name()', '0x95d89b41': 'symbol()', '0x313ce567': 'decimals()', '0x18160ddd': 'totalSupply()', '0x8da5cb5b': 'owner()' };
|
|
187
|
+
const STATE_READS = new Set(['eth_getCode', 'eth_call', 'eth_getStorageAt']);
|
|
188
|
+
const decimalQuantity = (value) => typeof value === 'string' && /^0x[0-9a-fA-F]{1,64}$/.test(value) ? BigInt(value).toString() : null;
|
|
189
|
+
/**
|
|
190
|
+
* Stable across recovery attempts (which renumber rpc-N ids): the read's method, request and pinned snapshot. Header re-reads are keyed by
|
|
191
|
+
* request only: the same block-number request is recorded before the snapshot is pinned during recovery and after it at completion.
|
|
192
|
+
*/
|
|
193
|
+
export const summaryEvidenceKey = (item) => item.source.method === 'eth_getBlockByNumber'
|
|
194
|
+
? contentHash({ method: item.source.method, request: item.request })
|
|
195
|
+
: contentHash({ method: item.source.method, request: item.request, snapshot: item.snapshot ? { blockNumber: item.snapshot.blockNumber, blockHash: item.snapshot.blockHash.toLowerCase() } : null });
|
|
196
|
+
/**
|
|
197
|
+
* Citable facts for the summary: the pinned state reads and the snapshot header re-read at completion, each with the decoded
|
|
198
|
+
* observations that cite it. Volatile reads (chain identity, the moving latest head) are excluded so a recovery attempt rebuilds
|
|
199
|
+
* identical facts from its checkpointed receipts. Every string here is untrusted chain or provider data.
|
|
200
|
+
*/
|
|
201
|
+
export function summaryFacts(report) {
|
|
202
|
+
const snapshot = report.snapshot;
|
|
203
|
+
const numberTag = snapshot ? `0x${BigInt(snapshot.blockNumber).toString(16)}` : null;
|
|
204
|
+
const observations = ['metadata', 'contract', 'controls'].flatMap((group) => Object.entries(report[group]).map(([field, observation]) => ({ field: `${group}.${field}`, observation })));
|
|
205
|
+
const chosen = new Map();
|
|
206
|
+
for (const item of report.evidence) {
|
|
207
|
+
const state = (STATE_READS.has(item.source.method) || item.source.method === HOLDER_ACTIVITY_METHOD) && item.snapshot !== null;
|
|
208
|
+
const header = item.source.method === 'eth_getBlockByNumber' && numberTag !== null && item.request[0] === numberTag;
|
|
209
|
+
if (state || header)
|
|
210
|
+
chosen.set(summaryEvidenceKey(item), item);
|
|
211
|
+
}
|
|
212
|
+
const facts = [...chosen].map(([key, item]) => {
|
|
213
|
+
if (item.source.method === 'eth_getBlockByNumber') {
|
|
214
|
+
const block = item.result !== null && typeof item.result === 'object' && !Array.isArray(item.result) ? item.result : {};
|
|
215
|
+
return { key, evidenceId: item.id, completeness: item.completeness, summary: { read: 'snapshot block header', method: item.source.method, block: { number: decimalQuantity(block.number), hash: typeof block.hash === 'string' ? block.hash : null, timestamp: decimalQuantity(block.timestamp) } } };
|
|
216
|
+
}
|
|
217
|
+
const call = item.request[0] !== null && typeof item.request[0] === 'object' && !Array.isArray(item.request[0]) ? item.request[0] : null;
|
|
218
|
+
const slot = item.source.method === 'eth_getStorageAt' ? Object.entries(EIP1967_SLOTS).find(([, value]) => value === item.request[1])?.[0] ?? 'unrecognised' : null;
|
|
219
|
+
// 0.3.0 market receipts are summarised from the derived market section, never from raw multicall bytes.
|
|
220
|
+
const marketRead = item.source.method === HOLDER_ACTIVITY_METHOD ? 'recent transfer recipients (provider index page, not a holder list)'
|
|
221
|
+
: typeof call?.to === 'string' && call.to.toLowerCase() === MULTICALL3_ADDRESS.toLowerCase() ? 'DEX pool lookup or pool and recipient balances (Multicall3)' : null;
|
|
222
|
+
const read = marketRead ?? (item.source.method === 'eth_getCode' ? 'contract bytecode' : slot ? `EIP-1967 ${slot} slot` : SUMMARY_SELECTORS[String(call?.data ?? '').toLowerCase()] ?? 'contract call');
|
|
223
|
+
const cited = observations.filter(({ observation }) => observation.evidenceIds.includes(item.id))
|
|
224
|
+
.map(({ field, observation }) => observation.status === 'observed' ? { field, status: observation.status, value: observation.value } : { field, status: observation.status, reason: observation.reason });
|
|
225
|
+
const findings = report.findings.filter((finding) => finding.evidenceIds.includes(item.id)).map((finding) => finding.message);
|
|
226
|
+
const pools = report.market?.pools.pools.filter((pool) => pool.evidenceIds.includes(item.id)).map((pool) => ({ venue: pool.venue, pool: pool.address, quoteToken: pool.quoteSymbol, feeTier: pool.feeTier, stable: pool.stable, tokenBalanceAtomic: pool.tokenBalanceAtomic, quoteBalanceAtomic: pool.quoteBalanceAtomic })) ?? [];
|
|
227
|
+
const recipients = report.market?.recentHolderActivity.accounts.filter((account) => account.evidenceIds.includes(item.id)).map((account) => ({ recipient: account.address, balanceAtomic: account.balanceAtomic, isDiscoveredPool: account.isDiscoveredPool })) ?? [];
|
|
228
|
+
return { key, evidenceId: item.id, completeness: item.completeness, summary: { read, method: item.source.method, target: report.input.address, block: item.snapshot ? { number: item.snapshot.blockNumber, hash: item.snapshot.blockHash } : null, observations: cited, ...(findings.length ? { deterministicFindings: findings } : {}),
|
|
229
|
+
...(pools.length ? { pools } : {}), ...(recipients.length ? { recentRecipientsNotHolders: recipients } : {}) } };
|
|
230
|
+
});
|
|
231
|
+
const context = {
|
|
232
|
+
chain: report.input.chain, chainId: report.chainId, tokenAddress: report.input.address, deterministicReportStatus: report.status, snapshotBlock: snapshot?.blockNumber ?? null,
|
|
233
|
+
errors: report.errors.map((error) => ({ step: error.step, category: error.category })),
|
|
234
|
+
notInspected: [...Object.entries(report.coverage).map(([field, observation]) => ({ field: `coverage.${field}`, status: observation.status })),
|
|
235
|
+
...['mintAuthority', 'freezeAuthority', 'upgradeability'].map((field) => ({ field: `controls.${field}`, status: report.controls[field].status }))],
|
|
236
|
+
};
|
|
237
|
+
return { facts, context };
|
|
238
|
+
}
|
|
239
|
+
function summaryCheckpointKey(report, policy, model, facts, context) {
|
|
240
|
+
const ordered = facts.map(({ key, completeness, summary }) => ({ key, completeness, summary })).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
241
|
+
return contentHash({ scope: 'agentex.token-research-summary.v1', manifestHash: report.manifestHash, inputHash: contentHash(report.input), model, policy: { ...policy }, context, facts: ordered });
|
|
242
|
+
}
|
|
243
|
+
/** Reuses a checkpointed outcome only when it matches these exact facts and every stored citation still passes the provenance gate. */
|
|
244
|
+
function restoreSummary(stored, checkpointKey, facts) {
|
|
245
|
+
const parsed = z.object({ checkpointKey: z.string(), value: StoredSummarySchema }).safeParse(stored);
|
|
246
|
+
if (!parsed.success || parsed.data.checkpointKey !== checkpointKey)
|
|
247
|
+
return null;
|
|
248
|
+
const { value } = parsed.data;
|
|
249
|
+
if (value.status !== 'completed' && value.findings.length)
|
|
250
|
+
return null;
|
|
251
|
+
const idByKey = new Map(facts.map((fact) => [fact.key, fact.evidenceId]));
|
|
252
|
+
const findings = [];
|
|
253
|
+
for (const finding of value.findings) {
|
|
254
|
+
const ids = finding.evidenceKeys.map((key) => idByKey.get(key));
|
|
255
|
+
if (ids.some((id) => id === undefined))
|
|
256
|
+
return null;
|
|
257
|
+
findings.push({ claim: finding.claim, evidenceIds: [...new Set(ids)], confidence: finding.confidence });
|
|
258
|
+
}
|
|
259
|
+
const known = new Map(facts.map((fact) => [fact.evidenceId, { strings: groundingStringsOf(fact.summary), completeness: fact.completeness }]));
|
|
260
|
+
const checked = checkSummaryFindings(findings, known);
|
|
261
|
+
if (checked.rejected.length || checked.findings.length !== findings.length || checked.findings.some((finding, index) => finding.confidence !== findings[index].confidence))
|
|
262
|
+
return null;
|
|
263
|
+
return { value, findings: checked.findings };
|
|
264
|
+
}
|
|
265
|
+
function applySummary(report, value, findings) {
|
|
266
|
+
const analysis = report.analysis;
|
|
267
|
+
analysis.status = value.status;
|
|
268
|
+
analysis.model = value.usage.model;
|
|
269
|
+
analysis.findings = value.status === 'completed' ? findings : [];
|
|
270
|
+
if (value.status === 'completed')
|
|
271
|
+
delete analysis.unavailableReason;
|
|
272
|
+
else
|
|
273
|
+
analysis.unavailableReason = value.reason ?? 'The AI-assisted summary did not complete.';
|
|
274
|
+
report.usage.model = { ...value.usage };
|
|
275
|
+
if (value.withheld)
|
|
276
|
+
report.limitations.push(`${value.withheld} AI-assisted claim${value.withheld === 1 ? ' was' : 's were'} withheld because ${value.withheld === 1 ? 'it' : 'they'} cited evidence outside this report, used values absent from the cited evidence, or gave a verdict or advice.`);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Server model client for the summary, with the same gate as workflow AI analysis: AGENTEX_MODEL_ENABLED=true and a server-side Anthropic key.
|
|
280
|
+
* Refusal fallbacks are off, because a second billed model hop would break the single-request worst-case bound.
|
|
281
|
+
*/
|
|
282
|
+
async function serverSummaryClient(env) {
|
|
283
|
+
if (env.AGENTEX_MODEL_ENABLED !== 'true')
|
|
284
|
+
return null;
|
|
285
|
+
const { createClaudeModelClient, resolveAnthropicKey } = await import('../../model/src/claude.js');
|
|
286
|
+
const apiKey = resolveAnthropicKey(env);
|
|
287
|
+
return apiKey ? createClaudeModelClient({ apiKey, fallbacks: false, timeoutMs: 45_000 }) : null;
|
|
288
|
+
}
|
|
289
|
+
async function attachAnalysis(report, policy, options, signal, deadlineAt) {
|
|
290
|
+
const model = options.model ?? DEFAULT_MODEL_ID;
|
|
291
|
+
const analysis = report.analysis;
|
|
292
|
+
const notRun = (reason) => { analysis.status = 'skipped'; analysis.findings = []; analysis.unavailableReason = reason; };
|
|
293
|
+
if (report.status === 'failed')
|
|
294
|
+
return notRun('The deterministic report did not complete, so no AI-assisted summary was requested.');
|
|
295
|
+
if (signal.aborted)
|
|
296
|
+
return notRun('The run was cancelled or reached its deadline before the AI-assisted summary was requested.');
|
|
297
|
+
const { facts, context } = summaryFacts(report);
|
|
298
|
+
const checkpointKey = summaryCheckpointKey(report, policy, model, facts, context);
|
|
299
|
+
for (const entry of options.restore?.analysis ?? []) {
|
|
300
|
+
if (entry?.nodeId !== RESEARCH_ANALYSIS_NODE_ID)
|
|
301
|
+
continue;
|
|
302
|
+
const restored = restoreSummary(entry.stored, checkpointKey, facts);
|
|
303
|
+
if (!restored)
|
|
304
|
+
continue;
|
|
305
|
+
applySummary(report, restored.value, restored.findings);
|
|
306
|
+
report.limitations.push('The AI-assisted summary was restored from this run\'s checkpoint after its citations were re-checked; no second model request was made.');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (Date.now() > deadlineAt - 5000)
|
|
310
|
+
return notRun('Too little of the run deadline remained to request the AI-assisted summary.');
|
|
311
|
+
let result;
|
|
312
|
+
try {
|
|
313
|
+
result = await runEvidenceSummary({ context, facts: facts.map(({ evidenceId, summary, completeness }) => ({ evidenceId, summary, completeness })), limits: { maximumOutputTokens: policy.maximumOutputTokens, maximumCostMicrousd: policy.maximumCostMicrousd }, signal }, { client: options.modelClient !== undefined ? options.modelClient : await serverSummaryClient(options.env ?? process.env), model, ...(options.modelTariffs ? { tariffs: options.modelTariffs } : {}),
|
|
314
|
+
...(options.beforeModelRequest ? { beforeModelRequest: (permit) => options.beforeModelRequest({ nodeId: RESEARCH_ANALYSIS_NODE_ID, step: 1, ...permit }) } : {}) });
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error?.code !== 'RUN_MODEL_BUDGET_EXHAUSTED')
|
|
318
|
+
throw error;
|
|
319
|
+
analysis.status = 'unavailable';
|
|
320
|
+
analysis.findings = [];
|
|
321
|
+
const prior = PriorRequestSchema.safeParse(error.priorRequest);
|
|
322
|
+
if (!prior.success) {
|
|
323
|
+
analysis.unavailableReason = 'The run model budget refused the AI-assisted summary request; no new request was sent.';
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const { status, model: priorModel, inputTokens, outputTokens, costMicrousd } = prior.data;
|
|
327
|
+
analysis.model = priorModel;
|
|
328
|
+
if (status === 'unresolved') {
|
|
329
|
+
// Never report zero for a request that may have been billed: carry the reserved maximum that settlement also counts.
|
|
330
|
+
const reserved = Math.max(1, costMicrousd);
|
|
331
|
+
report.usage.model = { model: priorModel, inputTokens: 0, outputTokens: 0, costMicrousd: reserved, costStatus: 'unresolved' };
|
|
332
|
+
analysis.unavailableReason = `An earlier attempt sent the AI-assisted summary request but stopped before recording its outcome. Its cost is unresolved, at most ${reserved} micro-USD as reserved; no new request was sent.`;
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
report.usage.model = { model: priorModel, inputTokens, outputTokens, costMicrousd };
|
|
336
|
+
analysis.unavailableReason = 'An earlier attempt already made the AI-assisted summary request and recorded its cost, but its outcome could not be reused for this evidence; no new request was sent.';
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (result.status === 'skipped')
|
|
341
|
+
return notRun(result.reason ?? 'The AI-assisted summary was not requested.');
|
|
342
|
+
const keyById = new Map(facts.map((fact) => [fact.evidenceId, fact.key]));
|
|
343
|
+
const value = { status: result.status === 'completed' ? 'completed' : 'unavailable', stopReason: result.stopReason, reason: result.reason, withheld: Math.min(12, result.rejected.length),
|
|
344
|
+
findings: result.findings.map((finding) => ({ claim: finding.claim, evidenceKeys: finding.evidenceIds.map((id) => keyById.get(id)), confidence: finding.confidence })),
|
|
345
|
+
usage: { model: result.usage.model, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, costMicrousd: result.usage.costMicrousd } };
|
|
346
|
+
if (result.requestSent)
|
|
347
|
+
await options.onAnalysisResult?.({ nodeId: RESEARCH_ANALYSIS_NODE_ID, checkpointKey, value, evidenceIds: [...new Set(result.findings.flatMap((finding) => finding.evidenceIds))],
|
|
348
|
+
usage: { modelTokens: result.usage.inputTokens + result.usage.outputTokens, costMicrousd: result.usage.costMicrousd }, modelUsage: result.usage.entries, stopReason: result.usage.stopReason ?? result.stopReason, modelServed: result.usage.model });
|
|
349
|
+
applySummary(report, value, result.findings);
|
|
350
|
+
}
|
|
351
|
+
/** Purely read-only bounded inspection. Metadata is untrusted data and never instructions. */
|
|
352
|
+
export async function researchToken(rawInput, options = {}) {
|
|
353
|
+
const input = ResearchInputSchema.parse(rawInput);
|
|
354
|
+
const manifest = AgentManifestSchema.parse(options.manifest ?? TOKEN_RESEARCHER_MANIFEST);
|
|
355
|
+
const policy = resolveResearchPolicy(manifest);
|
|
356
|
+
const analysisPolicy = resolveAnalysisPolicy(manifest);
|
|
357
|
+
const env = options.env ?? process.env;
|
|
358
|
+
const config = chainConfiguration(input.chain, env);
|
|
359
|
+
// Per-chain read-snapshot policy: the latest block, pinned by number (Ethereum, Base) or by block hash (Robinhood). Never a finality wait.
|
|
360
|
+
const readPolicy = readSnapshotPolicy(input.chain === 'robinhood' && config.chainId === 46630 ? 'robinhood-testnet' : input.chain);
|
|
361
|
+
const stateTag = (block) => snapshotBlockTag({ ...block, pinBy: readPolicy.pinBy });
|
|
362
|
+
let confirmation = 'sequencer-confirmed';
|
|
363
|
+
const now = options.now ?? (() => new Date());
|
|
364
|
+
const started = now().getTime();
|
|
365
|
+
const deadlineAt = Date.now() + Math.min(60000, manifest.limits.maximumDurationMs);
|
|
366
|
+
const deadline = AbortSignal.timeout(Math.min(60000, manifest.limits.maximumDurationMs));
|
|
367
|
+
const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
|
|
368
|
+
const report = {
|
|
369
|
+
schemaVersion: 'agentex.token-research.v1', agentVersion: manifest.version, manifestHash: contentHash(manifest), researchPolicy: policy, input, status: 'failed', generatedAt: now().toISOString(), chainId: Number.isSafeInteger(config.chainId) && config.chainId > 0 ? config.chainId : null, snapshot: null,
|
|
370
|
+
metadata: { name: unknown('Not read.'), symbol: unknown('Not read.'), decimals: unknown('Not read.'), totalSupplyAtomic: unknown('Not read.'), totalSupplyFormatted: unknown('Decimals and supply must both be observed.') },
|
|
371
|
+
contract: { codePresent: unknown('Not read.'), codeHash: unknown('Not read.'), sourceVerification: unsupported('A verified source-code provider is not connected.') },
|
|
372
|
+
controls: { implementation: policy.readProxySlots ? unknown('Not read.') : skipped('readProxySlots'), admin: policy.readProxySlots ? unknown('Not read.') : skipped('readProxySlots'), beacon: policy.readProxySlots ? unknown('Not read.') : skipped('readProxySlots'), owner: policy.readOwner ? unknown('Not read.') : skipped('readOwner'), mintAuthority: unknown('Generic ERC-20 RPC calls cannot enumerate mint permissions.'), freezeAuthority: unknown('Generic ERC-20 RPC calls cannot enumerate freeze or denylist permissions.'), upgradeability: unknown(policy.readProxySlots ? 'Slot probes cannot rule out custom proxies or other upgrade mechanisms.' : 'Standard proxy-slot reads were intentionally skipped by research policy. Upgrade mechanisms have not been inspected.') },
|
|
373
|
+
coverage: { holders: unsupported('No complete holder index is connected; distribution is not calculated.'), liquidity: unsupported('No supported DEX pool index is connected; liquidity is not calculated.'), deployment: unsupported('No creation-transaction index is connected; issuer identity is not inferred.'), historicalActivity: unsupported('This scope performs no historical activity queries.') },
|
|
374
|
+
findings: [], limitations: [analysisPolicy ? ANALYSIS_REPORT_LIMITATION : 'This report records deterministic reads of token metadata and contract controls. It contains no AI-written investigation or narrative.', 'Token names and symbols are untrusted labels; they do not authenticate an issuer or payment asset.', 'Empty EIP-1967 slots and a missing owner() method do not establish an immutable contract or the absence of privileged controls.', 'A returned owner() address is a contract response, not proof of exclusive control or a human identity.', CONFIRMATION_NOTES['sequencer-confirmed'], 'No security score, investment recommendation or complete holder/liquidity assessment is produced.'],
|
|
375
|
+
errors: [], evidence: [], usage: { rpcCalls: 0, maximumRpcCalls: Math.min(16, manifest.limits.maximumRpcCalls, ...manifest.tools.map((tool) => tool.maximumCalls)), elapsedMs: 0, modelTokens: 0, providerCostAtomic: null, providerCostStatus: 'unpriced' },
|
|
376
|
+
};
|
|
377
|
+
if (!policy.readOwner || !policy.readProxySlots)
|
|
378
|
+
report.limitations.push('Complete status refers to the selected inspection policy only. Intentionally skipped controls carry no observation or evidence of absence.');
|
|
379
|
+
if (analysisPolicy) {
|
|
380
|
+
const requestedModel = (options.model ?? DEFAULT_MODEL_ID).slice(0, 64);
|
|
381
|
+
report.analysis = { label: ANALYSIS_LABEL, status: 'skipped', model: requestedModel, findings: [], unavailableReason: 'The AI-assisted summary runs only after the deterministic reads finish.' };
|
|
382
|
+
report.usage.model = { model: requestedModel, inputTokens: 0, outputTokens: 0, costMicrousd: 0 };
|
|
383
|
+
}
|
|
384
|
+
// 0.3.0 market reads: availability is decided before any read, and an unavailable section is reported unsupported with its reason.
|
|
385
|
+
const marketPolicy = manifest.marketPolicy;
|
|
386
|
+
const marketUnsupported = { pools: null, holders: null };
|
|
387
|
+
if (marketPolicy) {
|
|
388
|
+
const coverageChain = input.chain === 'robinhood' && config.chainId === 46630 ? 'robinhood-testnet' : input.chain;
|
|
389
|
+
const availability = (tool) => {
|
|
390
|
+
if (!options.marketCoverage && options.transport)
|
|
391
|
+
return null;
|
|
392
|
+
try {
|
|
393
|
+
(options.marketCoverage ?? ((chain, ref) => { assertToolCoverage(chain, ref, { env }); }))(coverageChain, tool);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (error instanceof CoverageError)
|
|
398
|
+
return `Not available on this server for ${coverageChain}: ${error.message}`;
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
if (marketPolicy.discoverPools)
|
|
403
|
+
marketUnsupported.pools = availability(MARKET_COVERAGE_TOOLS.pools);
|
|
404
|
+
if (marketPolicy.recentHolderActivity)
|
|
405
|
+
marketUnsupported.holders = availability(MARKET_COVERAGE_TOOLS.holders);
|
|
406
|
+
report.marketPolicy = { ...marketPolicy };
|
|
407
|
+
report.market = buildMarketSection({ policy: marketPolicy, chainId: report.chainId, token: input.address, snapshotBlock: null, evidence: [], unsupported: marketUnsupported });
|
|
408
|
+
report.coverage.holders = { status: 'unknown', reason: 'No holder census or distribution is computed. Balances of recent transfer recipients are shown in the market section; they are not a holder list.', evidenceIds: [] };
|
|
409
|
+
report.coverage.liquidity = { status: 'unknown', reason: 'Total liquidity is not computed. Pools found on the listed venues are shown in the market section with the token balances they hold.', evidenceIds: [] };
|
|
410
|
+
}
|
|
411
|
+
let transport;
|
|
412
|
+
/** 0.3.0 only: the keyed Alchemy host for the transfer index, which no other endpoint can serve. */
|
|
413
|
+
let indexTransport = null;
|
|
414
|
+
const restoredReads = new Map();
|
|
415
|
+
let restoredSnapshot = null;
|
|
416
|
+
const addError = (category, step, evidenceId) => { report.errors.push({ category, message: ERROR_MESSAGES[category], step, ...(evidenceId ? { evidenceId } : {}) }); };
|
|
417
|
+
// Hidden provider work (failover retries, identity checks, cross-checks) needs its own runtime permit and counts against the RPC budget.
|
|
418
|
+
let prepaidAttempt = false;
|
|
419
|
+
const providerAttempt = async ({ method, purpose }) => {
|
|
420
|
+
if (prepaidAttempt) {
|
|
421
|
+
prepaidAttempt = false;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (report.usage.rpcCalls >= report.usage.maximumRpcCalls)
|
|
425
|
+
throw new ResearchFailure('limit-exceeded', 'The RPC call budget was exhausted by provider failover work.');
|
|
426
|
+
let complete;
|
|
427
|
+
try {
|
|
428
|
+
complete = await options.beforeRpc?.({ method, step: `provider-${purpose}`, maximumRpcCalls: report.usage.maximumRpcCalls });
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
throw new RuntimeInterruption(error);
|
|
432
|
+
}
|
|
433
|
+
report.usage.rpcCalls++;
|
|
434
|
+
if (!complete)
|
|
435
|
+
return;
|
|
436
|
+
const settle = complete;
|
|
437
|
+
return async (result) => { try {
|
|
438
|
+
await settle(result);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
throw new RuntimeInterruption(error);
|
|
442
|
+
} };
|
|
443
|
+
};
|
|
444
|
+
const call = async (method, params, step) => {
|
|
445
|
+
if (signal.aborted)
|
|
446
|
+
throw new ResearchFailure(signal.reason?.name === 'TimeoutError' ? 'timeout' : 'cancelled', 'Research interrupted.');
|
|
447
|
+
const requestKey = contentHash({ method, params });
|
|
448
|
+
const cached = report.snapshot ? restoredReads.get(requestKey) : undefined;
|
|
449
|
+
if (cached) {
|
|
450
|
+
try {
|
|
451
|
+
await options.onProgress?.({ step, message: `Reusing checkpointed ${step} at the verified original snapshot.` });
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
throw new RuntimeInterruption(error);
|
|
455
|
+
}
|
|
456
|
+
if (signal.aborted)
|
|
457
|
+
throw new ResearchFailure(signal.reason?.name === 'TimeoutError' ? 'timeout' : 'cancelled', 'Research interrupted.');
|
|
458
|
+
const id = `rpc-${report.evidence.length + 1}`;
|
|
459
|
+
const evidence = { ...structuredClone(cached), id, canonicality: 'unchecked' };
|
|
460
|
+
report.evidence.push(evidence);
|
|
461
|
+
if (cached.completeness === 'error') {
|
|
462
|
+
addError('contract-reverted', step, id);
|
|
463
|
+
throw new ResearchFailure('contract-reverted', ERROR_MESSAGES['contract-reverted']);
|
|
464
|
+
}
|
|
465
|
+
return { result: evidence.result, id };
|
|
466
|
+
}
|
|
467
|
+
if (report.usage.rpcCalls >= report.usage.maximumRpcCalls)
|
|
468
|
+
throw new ResearchFailure('limit-exceeded', 'RPC call budget exhausted.');
|
|
469
|
+
try {
|
|
470
|
+
await options.onProgress?.({ step, message: `Reading ${step}.` });
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
throw new RuntimeInterruption(error);
|
|
474
|
+
}
|
|
475
|
+
if (signal.aborted)
|
|
476
|
+
throw new ResearchFailure(signal.reason?.name === 'TimeoutError' ? 'timeout' : 'cancelled', 'Research interrupted.');
|
|
477
|
+
try {
|
|
478
|
+
await options.beforeRpc?.({ method, step, maximumRpcCalls: report.usage.maximumRpcCalls });
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
throw new RuntimeInterruption(error);
|
|
482
|
+
}
|
|
483
|
+
if (signal.aborted)
|
|
484
|
+
throw new ResearchFailure(signal.reason?.name === 'TimeoutError' ? 'timeout' : 'cancelled', 'Research interrupted.');
|
|
485
|
+
report.usage.rpcCalls++;
|
|
486
|
+
prepaidAttempt = true;
|
|
487
|
+
const id = `rpc-${report.evidence.length + 1}`;
|
|
488
|
+
let result;
|
|
489
|
+
let errorCategory;
|
|
490
|
+
try {
|
|
491
|
+
result = await (method === HOLDER_ACTIVITY_METHOD && indexTransport ? indexTransport : transport).request(method, params, signal);
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
if (error instanceof AttemptRefusedError)
|
|
495
|
+
throw error.original;
|
|
496
|
+
if (error instanceof RuntimeInterruption)
|
|
497
|
+
throw error;
|
|
498
|
+
errorCategory = categoryOf(error, signal);
|
|
499
|
+
result = { error: errorCategory };
|
|
500
|
+
}
|
|
501
|
+
prepaidAttempt = false;
|
|
502
|
+
const resultJson = JSON.parse(JSON.stringify(result));
|
|
503
|
+
const indexed = method === HOLDER_ACTIVITY_METHOD;
|
|
504
|
+
const multicall = method === 'eth_call' && typeof params[0]?.to === 'string' && params[0].to.toLowerCase() === MULTICALL3_ADDRESS.toLowerCase();
|
|
505
|
+
const evidence = { id, tool: 'evm.inspect-token@0.1.0', source: { kind: 'rpc', provider: indexed ? `${input.chain}-alchemy-transfer-index` : servedProviderLabel(input.chain, `${input.chain}-configured-rpc`, transport.servedBy?.()), method, documentationUrl: indexed ? HOLDER_ACTIVITY_DOCUMENTATION : multicall ? MULTICALL3_DOCUMENTATION : RPC_DOCUMENTATION }, chain: input.chain, chainId: config.chainId, request: params, snapshot: report.snapshot ? { blockNumber: report.snapshot.blockNumber, blockHash: report.snapshot.blockHash } : null, observedAt: now().toISOString(), finality: report.snapshot ? 'sequencer-confirmed' : 'unverified', canonicality: 'unchecked', completeness: errorCategory ? 'error' : 'complete-for-request', result: resultJson, contentHash: contentHash(resultJson), privacy: 'public-chain-data' };
|
|
506
|
+
report.evidence.push(evidence);
|
|
507
|
+
try {
|
|
508
|
+
await options.onEvidence?.(structuredClone(evidence));
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
throw new RuntimeInterruption(error);
|
|
512
|
+
}
|
|
513
|
+
if (errorCategory) {
|
|
514
|
+
addError(errorCategory, step, id);
|
|
515
|
+
throw new ResearchFailure(errorCategory, ERROR_MESSAGES[errorCategory]);
|
|
516
|
+
}
|
|
517
|
+
return { result, id };
|
|
518
|
+
};
|
|
519
|
+
const probe = async (method, params, step, decode) => {
|
|
520
|
+
let id;
|
|
521
|
+
try {
|
|
522
|
+
const response = await call(method, params, step);
|
|
523
|
+
id = response.id;
|
|
524
|
+
return observed(decode(response.result), id);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
if (error instanceof RuntimeInterruption)
|
|
528
|
+
throw error;
|
|
529
|
+
const category = categoryOf(error, signal);
|
|
530
|
+
if (!report.errors.some((item) => item.step === step))
|
|
531
|
+
addError(category, step, id);
|
|
532
|
+
if (['cancelled', 'timeout', 'limit-exceeded'].includes(category))
|
|
533
|
+
throw error;
|
|
534
|
+
return { status: category === 'contract-reverted' ? 'unknown' : 'error', reason: ERROR_MESSAGES[category], evidenceIds: id ? [id] : report.errors.filter((item) => item.step === step && item.evidenceId).map((item) => item.evidenceId) };
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
try {
|
|
538
|
+
if (!SupportedTokenResearcherManifestSchema.safeParse(manifest).success || !manifest.chains.includes(input.chain))
|
|
539
|
+
throw new ResearchFailure('unsupported', 'This version asks for research steps the Token Researcher does not support.');
|
|
540
|
+
if (!config.configured)
|
|
541
|
+
throw new ResearchFailure('unconfigured', 'No verified data provider is configured on this server for this chain.');
|
|
542
|
+
if (options.restore) {
|
|
543
|
+
if (options.restore.manifestHash !== report.manifestHash || options.restore.inputHash !== contentHash(input) || !Array.isArray(options.restore.evidence) || options.restore.evidence.length > 16 || Buffer.byteLength(JSON.stringify(options.restore.evidence)) > 1048576)
|
|
544
|
+
throw new ResearchFailure('invalid-response', 'Checkpoint identity or size is invalid.');
|
|
545
|
+
for (const raw of options.restore.evidence) {
|
|
546
|
+
const checked = EvidenceItemSchema.safeParse(raw);
|
|
547
|
+
if (!checked.success)
|
|
548
|
+
throw new ResearchFailure('invalid-response', 'Checkpoint receipt schema is invalid.');
|
|
549
|
+
const item = checked.data;
|
|
550
|
+
if (item.chain !== input.chain || item.chainId !== config.chainId || item.contentHash !== contentHash(item.result) || item.canonicality === 'changed')
|
|
551
|
+
throw new ResearchFailure('invalid-response', 'Checkpoint receipt integrity is invalid.');
|
|
552
|
+
const isStateRead = ['eth_getCode', 'eth_call', 'eth_getStorageAt'].includes(item.source.method);
|
|
553
|
+
if (!item.snapshot) {
|
|
554
|
+
if (isStateRead)
|
|
555
|
+
throw new ResearchFailure('invalid-response', 'Checkpointed state reads must have a pinned snapshot.');
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (restoredSnapshot && (item.snapshot.blockNumber !== restoredSnapshot.blockNumber || item.snapshot.blockHash.toLowerCase() !== restoredSnapshot.blockHash.toLowerCase()))
|
|
559
|
+
throw new ResearchFailure('invalid-response', 'Checkpoint receipts do not share one snapshot.');
|
|
560
|
+
restoredSnapshot = item.snapshot;
|
|
561
|
+
if (input.snapshotBlock !== undefined && input.snapshotBlock !== item.snapshot.blockNumber)
|
|
562
|
+
throw new ResearchFailure('invalid-response', 'Checkpoint snapshot disagrees with the accepted input.');
|
|
563
|
+
if (!isStateRead)
|
|
564
|
+
continue;
|
|
565
|
+
const blockTag = stateTag(item.snapshot);
|
|
566
|
+
const allowedReads = new Set([contentHash({ method: 'eth_getCode', params: [input.address, blockTag] })]);
|
|
567
|
+
for (const name of ['name', 'symbol', 'decimals', 'totalSupply', ...(policy.readOwner ? ['owner'] : [])])
|
|
568
|
+
allowedReads.add(contentHash({ method: 'eth_call', params: [{ to: input.address, data: encodeFunctionData({ abi: ABI, functionName: name }), gas: '0x7a120' }, blockTag] }));
|
|
569
|
+
if (policy.readProxySlots)
|
|
570
|
+
for (const slot of Object.values(EIP1967_SLOTS))
|
|
571
|
+
allowedReads.add(contentHash({ method: 'eth_getStorageAt', params: [input.address, slot, blockTag] }));
|
|
572
|
+
const venues = marketVenuesFor(config.chainId);
|
|
573
|
+
if (manifest.marketPolicy?.discoverPools && venues)
|
|
574
|
+
allowedReads.add(contentHash({ method: 'eth_call', params: [marketCall(encodeDiscovery(discoveryPlan(venues, input.address))), blockTag] }));
|
|
575
|
+
const key = contentHash({ method: item.source.method, params: item.request });
|
|
576
|
+
// A balances multicall is derived from earlier reads, so it is admitted by shape: the bounded balanceOf and pool-state selectors only.
|
|
577
|
+
const marketBalances = Boolean(manifest.marketPolicy) && item.source.method === 'eth_call' && item.request.length === 2 && contentHash(item.request[1]) === contentHash(blockTag) && marketCallInScope(item.request[0], input.address, config.chainId);
|
|
578
|
+
if (!allowedReads.has(key) && !marketBalances)
|
|
579
|
+
throw new ResearchFailure('invalid-response', 'Checkpoint request is outside the immutable inspection policy or accepted address/block scope.');
|
|
580
|
+
const deterministicRevert = item.completeness === 'error' && contentHash(item.result) === contentHash({ error: 'contract-reverted' });
|
|
581
|
+
if (item.completeness !== 'complete-for-request' && !deterministicRevert)
|
|
582
|
+
continue;
|
|
583
|
+
const prior = restoredReads.get(key);
|
|
584
|
+
if (prior && prior.contentHash !== item.contentHash)
|
|
585
|
+
throw new ResearchFailure('invalid-response', 'Checkpointed reads disagree at the same scope.');
|
|
586
|
+
restoredReads.set(key, item);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Product transport: Alchemy (keyed, mandatory per-run unit budget) first when configured, then the configured or official RPC.
|
|
590
|
+
const runBudget = new RunUnitBudget(KEYED_PROVIDER_POLICIES.alchemy.perRunUnits);
|
|
591
|
+
transport = options.transport ?? createChainReadTransport(readPolicy.chain, { env, rpcUrl: validRpcUrl(config.rpcUrl) ? config.rpcUrl : null, rpcTransport: makeTransport, runBudget, beforeAttempt: providerAttempt });
|
|
592
|
+
// The transfer index is an Alchemy method: it goes only to the keyed Alchemy host, never failed over to an RPC that cannot serve it,
|
|
593
|
+
// under the same run unit budget and with every attempt permitted and counted like any other read (no retry).
|
|
594
|
+
if (!options.transport && marketPolicy?.recentHolderActivity && !marketUnsupported.holders && keyedProviderHost('alchemy', readPolicy.chain) && keyedCredentialAvailable('alchemy', env)) {
|
|
595
|
+
indexTransport = createKeyedTransport('alchemy', readPolicy.chain, { env, runBudget, maximumRetries: 0, // With no retries each keyed request is one attempt, prepaid by the tool's own permit, so no hidden completion arises here.
|
|
596
|
+
beforeAttempt: async ({ method }) => { await providerAttempt({ method, purpose: 'request' }); } });
|
|
597
|
+
}
|
|
598
|
+
const identity = await call('eth_chainId', [], 'chain-identity');
|
|
599
|
+
if (quantity(identity.result) !== BigInt(config.chainId))
|
|
600
|
+
throw new ResearchFailure('chain-mismatch', 'RPC chain mismatch.');
|
|
601
|
+
let head = null;
|
|
602
|
+
for (const [index, tag] of readPolicy.tags.entries()) {
|
|
603
|
+
const last = index === readPolicy.tags.length - 1;
|
|
604
|
+
try {
|
|
605
|
+
const read = await call('eth_getBlockByNumber', [tag, false], `${tag}-snapshot`);
|
|
606
|
+
if (read.result === null && !last)
|
|
607
|
+
continue;
|
|
608
|
+
head = read;
|
|
609
|
+
confirmation = readPolicy.confirmationOf[tag];
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (last || error instanceof RuntimeInterruption || ['cancelled', 'timeout', 'limit-exceeded', 'rate-limited'].includes(categoryOf(error, signal)))
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (!head)
|
|
618
|
+
throw new ResearchFailure('provider-unavailable', 'The provider served no snapshot block.');
|
|
619
|
+
if (!researchReportAccepts(confirmation))
|
|
620
|
+
throw new ResearchFailure('unsupported', `The Token Researcher report contract does not yet accept ${confirmation} snapshots.`);
|
|
621
|
+
const headBlock = blockData(head.result);
|
|
622
|
+
let selectedBlock = headBlock;
|
|
623
|
+
if (restoredSnapshot) {
|
|
624
|
+
// Recovery re-reads the original height: an unchanged hash is reused, a reorged one fails the run instead of mixing states.
|
|
625
|
+
if (BigInt(restoredSnapshot.blockNumber) > BigInt(headBlock.blockNumber))
|
|
626
|
+
throw new ResearchFailure('snapshot-changed', 'The prior snapshot is above this provider\'s latest block.');
|
|
627
|
+
const original = await call('eth_getBlockByNumber', [`0x${BigInt(restoredSnapshot.blockNumber).toString(16)}`, false], 'restored-snapshot-canonicality');
|
|
628
|
+
selectedBlock = blockData(original.result);
|
|
629
|
+
if (selectedBlock.blockNumber !== restoredSnapshot.blockNumber || selectedBlock.blockHash.toLowerCase() !== restoredSnapshot.blockHash.toLowerCase())
|
|
630
|
+
throw new ResearchFailure('snapshot-changed', 'The prior snapshot changed before recovery.');
|
|
631
|
+
report.limitations.push('Checkpointed state reads retain their original observation times; the original snapshot was revalidated before reuse and again at completion.');
|
|
632
|
+
}
|
|
633
|
+
else if (input.snapshotBlock !== undefined) {
|
|
634
|
+
if (BigInt(input.snapshotBlock) > BigInt(headBlock.blockNumber))
|
|
635
|
+
throw new ResearchFailure('unsupported', 'Requested snapshot is newer than the latest block.');
|
|
636
|
+
const requested = await call('eth_getBlockByNumber', [`0x${BigInt(input.snapshotBlock).toString(16)}`, false], 'requested-snapshot');
|
|
637
|
+
selectedBlock = blockData(requested.result);
|
|
638
|
+
if (selectedBlock.blockNumber !== input.snapshotBlock)
|
|
639
|
+
throw new ResearchFailure('invalid-response', 'Returned snapshot height does not match the request.');
|
|
640
|
+
}
|
|
641
|
+
report.snapshot = { ...selectedBlock, finality: confirmation, canonicality: 'unchecked' };
|
|
642
|
+
const blockTag = stateTag(selectedBlock);
|
|
643
|
+
const numberTag = `0x${BigInt(selectedBlock.blockNumber).toString(16)}`;
|
|
644
|
+
const code = await call('eth_getCode', [input.address, blockTag], 'contract-bytecode');
|
|
645
|
+
const bytecode = hexData(code.result);
|
|
646
|
+
report.contract.codePresent = observed(bytecode !== '0x', code.id);
|
|
647
|
+
report.contract.codeHash = observed(keccak256(bytecode), code.id);
|
|
648
|
+
if (bytecode === '0x')
|
|
649
|
+
throw new ResearchFailure('no-contract', 'No deployed code at this snapshot.');
|
|
650
|
+
report.status = 'partial';
|
|
651
|
+
for (const name of ['name', 'symbol', 'decimals', 'totalSupply']) {
|
|
652
|
+
const destination = name === 'totalSupply' ? 'totalSupplyAtomic' : name;
|
|
653
|
+
report.metadata[destination] = await probe('eth_call', [{ to: input.address, data: encodeFunctionData({ abi: ABI, functionName: name }), gas: '0x7a120' }, blockTag], `token-${name}`, (value) => {
|
|
654
|
+
const data = hexData(value);
|
|
655
|
+
if (data === '0x')
|
|
656
|
+
throw new ResearchFailure('contract-reverted', 'Method returned no data.');
|
|
657
|
+
let decoded;
|
|
658
|
+
try {
|
|
659
|
+
decoded = decodeFunctionResult({ abi: ABI, functionName: name, data });
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
throw new ResearchFailure('invalid-response', 'ABI response invalid.');
|
|
663
|
+
}
|
|
664
|
+
if (typeof decoded === 'string') {
|
|
665
|
+
if (Buffer.byteLength(decoded, 'utf8') > 256 || Array.from(decoded).some((character) => { const code = character.charCodeAt(0); return code === 127 || (code < 32 && ![9, 10, 13].includes(code)); }))
|
|
666
|
+
throw new ResearchFailure('invalid-response', 'Token label exceeds metadata limits.');
|
|
667
|
+
return decoded;
|
|
668
|
+
}
|
|
669
|
+
if (typeof decoded === 'bigint')
|
|
670
|
+
return decoded.toString();
|
|
671
|
+
if (typeof decoded === 'number' && Number.isInteger(decoded) && decoded >= 0 && decoded <= 255)
|
|
672
|
+
return decoded;
|
|
673
|
+
throw new ResearchFailure('invalid-response', 'Unexpected token metadata type.');
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if (report.metadata.decimals.status === 'observed' && report.metadata.totalSupplyAtomic.status === 'observed') {
|
|
677
|
+
const decimals = Number(report.metadata.decimals.value);
|
|
678
|
+
report.metadata.totalSupplyFormatted = { status: 'observed', value: formatUnits(BigInt(String(report.metadata.totalSupplyAtomic.value)), decimals), evidenceIds: [...report.metadata.decimals.evidenceIds, ...report.metadata.totalSupplyAtomic.evidenceIds], note: 'Exact display conversion; atomic supply is retained.' };
|
|
679
|
+
}
|
|
680
|
+
if (!policy.readProxySlots) {
|
|
681
|
+
try {
|
|
682
|
+
await options.onProgress?.({ step: 'eip1967-slots', message: 'Skipping standard proxy-slot reads as selected by the immutable research policy.' });
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
throw new RuntimeInterruption(error);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (policy.readProxySlots)
|
|
689
|
+
for (const [role, slot] of Object.entries(EIP1967_SLOTS)) {
|
|
690
|
+
const slotResult = await probe('eth_getStorageAt', [input.address, slot, blockTag], `eip1967-${role}`, (value) => {
|
|
691
|
+
const data = hexData(value);
|
|
692
|
+
if (!/^0x0{24}[a-fA-F0-9]{40}$/.test(data))
|
|
693
|
+
throw new ResearchFailure('invalid-response', 'EIP-1967 slot does not encode an address.');
|
|
694
|
+
return getAddress(`0x${data.slice(26)}`);
|
|
695
|
+
});
|
|
696
|
+
report.controls[role] = slotResult.status === 'observed' && slotResult.value === ZERO_ADDRESS ? { status: 'not-detected', reason: 'The standard EIP-1967 slot is zero at this snapshot. Other control mechanisms are not excluded.', evidenceIds: slotResult.evidenceIds } : slotResult;
|
|
697
|
+
if (slotResult.status === 'observed' && slotResult.value !== ZERO_ADDRESS)
|
|
698
|
+
report.findings.push({ code: `eip1967-${role}-populated`, message: `The standard ${role} slot contains ${String(slotResult.value)}. This is an observed storage value; proxy behaviour and controller permissions are not fully verified.`, evidenceIds: slotResult.evidenceIds });
|
|
699
|
+
}
|
|
700
|
+
if (!policy.readOwner) {
|
|
701
|
+
try {
|
|
702
|
+
await options.onProgress?.({ step: 'owner-method', message: 'Skipping owner() as selected by the immutable research policy.' });
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
throw new RuntimeInterruption(error);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (policy.readOwner)
|
|
709
|
+
report.controls.owner = await probe('eth_call', [{ to: input.address, data: encodeFunctionData({ abi: ABI, functionName: 'owner' }), gas: '0x7a120' }, blockTag], 'owner-method', (value) => {
|
|
710
|
+
const data = hexData(value);
|
|
711
|
+
if (data === '0x')
|
|
712
|
+
throw new ResearchFailure('contract-reverted', 'No owner response.');
|
|
713
|
+
try {
|
|
714
|
+
return getAddress(decodeFunctionResult({ abi: ABI, functionName: 'owner', data }));
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
throw new ResearchFailure('invalid-response', 'Owner response invalid.');
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
if (marketPolicy) {
|
|
721
|
+
// At most three reads at the pinned block: pool discovery, one indexed transfer page, and one balances multicall. A failed read is a
|
|
722
|
+
// recorded error (partial report), never an empty result; budget, cancellation and deadline stops propagate.
|
|
723
|
+
const marketRead = async (method, params, step) => {
|
|
724
|
+
try {
|
|
725
|
+
return await call(method, params, step);
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
if (error instanceof RuntimeInterruption)
|
|
729
|
+
throw error;
|
|
730
|
+
const category = categoryOf(error, signal);
|
|
731
|
+
if (!report.errors.some((item) => item.step === step))
|
|
732
|
+
addError(category, step);
|
|
733
|
+
if (['cancelled', 'timeout', 'limit-exceeded'].includes(category))
|
|
734
|
+
throw error;
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
const venues = marketVenuesFor(report.chainId);
|
|
739
|
+
let found = [];
|
|
740
|
+
let recipients = [];
|
|
741
|
+
if (marketPolicy.discoverPools && !marketUnsupported.pools && venues) {
|
|
742
|
+
const plan = discoveryPlan(venues, input.address);
|
|
743
|
+
const read = await marketRead('eth_call', [marketCall(encodeDiscovery(plan)), blockTag], 'dex-pool-discovery');
|
|
744
|
+
if (read) {
|
|
745
|
+
try {
|
|
746
|
+
found = poolsFromDiscovery(plan, decodeAggregate(read.result, plan.length)).found;
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
addError('invalid-response', 'dex-pool-discovery', read.id);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (marketPolicy.recentHolderActivity && !marketUnsupported.holders) {
|
|
754
|
+
const read = await marketRead(HOLDER_ACTIVITY_METHOD, [holderTransferRequest(input.address, selectedBlock.blockNumber)], 'recent-holder-activity');
|
|
755
|
+
if (read) {
|
|
756
|
+
try {
|
|
757
|
+
recipients = recipientsFromTransfers(read.result, input.address, selectedBlock.blockNumber).recipients;
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
addError('invalid-response', 'recent-holder-activity', read.id);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const balances = marketBalancesData(input.address, found.slice(0, MAXIMUM_MARKET_POOLS), recipients);
|
|
765
|
+
if (balances) {
|
|
766
|
+
const read = await marketRead('eth_call', [marketCall(balances.data), blockTag], 'market-balances');
|
|
767
|
+
if (read) {
|
|
768
|
+
try {
|
|
769
|
+
decodeAggregate(read.result, balances.plan.length);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
addError('invalid-response', 'market-balances', read.id);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const ending = await call('eth_getBlockByNumber', [numberTag, false], 'snapshot-canonicality');
|
|
778
|
+
const endBlock = blockData(ending.result);
|
|
779
|
+
if (endBlock.blockHash.toLowerCase() !== selectedBlock.blockHash.toLowerCase() || endBlock.blockNumber !== selectedBlock.blockNumber) {
|
|
780
|
+
report.snapshot.canonicality = 'changed';
|
|
781
|
+
for (const item of report.evidence)
|
|
782
|
+
if (item.snapshot)
|
|
783
|
+
item.canonicality = 'changed';
|
|
784
|
+
throw new ResearchFailure('snapshot-changed', 'Snapshot changed.');
|
|
785
|
+
}
|
|
786
|
+
report.snapshot.canonicality = 'verified-at-completion';
|
|
787
|
+
for (const item of report.evidence)
|
|
788
|
+
if (item.snapshot)
|
|
789
|
+
item.canonicality = 'verified-at-completion';
|
|
790
|
+
report.status = report.errors.length ? 'partial' : 'complete';
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
if (error instanceof RuntimeInterruption)
|
|
794
|
+
throw error.original;
|
|
795
|
+
const category = categoryOf(error, signal);
|
|
796
|
+
if (!report.errors.some((item) => item.category === category))
|
|
797
|
+
addError(category, 'research');
|
|
798
|
+
if (['chain-mismatch', 'snapshot-changed', 'no-contract', 'unconfigured', 'cancelled'].includes(category))
|
|
799
|
+
report.status = 'failed';
|
|
800
|
+
}
|
|
801
|
+
// The market section is always derived from this report's own receipts, including after an interruption.
|
|
802
|
+
if (marketPolicy) {
|
|
803
|
+
report.market = buildMarketSection({ policy: marketPolicy, chainId: report.chainId, token: input.address, snapshotBlock: report.snapshot?.blockNumber ?? null, evidence: report.evidence, unsupported: marketUnsupported });
|
|
804
|
+
report.coverage.holders.evidenceIds = [...report.market.recentHolderActivity.evidenceIds];
|
|
805
|
+
report.coverage.liquidity.evidenceIds = [...report.market.pools.evidenceIds];
|
|
806
|
+
}
|
|
807
|
+
// Captured facts remain inspectable after interruption, but an unchecked snapshot is
|
|
808
|
+
// never an accepted partial deliverable, including when facts came from checkpoints.
|
|
809
|
+
if (report.status === 'partial' && report.snapshot?.canonicality !== 'verified-at-completion')
|
|
810
|
+
report.status = 'failed';
|
|
811
|
+
// One bounded model request over the finished evidence. Runtime refusals other than the model budget propagate unchanged.
|
|
812
|
+
if (analysisPolicy)
|
|
813
|
+
await attachAnalysis(report, analysisPolicy, options, signal, deadlineAt);
|
|
814
|
+
report.generatedAt = now().toISOString();
|
|
815
|
+
report.usage.elapsedMs = Math.max(0, now().getTime() - started);
|
|
816
|
+
// 0.1.0/0.2.0 keep the 128 KiB ceiling; a 0.3.0 market report may use up to 256 KiB, as the other first-party agents do.
|
|
817
|
+
const outputBudget = Math.min(manifest.marketPolicy ? 262144 : 131072, manifest.limits.maximumOutputBytes);
|
|
818
|
+
if (report.analysis?.findings.length && Buffer.byteLength(JSON.stringify(report)) > outputBudget) {
|
|
819
|
+
report.analysis.status = 'unavailable';
|
|
820
|
+
report.analysis.findings = [];
|
|
821
|
+
report.analysis.unavailableReason = 'The AI-assisted findings did not fit the report output budget and were withheld.';
|
|
822
|
+
}
|
|
823
|
+
if (Buffer.byteLength(JSON.stringify(report)) > outputBudget) {
|
|
824
|
+
report.status = 'failed';
|
|
825
|
+
// Preserve verifiable hashes while withholding the oversized artifact from callers.
|
|
826
|
+
throw new ResearchFailure('limit-exceeded', 'The captured report exceeds the configured output budget; no truncated report is accepted.');
|
|
827
|
+
}
|
|
828
|
+
return TokenResearchReportSchema.parse(report);
|
|
829
|
+
}
|