@crediolabs/policy-synth 0.1.4 → 0.1.6
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/LICENSE +21 -0
- package/dist/codegen/template.js +3 -3
- package/dist/record/decode.d.ts +6 -3
- package/dist/record/decode.js +56 -3
- package/dist/record/freshness.d.ts +14 -1
- package/dist/record/freshness.js +32 -2
- package/dist/record/index.d.ts +11 -0
- package/dist/record/index.js +25 -0
- package/dist/record/movements.d.ts +15 -3
- package/dist/record/movements.js +42 -7
- package/dist/run/index.d.ts +43 -0
- package/dist/run/index.js +222 -0
- package/dist/run/schemas.d.ts +2016 -0
- package/dist/run/schemas.js +223 -0
- package/dist/synth/address.d.ts +7 -0
- package/dist/synth/address.js +12 -0
- package/dist/synth/compose-from-recording.d.ts +4 -3
- package/dist/synth/compose-from-recording.js +16 -6
- package/dist/synth/deny-cases.d.ts +12 -2
- package/dist/synth/deny-cases.js +76 -4
- package/dist/synth/evaluate.js +2 -2
- package/dist/synth/index.d.ts +1 -0
- package/dist/synth/index.js +4 -0
- package/dist/synth/minimize.d.ts +1 -1
- package/dist/synth/minimize.js +3 -3
- package/dist/synth/synthesize-from-recording.d.ts +5 -2
- package/dist/synth/synthesize-from-recording.js +175 -69
- package/dist/types.d.ts +41 -1
- package/dist/types.js +17 -0
- package/dist/verify/simulate.js +18 -2
- package/dist/verify/verify.js +18 -2
- package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
- package/dist-cjs/adapters/interpreter/adapter.js +527 -0
- package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
- package/dist-cjs/adapters/interpreter/index.js +8 -0
- package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
- package/dist-cjs/adapters/oz/adapter.js +289 -0
- package/dist-cjs/adapters/oz/index.d.ts +1 -0
- package/dist-cjs/adapters/oz/index.js +8 -0
- package/dist-cjs/codegen/compile-gate.d.ts +33 -0
- package/dist-cjs/codegen/compile-gate.js +123 -0
- package/dist-cjs/codegen/index.d.ts +2 -0
- package/dist-cjs/codegen/index.js +14 -0
- package/dist-cjs/codegen/template.d.ts +18 -0
- package/dist-cjs/codegen/template.js +134 -0
- package/dist-cjs/errors.d.ts +37 -0
- package/dist-cjs/errors.js +3 -0
- package/dist-cjs/index.d.ts +11 -0
- package/dist-cjs/index.js +27 -0
- package/dist-cjs/ir/index.d.ts +1 -0
- package/dist-cjs/ir/index.js +3 -0
- package/dist-cjs/ir/types.d.ts +108 -0
- package/dist-cjs/ir/types.js +12 -0
- package/dist-cjs/mandate/index.d.ts +2 -0
- package/dist-cjs/mandate/index.js +6 -0
- package/dist-cjs/mandate/to-ir.d.ts +3 -0
- package/dist-cjs/mandate/to-ir.js +63 -0
- package/dist-cjs/mandate/types.d.ts +20 -0
- package/dist-cjs/mandate/types.js +9 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/predicate/encode.d.ts +10 -0
- package/dist-cjs/predicate/encode.js +252 -0
- package/dist-cjs/predicate/index.d.ts +1 -0
- package/dist-cjs/predicate/index.js +6 -0
- package/dist-cjs/record/decode.d.ts +79 -0
- package/dist-cjs/record/decode.js +441 -0
- package/dist-cjs/record/freshness.d.ts +30 -0
- package/dist-cjs/record/freshness.js +85 -0
- package/dist-cjs/record/index.d.ts +32 -0
- package/dist-cjs/record/index.js +191 -0
- package/dist-cjs/record/movements.d.ts +32 -0
- package/dist-cjs/record/movements.js +227 -0
- package/dist-cjs/record/rpc.d.ts +22 -0
- package/dist-cjs/record/rpc.js +74 -0
- package/dist-cjs/record/validate.d.ts +22 -0
- package/dist-cjs/record/validate.js +63 -0
- package/dist-cjs/registry/identify.d.ts +11 -0
- package/dist-cjs/registry/identify.js +87 -0
- package/dist-cjs/registry/index.d.ts +3 -0
- package/dist-cjs/registry/index.js +15 -0
- package/dist-cjs/registry/known-addresses.d.ts +16 -0
- package/dist-cjs/registry/known-addresses.js +53 -0
- package/dist-cjs/registry/protocols.d.ts +38 -0
- package/dist-cjs/registry/protocols.js +153 -0
- package/dist-cjs/review-card/builder.d.ts +14 -0
- package/dist-cjs/review-card/builder.js +264 -0
- package/dist-cjs/review-card/conflict.d.ts +40 -0
- package/dist-cjs/review-card/conflict.js +114 -0
- package/dist-cjs/review-card/cross-check.d.ts +11 -0
- package/dist-cjs/review-card/cross-check.js +151 -0
- package/dist-cjs/review-card/index.d.ts +3 -0
- package/dist-cjs/review-card/index.js +10 -0
- package/dist-cjs/run/index.d.ts +43 -0
- package/dist-cjs/run/index.js +237 -0
- package/dist-cjs/run/schemas.d.ts +2016 -0
- package/dist-cjs/run/schemas.js +226 -0
- package/dist-cjs/seams/index.d.ts +1 -0
- package/dist-cjs/seams/index.js +3 -0
- package/dist-cjs/seams/types.d.ts +66 -0
- package/dist-cjs/seams/types.js +12 -0
- package/dist-cjs/synth/address.d.ts +7 -0
- package/dist-cjs/synth/address.js +15 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +65 -0
- package/dist-cjs/synth/compose-from-recording.js +366 -0
- package/dist-cjs/synth/deny-cases.d.ts +22 -0
- package/dist-cjs/synth/deny-cases.js +438 -0
- package/dist-cjs/synth/evaluate.d.ts +39 -0
- package/dist-cjs/synth/evaluate.js +428 -0
- package/dist-cjs/synth/harness.d.ts +16 -0
- package/dist-cjs/synth/harness.js +29 -0
- package/dist-cjs/synth/index.d.ts +10 -0
- package/dist-cjs/synth/index.js +28 -0
- package/dist-cjs/synth/lower.d.ts +23 -0
- package/dist-cjs/synth/lower.js +119 -0
- package/dist-cjs/synth/minimize.d.ts +4 -0
- package/dist-cjs/synth/minimize.js +41 -0
- package/dist-cjs/synth/predicate-literals.d.ts +5 -0
- package/dist-cjs/synth/predicate-literals.js +28 -0
- package/dist-cjs/synth/scope.d.ts +26 -0
- package/dist-cjs/synth/scope.js +81 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
- package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
- package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
- package/dist-cjs/synth/synthesize-from-recording.js +759 -0
- package/dist-cjs/types.d.ts +311 -0
- package/dist-cjs/types.js +55 -0
- package/dist-cjs/verify/envelope.d.ts +15 -0
- package/dist-cjs/verify/envelope.js +23 -0
- package/dist-cjs/verify/index.d.ts +3 -0
- package/dist-cjs/verify/index.js +8 -0
- package/dist-cjs/verify/simulate.d.ts +31 -0
- package/dist-cjs/verify/simulate.js +261 -0
- package/dist-cjs/verify/verify.d.ts +21 -0
- package/dist-cjs/verify/verify.js +192 -0
- package/package.json +43 -3
- package/src/codegen/template.ts +3 -3
- package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +171 -0
- package/src/record/corpus-fixtures.json +532 -0
- package/src/record/decode.ts +63 -10
- package/src/record/freshness.ts +34 -2
- package/src/record/index.ts +40 -0
- package/src/record/movements.ts +40 -7
- package/src/run/index.ts +277 -0
- package/src/run/schemas.ts +250 -0
- package/src/synth/address.ts +14 -0
- package/src/synth/compose-from-recording.ts +20 -9
- package/src/synth/deny-cases.ts +87 -4
- package/src/synth/evaluate.ts +2 -2
- package/src/synth/index.ts +4 -0
- package/src/synth/minimize.ts +7 -3
- package/src/synth/synthesize-from-recording.ts +200 -68
- package/src/types.ts +37 -1
- package/src/verify/simulate.ts +21 -2
- package/src/verify/verify.ts +21 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/record/index.ts - the recorder orchestrator.
|
|
3
|
+
//
|
|
4
|
+
// `recordTransaction(input)` is the single public entry point for the
|
|
5
|
+
// synthesizer (the next phase) to consume. Two modes:
|
|
6
|
+
// - on-chain (hash set): fetch via the injected RPC fetcher (default:
|
|
7
|
+
// public Soroban RPC for the requested network).
|
|
8
|
+
// - simulation/XDR (xdr set): decode the provided envelope XDR directly.
|
|
9
|
+
//
|
|
10
|
+
// In both modes the recorder:
|
|
11
|
+
// 1. Decodes the envelope to sourceAccount, signers, invocations (+ diagnostic
|
|
12
|
+
// subInvocations), args -> ScVal subset.
|
|
13
|
+
// 2. Extracts token movements from raw events (or skips in XDR mode).
|
|
14
|
+
// 3. Validates the parsed movements/invocations against the raw events.
|
|
15
|
+
// 4. Computes parseConfidence.
|
|
16
|
+
// 5. Refuses with ToolError RECORDING_VALIDATION_FAILED when overall < threshold.
|
|
17
|
+
//
|
|
18
|
+
// Returns ToolResponse<RecordedTransaction> per the canonical envelope
|
|
19
|
+
// defined in src/errors.ts.
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.recordTransaction = recordTransaction;
|
|
22
|
+
const decode_ts_1 = require("./decode.js");
|
|
23
|
+
const freshness_ts_1 = require("./freshness.js");
|
|
24
|
+
const movements_ts_1 = require("./movements.js");
|
|
25
|
+
const rpc_ts_1 = require("./rpc.js");
|
|
26
|
+
const validate_ts_1 = require("./validate.js");
|
|
27
|
+
async function recordTransaction(input) {
|
|
28
|
+
if (!input.network) {
|
|
29
|
+
return err('RECORDING_FAILED', 'network required', false);
|
|
30
|
+
}
|
|
31
|
+
// A confidenceOverride outside [0, 1] would disable the fail-closed gate (a
|
|
32
|
+
// negative threshold can never be exceeded), so reject it up front.
|
|
33
|
+
if (input.confidenceOverride !== undefined &&
|
|
34
|
+
(!Number.isFinite(input.confidenceOverride) ||
|
|
35
|
+
input.confidenceOverride < 0 ||
|
|
36
|
+
input.confidenceOverride > 1)) {
|
|
37
|
+
return err('RECORDING_FAILED', 'confidenceOverride must be a finite number within [0, 1]', false);
|
|
38
|
+
}
|
|
39
|
+
const hasHash = typeof input.hash === 'string' && input.hash.length > 0;
|
|
40
|
+
const hasXdr = typeof input.xdr === 'string' && input.xdr.length > 0;
|
|
41
|
+
if (hasHash && hasXdr) {
|
|
42
|
+
return err('RECORDING_FAILED', 'provide exactly one of `hash` or `xdr`, not both', false);
|
|
43
|
+
}
|
|
44
|
+
if (!hasHash && !hasXdr) {
|
|
45
|
+
return err('RECORDING_FAILED', 'one of `hash` or `xdr` is required', false);
|
|
46
|
+
}
|
|
47
|
+
// === Phase 1: fetch + decode ===
|
|
48
|
+
let decoded;
|
|
49
|
+
if (hasHash) {
|
|
50
|
+
const fetcher = input.fetcher ?? (0, rpc_ts_1.createRpcServer)(input.network);
|
|
51
|
+
const hash = input.hash;
|
|
52
|
+
if (!hash)
|
|
53
|
+
return err('RECORDING_FAILED', 'hash required for on-chain mode', false);
|
|
54
|
+
const fetched = await fetcher(hash);
|
|
55
|
+
if (!fetched) {
|
|
56
|
+
// Item 2: cross-network sanity check. The default NOT_FOUND message
|
|
57
|
+
// ("transaction X not found on <network>") is not actionable when the
|
|
58
|
+
// user just used the wrong --network. Probe the OTHER network's
|
|
59
|
+
// fetcher before giving up: if the hash actually exists there, the
|
|
60
|
+
// user almost certainly passed the wrong network flag. Trade-off:
|
|
61
|
+
// one extra RPC round-trip ONLY on the NOT_FOUND path (the happy
|
|
62
|
+
// path is unchanged; NOT_FOUND is rare). The probe is auto-built
|
|
63
|
+
// from createRpcServer(otherNetwork) when the caller did not inject
|
|
64
|
+
// one; tests pass an explicit stub for offline determinism.
|
|
65
|
+
const otherNetwork = input.network === 'mainnet' ? 'testnet' : 'mainnet';
|
|
66
|
+
const crossFetcher = input.crossNetworkFetcher ?? (0, rpc_ts_1.createRpcServer)(otherNetwork);
|
|
67
|
+
const crossFetched = await crossFetcher(hash);
|
|
68
|
+
if (crossFetched) {
|
|
69
|
+
return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}; it exists on ${otherNetwork}. Re-run with --network ${otherNetwork} (or the corresponding MCP / API field).`, true);
|
|
70
|
+
}
|
|
71
|
+
return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}`, true);
|
|
72
|
+
}
|
|
73
|
+
const events = combineEvents(fetched.events);
|
|
74
|
+
try {
|
|
75
|
+
decoded = (0, decode_ts_1.decodeEnvelope)(fetched.envelopeXdr, events, [], fetched.ledger, undefined, input.network);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (e instanceof decode_ts_1.DecodeError)
|
|
79
|
+
return err('RECORDING_FAILED', e.message, false);
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
return finish(input.network, decoded, input.confidenceOverride);
|
|
83
|
+
}
|
|
84
|
+
// XDR mode has no raw on-chain events, so the events-based cross-check is
|
|
85
|
+
// skipped. parseConfidence reaches 1.0 when every invocation matches a
|
|
86
|
+
// known protocol interface or pinned address; a confidenceOverride is only
|
|
87
|
+
// needed when the caller wants to lower the gate below the default 1.0.
|
|
88
|
+
const xdrStr = input.xdr;
|
|
89
|
+
if (!xdrStr)
|
|
90
|
+
return err('RECORDING_FAILED', 'xdr required for simulation mode', false);
|
|
91
|
+
try {
|
|
92
|
+
decoded = (0, decode_ts_1.decodeEnvelopeXdr)(xdrStr, [], [], 0, undefined, input.network);
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (e instanceof decode_ts_1.DecodeError)
|
|
96
|
+
return err('RECORDING_FAILED', e.message, false);
|
|
97
|
+
return err('RECORDING_FAILED', `failed to decode base64 envelope XDR: ${e.message}`, false);
|
|
98
|
+
}
|
|
99
|
+
return finish(input.network, decoded, input.confidenceOverride);
|
|
100
|
+
}
|
|
101
|
+
function combineEvents(raw) {
|
|
102
|
+
const a = (0, decode_ts_1.transactionEventsToOnChainEvents)(raw.transactionEventsXdr);
|
|
103
|
+
const b = (0, decode_ts_1.contractEventsToOnChainEvents)(raw.contractEventsXdr);
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const merged = [];
|
|
106
|
+
for (const e of [...a, ...b]) {
|
|
107
|
+
const k = `${e.contract}|${e.topics.join(',')}|${JSON.stringify(e.data)}`;
|
|
108
|
+
if (seen.has(k))
|
|
109
|
+
continue;
|
|
110
|
+
seen.add(k);
|
|
111
|
+
merged.push(e);
|
|
112
|
+
}
|
|
113
|
+
return merged;
|
|
114
|
+
}
|
|
115
|
+
/** Apply freshness + validate + refuse gate, then build the RecordedTransaction
|
|
116
|
+
* on success. */
|
|
117
|
+
function finish(network, decoded, confidenceOverride) {
|
|
118
|
+
// === Phase 2: extract token movements ===
|
|
119
|
+
const tokenMovements = (0, movements_ts_1.extractTokenMovements)(decoded.events, decoded.opaqueScVals);
|
|
120
|
+
// === Phase 3: validate against events (fail-closed) ===
|
|
121
|
+
if (decoded.events.length > 0) {
|
|
122
|
+
const failure = (0, validate_ts_1.validateAgainstEvents)(tokenMovements, decoded.events);
|
|
123
|
+
if (failure) {
|
|
124
|
+
const te = {
|
|
125
|
+
code: 'RECORDING_VALIDATION_FAILED',
|
|
126
|
+
message: failure.message,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
retryable: false,
|
|
129
|
+
details: failure.details,
|
|
130
|
+
remediation: {
|
|
131
|
+
userQuestion: {
|
|
132
|
+
code: failure.code,
|
|
133
|
+
question: `Recording refused: ${failure.message}. Inspect details and re-run record_transaction against a re-fetched transaction.`,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
return { ok: false, error: te };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// === Phase 4: parseConfidence + refuse gate ===
|
|
141
|
+
let confidence = (0, freshness_ts_1.computeParseConfidence)({
|
|
142
|
+
knownContracts: decoded.knownContracts,
|
|
143
|
+
unknownContracts: decoded.unknownContracts,
|
|
144
|
+
opaqueScVals: decoded.opaqueScVals,
|
|
145
|
+
});
|
|
146
|
+
// Item 1: surface the "zero contract invocations decoded" case explicitly.
|
|
147
|
+
// The math already pins overall = 1.0 via the `denom === 0` guard; the
|
|
148
|
+
// marker just makes the silence visible so a downstream synth can refuse
|
|
149
|
+
// the recording (a policy must scope to an authorized contract call)
|
|
150
|
+
// without inferring the situation from `invocations: []` next to a
|
|
151
|
+
// confidence of 1.0. Only set when zero invocations were actually decoded
|
|
152
|
+
// - the marker is a positive signal, not a default.
|
|
153
|
+
if (decoded.invocations.length === 0) {
|
|
154
|
+
confidence = { ...confidence, noInvocations: true };
|
|
155
|
+
}
|
|
156
|
+
if (typeof confidenceOverride === 'number') {
|
|
157
|
+
confidence = { ...confidence, thresholdUsed: confidenceOverride };
|
|
158
|
+
}
|
|
159
|
+
if ((0, freshness_ts_1.isBelowThreshold)(confidence)) {
|
|
160
|
+
const te = {
|
|
161
|
+
code: 'RECORDING_VALIDATION_FAILED',
|
|
162
|
+
message: `parseConfidence ${confidence.overall} < threshold ${confidence.thresholdUsed}`,
|
|
163
|
+
severity: 'error',
|
|
164
|
+
retryable: false,
|
|
165
|
+
details: confidence,
|
|
166
|
+
remediation: {
|
|
167
|
+
userQuestion: {
|
|
168
|
+
code: 'PARSE_CONFIDENCE_BELOW_THRESHOLD',
|
|
169
|
+
question: (0, freshness_ts_1.buildLowConfidenceQuestion)(confidence),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return { ok: false, error: te };
|
|
174
|
+
}
|
|
175
|
+
const recorded = {
|
|
176
|
+
network,
|
|
177
|
+
signers: decoded.signers,
|
|
178
|
+
invocations: decoded.invocations,
|
|
179
|
+
tokenMovements,
|
|
180
|
+
events: decoded.events,
|
|
181
|
+
authEntries: decoded.authEntries,
|
|
182
|
+
ledgerSequence: decoded.ledgerSequence,
|
|
183
|
+
fetchedAt: Math.floor(Date.now() / 1000),
|
|
184
|
+
parseConfidence: confidence,
|
|
185
|
+
sourceAccount: decoded.sourceAccount,
|
|
186
|
+
};
|
|
187
|
+
return { ok: true, data: recorded };
|
|
188
|
+
}
|
|
189
|
+
function err(code, message, retryable) {
|
|
190
|
+
return { ok: false, error: { code, message, severity: 'error', retryable } };
|
|
191
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { xdr } from '@stellar/stellar-sdk';
|
|
2
|
+
import type { OnChainEvent, TokenMovement } from '../types.ts';
|
|
3
|
+
/** Extract TokenMovement[] from a list of raw on-chain events. Pure: no IO,
|
|
4
|
+
* no network. Non-movement events are ignored; malformed movement events are
|
|
5
|
+
* recorded as opaque so the recorder can fail closed. */
|
|
6
|
+
export declare function extractTokenMovements(events: OnChainEvent[], opaqueScVals?: Array<{
|
|
7
|
+
path: string;
|
|
8
|
+
type: string;
|
|
9
|
+
}>): TokenMovement[];
|
|
10
|
+
/** Read an I128 amount from an event data ScVal. Supports:
|
|
11
|
+
* - data is I128 directly
|
|
12
|
+
* - data is U64 (fallback for amount fields encoded as u64)
|
|
13
|
+
* - data is a Vec whose [0] (or [1] for SAC transfer) is I128 / U64
|
|
14
|
+
* - data is a Vec whose [0] is a Map { 'amount': I128 | U64 }
|
|
15
|
+
* - data is a Map whose entry with key "amount" is I128 / U64
|
|
16
|
+
*
|
|
17
|
+
* The Map shapes are the canonical data layout for SAC/SEP-41 `transfer`,
|
|
18
|
+
* `mint`, and `burn` events on Stellar mainnet, e.g. the USDC SAC and every
|
|
19
|
+
* Stellar Asset Contract. The key `amount` is the SEP-41-defined field name
|
|
20
|
+
* (verified empirically against txs 112d2392..., eb39f493..., 50d36f5c...
|
|
21
|
+
* where the data is `Map { amount: I128, to_muxed_id: ... }`). The other
|
|
22
|
+
* Map entries we observe in the wild (to_muxed_id, from_muxed_id) are
|
|
23
|
+
* muxed-address adjuncts that the recorder does not support as topic
|
|
24
|
+
* addresses; they are ignored here on purpose. A Map without an `amount`
|
|
25
|
+
* entry, or with an `amount` entry that is not I128/U64, returns null
|
|
26
|
+
* (fail-closed). */
|
|
27
|
+
export declare function readAmount(data: OnChainEvent['data']): string | null;
|
|
28
|
+
/** Re-decode a single ContractEvent body into a TokenMovement-shaped tuple,
|
|
29
|
+
* given the raw xdr.ScVal form (NOT the OnChainEvent subset). Used by the
|
|
30
|
+
* integration test to compare against the parse path without depending on
|
|
31
|
+
* the OnChainEvent subset. */
|
|
32
|
+
export declare function parseContractEventToMovement(emitterC: string, body: xdr.ContractEventV0): TokenMovement | null;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/record/movements.ts - raw on-chain contract events -> TokenMovement[].
|
|
3
|
+
//
|
|
4
|
+
// SEP-41 / SAC `transfer` / `mint` / `burn` events emit topics shaped as:
|
|
5
|
+
// topics[0] = Symbol("transfer") | "mint" | "burn"
|
|
6
|
+
// topics[1] = Address (from) (transfer only - omitted for mint; burn "from" is the caller)
|
|
7
|
+
// topics[2] = Address (to) (transfer + mint; burn uses a single from)
|
|
8
|
+
// data = Vec<ScVal> { Address to, I128 amount } (for transfer on classic SAC)
|
|
9
|
+
// OR I128 amount directly (for some token flavours)
|
|
10
|
+
//
|
|
11
|
+
// We accept both shapes; the validator reuses these as keys to cross-check
|
|
12
|
+
// against the parsed invocation args.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.extractTokenMovements = extractTokenMovements;
|
|
15
|
+
exports.readAmount = readAmount;
|
|
16
|
+
exports.parseContractEventToMovement = parseContractEventToMovement;
|
|
17
|
+
const decode_ts_1 = require("./decode.js");
|
|
18
|
+
const TRANSFER = 'transfer';
|
|
19
|
+
const MINT = 'mint';
|
|
20
|
+
const BURN = 'burn';
|
|
21
|
+
/** Extract TokenMovement[] from a list of raw on-chain events. Pure: no IO,
|
|
22
|
+
* no network. Non-movement events are ignored; malformed movement events are
|
|
23
|
+
* recorded as opaque so the recorder can fail closed. */
|
|
24
|
+
function extractTokenMovements(events, opaqueScVals = []) {
|
|
25
|
+
const out = [];
|
|
26
|
+
events.forEach((evt, index) => {
|
|
27
|
+
try {
|
|
28
|
+
const moved = parseSingleEvent(evt);
|
|
29
|
+
if (moved) {
|
|
30
|
+
out.push(moved);
|
|
31
|
+
}
|
|
32
|
+
else if (isMovementEvent(evt)) {
|
|
33
|
+
opaqueScVals.push({ path: `events[${index}]`, type: movementDecodeFailure(evt) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
opaqueScVals.push({ path: `events[${index}]`, type: 'undecodable-token-movement' });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function isMovementEvent(evt) {
|
|
43
|
+
const op = evt.topics[0];
|
|
44
|
+
return op === TRANSFER || op === MINT || op === BURN;
|
|
45
|
+
}
|
|
46
|
+
function movementDecodeFailure(evt) {
|
|
47
|
+
const address = evt.topics.slice(1).find((topic) => topic.startsWith('M'));
|
|
48
|
+
return address ? 'unsupported-muxed-address-topic' : 'undecodable-token-movement';
|
|
49
|
+
}
|
|
50
|
+
function parseSingleEvent(evt) {
|
|
51
|
+
const [head, second, third] = evt.topics;
|
|
52
|
+
if (!head)
|
|
53
|
+
return null;
|
|
54
|
+
const op = head;
|
|
55
|
+
if (op !== TRANSFER && op !== MINT && op !== BURN)
|
|
56
|
+
return null;
|
|
57
|
+
// Read the `amount` from the event data; the contract address is the emitter.
|
|
58
|
+
const token = evt.contract;
|
|
59
|
+
if (!token)
|
|
60
|
+
return null;
|
|
61
|
+
const amount = readAmount(evt.data);
|
|
62
|
+
if (amount === null)
|
|
63
|
+
return null;
|
|
64
|
+
if (op === TRANSFER) {
|
|
65
|
+
// topics[1] = from, topics[2] = to
|
|
66
|
+
const from = second ? addressTopic(second) : null;
|
|
67
|
+
const to = third ? addressTopic(third) : null;
|
|
68
|
+
if (!from || !to)
|
|
69
|
+
return null;
|
|
70
|
+
return { token, from, to, amount };
|
|
71
|
+
}
|
|
72
|
+
if (op === MINT) {
|
|
73
|
+
// topics[1] = admin (optional), topics[2] = to (sometimes). Some SEP-41 mints
|
|
74
|
+
// only emit one address topic. Best-effort: prefer topics[2], then data.to.
|
|
75
|
+
const to = third ? addressTopic(third) : readDataAddress(evt.data, 0);
|
|
76
|
+
if (!to)
|
|
77
|
+
return null;
|
|
78
|
+
return { token, from: 'mint', to, amount };
|
|
79
|
+
}
|
|
80
|
+
// BURN
|
|
81
|
+
// topics[1] = from (the burned holder). Some burns also surface a "from" in data.
|
|
82
|
+
const from = second ? addressTopic(second) : readDataAddress(evt.data, 0);
|
|
83
|
+
if (!from)
|
|
84
|
+
return null;
|
|
85
|
+
return { token, from, to: 'burn', amount };
|
|
86
|
+
}
|
|
87
|
+
/** Read an I128 amount from an event data ScVal. Supports:
|
|
88
|
+
* - data is I128 directly
|
|
89
|
+
* - data is U64 (fallback for amount fields encoded as u64)
|
|
90
|
+
* - data is a Vec whose [0] (or [1] for SAC transfer) is I128 / U64
|
|
91
|
+
* - data is a Vec whose [0] is a Map { 'amount': I128 | U64 }
|
|
92
|
+
* - data is a Map whose entry with key "amount" is I128 / U64
|
|
93
|
+
*
|
|
94
|
+
* The Map shapes are the canonical data layout for SAC/SEP-41 `transfer`,
|
|
95
|
+
* `mint`, and `burn` events on Stellar mainnet, e.g. the USDC SAC and every
|
|
96
|
+
* Stellar Asset Contract. The key `amount` is the SEP-41-defined field name
|
|
97
|
+
* (verified empirically against txs 112d2392..., eb39f493..., 50d36f5c...
|
|
98
|
+
* where the data is `Map { amount: I128, to_muxed_id: ... }`). The other
|
|
99
|
+
* Map entries we observe in the wild (to_muxed_id, from_muxed_id) are
|
|
100
|
+
* muxed-address adjuncts that the recorder does not support as topic
|
|
101
|
+
* addresses; they are ignored here on purpose. A Map without an `amount`
|
|
102
|
+
* entry, or with an `amount` entry that is not I128/U64, returns null
|
|
103
|
+
* (fail-closed). */
|
|
104
|
+
function readAmount(data) {
|
|
105
|
+
if (data.type === 'i128')
|
|
106
|
+
return data.value;
|
|
107
|
+
if (data.type === 'u64')
|
|
108
|
+
return data.value;
|
|
109
|
+
if (data.type === 'map')
|
|
110
|
+
return readAmountFromMap(data.value);
|
|
111
|
+
if (data.type !== 'vec')
|
|
112
|
+
return null;
|
|
113
|
+
// Vec shape: walk the elements and accept the first I128/U64 OR a Map whose
|
|
114
|
+
// 'amount' entry is I128/U64. SAC transfer event data is sometimes
|
|
115
|
+
// Vec<Address, I128> and sometimes Vec<Map{amount: I128}, ...>; both reach
|
|
116
|
+
// the same code path below.
|
|
117
|
+
for (const item of data.value) {
|
|
118
|
+
if (item.type === 'i128')
|
|
119
|
+
return item.value;
|
|
120
|
+
if (item.type === 'u64')
|
|
121
|
+
return item.value;
|
|
122
|
+
if (item.type === 'map') {
|
|
123
|
+
const fromMap = readAmountFromMap(item.value);
|
|
124
|
+
if (fromMap !== null)
|
|
125
|
+
return fromMap;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** Try to extract an I128/U64 amount from a Map's `amount` entry. Returns
|
|
131
|
+
* null when the Map lacks an `amount` key or the entry is not a numeric
|
|
132
|
+
* ScVal kind this recorder understands. */
|
|
133
|
+
function readAmountFromMap(entries) {
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (entry.key !== 'amount')
|
|
136
|
+
continue;
|
|
137
|
+
if (entry.val.type === 'i128')
|
|
138
|
+
return entry.val.value;
|
|
139
|
+
if (entry.val.type === 'u64')
|
|
140
|
+
return entry.val.value;
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
/** Extract a single address from an event data ScVal at the given vec index. */
|
|
146
|
+
function readDataAddress(data, index) {
|
|
147
|
+
if (data.type !== 'vec')
|
|
148
|
+
return null;
|
|
149
|
+
const item = data.value[index];
|
|
150
|
+
if (item?.type !== 'address')
|
|
151
|
+
return null;
|
|
152
|
+
return item.value;
|
|
153
|
+
}
|
|
154
|
+
/** Convert a topic-string back into an address strkey if it looks like one.
|
|
155
|
+
* Topics we previously encoded as `<kind>` (non-address fallback) are left
|
|
156
|
+
* alone. */
|
|
157
|
+
function addressTopic(s) {
|
|
158
|
+
if (s.startsWith('G') || s.startsWith('C'))
|
|
159
|
+
return s;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/** Re-decode a single ContractEvent body into a TokenMovement-shaped tuple,
|
|
163
|
+
* given the raw xdr.ScVal form (NOT the OnChainEvent subset). Used by the
|
|
164
|
+
* integration test to compare against the parse path without depending on
|
|
165
|
+
* the OnChainEvent subset. */
|
|
166
|
+
function parseContractEventToMovement(emitterC, body) {
|
|
167
|
+
const topics = body.topics();
|
|
168
|
+
if (topics.length === 0)
|
|
169
|
+
return null;
|
|
170
|
+
const head = topics[0];
|
|
171
|
+
if (!head)
|
|
172
|
+
return null;
|
|
173
|
+
const op = head.switch().name === 'scvSymbol' ? head.sym().toString() : '';
|
|
174
|
+
if (op !== TRANSFER && op !== MINT && op !== BURN)
|
|
175
|
+
return null;
|
|
176
|
+
const data = body.data();
|
|
177
|
+
const amountScval = pickAmountScval(data);
|
|
178
|
+
const amount = amountScval
|
|
179
|
+
? amountScval.switch().name === 'scvI128'
|
|
180
|
+
? (0, decode_ts_1.i128PartsToBigInt)(amountScval.i128()).toString()
|
|
181
|
+
: amountScval.switch().name === 'scvU64'
|
|
182
|
+
? (0, decode_ts_1.u64PartsToBigInt)(amountScval.u64()).toString()
|
|
183
|
+
: null
|
|
184
|
+
: null;
|
|
185
|
+
if (amount === null)
|
|
186
|
+
return null;
|
|
187
|
+
if (op === TRANSFER) {
|
|
188
|
+
const from = topics[1] && topics[1].switch().name === 'scvAddress'
|
|
189
|
+
? (0, decode_ts_1.decodeScAddressToAnyStrkey)(topics[1].address())
|
|
190
|
+
: null;
|
|
191
|
+
const to = topics[2] && topics[2].switch().name === 'scvAddress'
|
|
192
|
+
? (0, decode_ts_1.decodeScAddressToAnyStrkey)(topics[2].address())
|
|
193
|
+
: null;
|
|
194
|
+
if (!from || !to)
|
|
195
|
+
return null;
|
|
196
|
+
return { token: emitterC, from, to, amount };
|
|
197
|
+
}
|
|
198
|
+
if (op === MINT) {
|
|
199
|
+
const to = topics[2] && topics[2].switch().name === 'scvAddress'
|
|
200
|
+
? (0, decode_ts_1.decodeScAddressToAnyStrkey)(topics[2].address())
|
|
201
|
+
: null;
|
|
202
|
+
if (!to)
|
|
203
|
+
return null;
|
|
204
|
+
return { token: emitterC, from: 'mint', to, amount };
|
|
205
|
+
}
|
|
206
|
+
// BURN
|
|
207
|
+
const from = topics[1] && topics[1].switch().name === 'scvAddress'
|
|
208
|
+
? (0, decode_ts_1.decodeScAddressToAnyStrkey)(topics[1].address())
|
|
209
|
+
: null;
|
|
210
|
+
if (!from)
|
|
211
|
+
return null;
|
|
212
|
+
return { token: emitterC, from, to: 'burn', amount };
|
|
213
|
+
}
|
|
214
|
+
function pickAmountScval(data) {
|
|
215
|
+
const kind = data.switch().name;
|
|
216
|
+
if (kind === 'scvI128' || kind === 'scvU64')
|
|
217
|
+
return data;
|
|
218
|
+
if (kind !== 'scvVec')
|
|
219
|
+
return null;
|
|
220
|
+
const arr = data.vec() ?? [];
|
|
221
|
+
for (const item of arr) {
|
|
222
|
+
const k = item.switch().name;
|
|
223
|
+
if (k === 'scvI128' || k === 'scvU64')
|
|
224
|
+
return item;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { rpc, type xdr } from '@stellar/stellar-sdk';
|
|
2
|
+
import type { Network } from '../types.ts';
|
|
3
|
+
/** A trimmed-down view of `rpc.Api.GetSuccessfulTransactionResponse` plus the
|
|
4
|
+
* failed-shape variant. Both are produced by Soroban RPC `getTransaction` on
|
|
5
|
+
* a successful or failed tx; missing/pending returns `null`. */
|
|
6
|
+
export interface SorobanTxResponse {
|
|
7
|
+
status: 'SUCCESS' | 'FAILED';
|
|
8
|
+
ledger: number;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
txHash: string;
|
|
11
|
+
envelopeXdr: xdr.TransactionEnvelope;
|
|
12
|
+
resultXdr?: xdr.TransactionResult;
|
|
13
|
+
resultMetaXdr?: xdr.TransactionMeta;
|
|
14
|
+
events: rpc.Api.TransactionEvents;
|
|
15
|
+
}
|
|
16
|
+
export type RpcFetcher = (hash: string) => Promise<SorobanTxResponse | null>;
|
|
17
|
+
/** Build a fetcher backed by the public Soroban RPC for the given network.
|
|
18
|
+
* Injectable by tests via the `fetcher` parameter to `recordTransaction`. */
|
|
19
|
+
export declare function createRpcServer(network: Network): RpcFetcher;
|
|
20
|
+
/** Lightweight reachability probe used by the integration test to decide whether
|
|
21
|
+
* to run or self-skip. Sends a JSON-RPC `getHealth` to the public endpoint. */
|
|
22
|
+
export declare function probeNetwork(network: Network, timeoutMs?: number): Promise<boolean>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/record/rpc.ts - isolates the network side-effect from the rest of the recorder.
|
|
3
|
+
//
|
|
4
|
+
// Everything else in the recorder (decode, movements, freshness, validate) is pure.
|
|
5
|
+
// Unit tests inject a fetcher so they never hit the network; the integration test
|
|
6
|
+
// uses the real Soroban RPC through `createRpcServer`.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.createRpcServer = createRpcServer;
|
|
9
|
+
exports.probeNetwork = probeNetwork;
|
|
10
|
+
const stellar_sdk_1 = require("@stellar/stellar-sdk");
|
|
11
|
+
const PUBLIC_RPC_URLS = {
|
|
12
|
+
testnet: 'https://soroban-testnet.stellar.org',
|
|
13
|
+
// The brief pins testnet; mainnet is left to the caller via injection. We keep
|
|
14
|
+
// a public default that matches the brief's note ("e.g. https://mainnet.sorobanrpc.com").
|
|
15
|
+
mainnet: 'https://mainnet.sorobanrpc.com',
|
|
16
|
+
};
|
|
17
|
+
/** Build a fetcher backed by the public Soroban RPC for the given network.
|
|
18
|
+
* Injectable by tests via the `fetcher` parameter to `recordTransaction`. */
|
|
19
|
+
function createRpcServer(network) {
|
|
20
|
+
const server = new stellar_sdk_1.rpc.Server(PUBLIC_RPC_URLS[network], { allowHttp: false });
|
|
21
|
+
return async (hash) => {
|
|
22
|
+
const resp = await server.getTransaction(hash);
|
|
23
|
+
if (resp.status === stellar_sdk_1.rpc.Api.GetTransactionStatus.NOT_FOUND) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (resp.status === stellar_sdk_1.rpc.Api.GetTransactionStatus.SUCCESS) {
|
|
27
|
+
return {
|
|
28
|
+
status: 'SUCCESS',
|
|
29
|
+
ledger: resp.ledger,
|
|
30
|
+
createdAt: resp.createdAt,
|
|
31
|
+
txHash: resp.txHash,
|
|
32
|
+
envelopeXdr: resp.envelopeXdr,
|
|
33
|
+
resultXdr: resp.resultXdr,
|
|
34
|
+
resultMetaXdr: resp.resultMetaXdr,
|
|
35
|
+
events: resp.events,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
status: 'FAILED',
|
|
40
|
+
ledger: resp.ledger,
|
|
41
|
+
createdAt: resp.createdAt,
|
|
42
|
+
txHash: resp.txHash,
|
|
43
|
+
envelopeXdr: resp.envelopeXdr,
|
|
44
|
+
resultXdr: resp.resultXdr,
|
|
45
|
+
resultMetaXdr: resp.resultMetaXdr,
|
|
46
|
+
events: resp.events,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Lightweight reachability probe used by the integration test to decide whether
|
|
51
|
+
* to run or self-skip. Sends a JSON-RPC `getHealth` to the public endpoint. */
|
|
52
|
+
async function probeNetwork(network, timeoutMs = 3000) {
|
|
53
|
+
const url = PUBLIC_RPC_URLS[network];
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
56
|
+
try {
|
|
57
|
+
const resp = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }),
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
});
|
|
63
|
+
if (!resp.ok)
|
|
64
|
+
return false;
|
|
65
|
+
const json = (await resp.json());
|
|
66
|
+
return json.result?.status === 'healthy';
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OnChainEvent, TokenMovement } from '../types.ts';
|
|
2
|
+
export interface ValidationFailure {
|
|
3
|
+
code: 'MOVEMENT_PARSE_NOT_IN_EVENTS' | 'MOVEMENT_EVENT_NOT_PARSED';
|
|
4
|
+
message: string;
|
|
5
|
+
details: unknown;
|
|
6
|
+
}
|
|
7
|
+
/** Cross-check parsed movements against the raw on-chain events.
|
|
8
|
+
* Returns null when validation passes; otherwise the first failure found.
|
|
9
|
+
*
|
|
10
|
+
* Mode:
|
|
11
|
+
* - on-chain mode (events.length > 0): full movement cross-check, fail-closed.
|
|
12
|
+
* - simulation/XDR mode (events.length === 0): SKIPPED - the caller surfaces
|
|
13
|
+
* the reduced certainty via parseConfidence.
|
|
14
|
+
*
|
|
15
|
+
* We intentionally do NOT require every invocation to have an attributable
|
|
16
|
+
* event. A Soroban call's events are commonly emitted by inner contracts
|
|
17
|
+
* (token SACs) that are not in the auth-derived invocation tree, so a
|
|
18
|
+
* per-invocation "did it emit an event" check produces false refusals on
|
|
19
|
+
* legitimate transactions. The real guards are the movement cross-check below
|
|
20
|
+
* plus parseConfidence (unknown contracts / opaque ScVals).
|
|
21
|
+
*/
|
|
22
|
+
export declare function validateAgainstEvents(parsedMovements: TokenMovement[], events: OnChainEvent[]): ValidationFailure | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/record/validate.ts - cross-check parsed TokenMovements against the raw
|
|
3
|
+
// on-chain events. NOT by re-parsing.
|
|
4
|
+
//
|
|
5
|
+
// Why events-based validation matters: the parse path is the thing we are
|
|
6
|
+
// trying to harden. Validating parse output by re-running the parser can only
|
|
7
|
+
// catch a parser bug that manifests the same way twice. Cross-checking against
|
|
8
|
+
// the canonical event stream is what catches real drift.
|
|
9
|
+
//
|
|
10
|
+
// Skips:
|
|
11
|
+
// - simulation/XDR mode: there are no raw events to compare against. The
|
|
12
|
+
// orchestrator reflects the reduced certainty in parseConfidence (any
|
|
13
|
+
// unknown contract or opaque ScVal reduces overall below 1) and the gate
|
|
14
|
+
// refuses the recording.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.validateAgainstEvents = validateAgainstEvents;
|
|
17
|
+
const decode_ts_1 = require("./decode.js");
|
|
18
|
+
const movements_ts_1 = require("./movements.js");
|
|
19
|
+
/** Cross-check parsed movements against the raw on-chain events.
|
|
20
|
+
* Returns null when validation passes; otherwise the first failure found.
|
|
21
|
+
*
|
|
22
|
+
* Mode:
|
|
23
|
+
* - on-chain mode (events.length > 0): full movement cross-check, fail-closed.
|
|
24
|
+
* - simulation/XDR mode (events.length === 0): SKIPPED - the caller surfaces
|
|
25
|
+
* the reduced certainty via parseConfidence.
|
|
26
|
+
*
|
|
27
|
+
* We intentionally do NOT require every invocation to have an attributable
|
|
28
|
+
* event. A Soroban call's events are commonly emitted by inner contracts
|
|
29
|
+
* (token SACs) that are not in the auth-derived invocation tree, so a
|
|
30
|
+
* per-invocation "did it emit an event" check produces false refusals on
|
|
31
|
+
* legitimate transactions. The real guards are the movement cross-check below
|
|
32
|
+
* plus parseConfidence (unknown contracts / opaque ScVals).
|
|
33
|
+
*/
|
|
34
|
+
function validateAgainstEvents(parsedMovements, events) {
|
|
35
|
+
// Simulation/XDR mode - skip cross-check, return null (no failure).
|
|
36
|
+
if (events.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
// Compute the set of movements the raw events IMPLY.
|
|
39
|
+
const eventMovements = (0, movements_ts_1.extractTokenMovements)(events);
|
|
40
|
+
const eventKeySet = new Set(eventMovements.map(decode_ts_1.tokenMovementKey));
|
|
41
|
+
const parsedKeySet = new Set(parsedMovements.map(decode_ts_1.tokenMovementKey));
|
|
42
|
+
// 1. Every parsed movement must appear in the raw events.
|
|
43
|
+
for (const m of parsedMovements) {
|
|
44
|
+
if (!eventKeySet.has((0, decode_ts_1.tokenMovementKey)(m))) {
|
|
45
|
+
return {
|
|
46
|
+
code: 'MOVEMENT_PARSE_NOT_IN_EVENTS',
|
|
47
|
+
message: `parsed TokenMovement not present in raw events`,
|
|
48
|
+
details: { movement: m },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// 2. Every raw-event movement must appear in the parsed set.
|
|
53
|
+
for (const m of eventMovements) {
|
|
54
|
+
if (!parsedKeySet.has((0, decode_ts_1.tokenMovementKey)(m))) {
|
|
55
|
+
return {
|
|
56
|
+
code: 'MOVEMENT_EVENT_NOT_PARSED',
|
|
57
|
+
message: `raw event implies a movement that the parse missed`,
|
|
58
|
+
details: { movement: m },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ScVal } from '../types.ts';
|
|
2
|
+
import type { ProtocolId } from './protocols.ts';
|
|
3
|
+
export interface IdentifiedProtocol {
|
|
4
|
+
protocol: ProtocolId;
|
|
5
|
+
fn: string;
|
|
6
|
+
}
|
|
7
|
+
/** Identify the protocol for a single (contract, method, args) invocation.
|
|
8
|
+
* Returns null on ANY mismatch (method not in ABI, arg count off, arg type
|
|
9
|
+
* off, or unknown method on a pinned address). The caller MUST keep the
|
|
10
|
+
* null path fail-closed. */
|
|
11
|
+
export declare function identifyProtocol(contract: string, method: string, args: ScVal[], network?: 'mainnet' | 'testnet'): IdentifiedProtocol | null;
|