@crediolabs/policy-synth 0.1.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.
Files changed (85) hide show
  1. package/dist/adapters/oz/adapter.d.ts +20 -0
  2. package/dist/adapters/oz/adapter.js +282 -0
  3. package/dist/adapters/oz/index.d.ts +1 -0
  4. package/dist/adapters/oz/index.js +2 -0
  5. package/dist/errors.d.ts +37 -0
  6. package/dist/errors.js +2 -0
  7. package/dist/index.d.ts +9 -0
  8. package/dist/index.js +9 -0
  9. package/dist/ir/index.d.ts +1 -0
  10. package/dist/ir/index.js +2 -0
  11. package/dist/ir/types.d.ts +97 -0
  12. package/dist/ir/types.js +11 -0
  13. package/dist/mandate/index.d.ts +2 -0
  14. package/dist/mandate/index.js +2 -0
  15. package/dist/mandate/to-ir.d.ts +3 -0
  16. package/dist/mandate/to-ir.js +60 -0
  17. package/dist/mandate/types.d.ts +20 -0
  18. package/dist/mandate/types.js +8 -0
  19. package/dist/record/decode.d.ts +76 -0
  20. package/dist/record/decode.js +372 -0
  21. package/dist/record/freshness.d.ts +17 -0
  22. package/dist/record/freshness.js +50 -0
  23. package/dist/record/index.d.ts +21 -0
  24. package/dist/record/index.js +163 -0
  25. package/dist/record/movements.d.ts +20 -0
  26. package/dist/record/movements.js +187 -0
  27. package/dist/record/rpc.d.ts +22 -0
  28. package/dist/record/rpc.js +70 -0
  29. package/dist/record/validate.d.ts +22 -0
  30. package/dist/record/validate.js +60 -0
  31. package/dist/registry/identify.d.ts +11 -0
  32. package/dist/registry/identify.js +84 -0
  33. package/dist/registry/index.d.ts +3 -0
  34. package/dist/registry/index.js +4 -0
  35. package/dist/registry/known-addresses.d.ts +16 -0
  36. package/dist/registry/known-addresses.js +49 -0
  37. package/dist/registry/protocols.d.ts +38 -0
  38. package/dist/registry/protocols.js +149 -0
  39. package/dist/seams/index.d.ts +1 -0
  40. package/dist/seams/index.js +2 -0
  41. package/dist/seams/types.d.ts +66 -0
  42. package/dist/seams/types.js +11 -0
  43. package/dist/synth/compose-from-recording.d.ts +36 -0
  44. package/dist/synth/compose-from-recording.js +162 -0
  45. package/dist/synth/index.d.ts +5 -0
  46. package/dist/synth/index.js +6 -0
  47. package/dist/synth/lower.d.ts +23 -0
  48. package/dist/synth/lower.js +116 -0
  49. package/dist/synth/scope.d.ts +26 -0
  50. package/dist/synth/scope.js +77 -0
  51. package/dist/synth/synthesize-from-mandate.d.ts +5 -0
  52. package/dist/synth/synthesize-from-mandate.js +34 -0
  53. package/dist/synth/synthesize-from-recording.d.ts +17 -0
  54. package/dist/synth/synthesize-from-recording.js +178 -0
  55. package/dist/types.d.ts +249 -0
  56. package/dist/types.js +35 -0
  57. package/package.json +37 -0
  58. package/src/adapters/oz/adapter.ts +363 -0
  59. package/src/adapters/oz/index.ts +9 -0
  60. package/src/errors.ts +79 -0
  61. package/src/index.ts +9 -0
  62. package/src/ir/index.ts +13 -0
  63. package/src/ir/types.ts +94 -0
  64. package/src/mandate/index.ts +4 -0
  65. package/src/mandate/to-ir.ts +71 -0
  66. package/src/mandate/types.ts +21 -0
  67. package/src/record/decode.ts +500 -0
  68. package/src/record/freshness.ts +63 -0
  69. package/src/record/index.ts +224 -0
  70. package/src/record/movements.ts +188 -0
  71. package/src/record/rpc.ts +88 -0
  72. package/src/record/validate.ts +75 -0
  73. package/src/registry/identify.ts +99 -0
  74. package/src/registry/index.ts +24 -0
  75. package/src/registry/known-addresses.ts +71 -0
  76. package/src/registry/protocols.ts +176 -0
  77. package/src/seams/index.ts +11 -0
  78. package/src/seams/types.ts +81 -0
  79. package/src/synth/compose-from-recording.ts +226 -0
  80. package/src/synth/index.ts +19 -0
  81. package/src/synth/lower.ts +144 -0
  82. package/src/synth/scope.ts +108 -0
  83. package/src/synth/synthesize-from-mandate.ts +46 -0
  84. package/src/synth/synthesize-from-recording.ts +226 -0
  85. package/src/types.ts +218 -0
@@ -0,0 +1,163 @@
1
+ // src/record/index.ts - the recorder orchestrator.
2
+ //
3
+ // `recordTransaction(input)` is the single public entry point for the
4
+ // synthesizer (the next phase) to consume. Two modes:
5
+ // - on-chain (hash set): fetch via the injected RPC fetcher (default:
6
+ // public Soroban RPC for the requested network).
7
+ // - simulation/XDR (xdr set): decode the provided envelope XDR directly.
8
+ //
9
+ // In both modes the recorder:
10
+ // 1. Decodes the envelope to sourceAccount, signers, invocations (+ diagnostic
11
+ // subInvocations), args -> ScVal subset.
12
+ // 2. Extracts token movements from raw events (or skips in XDR mode).
13
+ // 3. Validates the parsed movements/invocations against the raw events.
14
+ // 4. Computes parseConfidence.
15
+ // 5. Refuses with ToolError RECORDING_VALIDATION_FAILED when overall < threshold.
16
+ //
17
+ // Returns ToolResponse<RecordedTransaction> per the canonical envelope
18
+ // defined in src/errors.ts.
19
+ import { contractEventsToOnChainEvents, DecodeError, decodeEnvelope, decodeEnvelopeXdr, transactionEventsToOnChainEvents, } from "./decode.js";
20
+ import { buildLowConfidenceQuestion, computeParseConfidence, isBelowThreshold, } from "./freshness.js";
21
+ import { extractTokenMovements } from "./movements.js";
22
+ import { createRpcServer } from "./rpc.js";
23
+ import { validateAgainstEvents } from "./validate.js";
24
+ export async function recordTransaction(input) {
25
+ if (!input.network) {
26
+ return err('RECORDING_FAILED', 'network required', false);
27
+ }
28
+ // A confidenceOverride outside [0, 1] would disable the fail-closed gate (a
29
+ // negative threshold can never be exceeded), so reject it up front.
30
+ if (input.confidenceOverride !== undefined &&
31
+ (!Number.isFinite(input.confidenceOverride) ||
32
+ input.confidenceOverride < 0 ||
33
+ input.confidenceOverride > 1)) {
34
+ return err('RECORDING_FAILED', 'confidenceOverride must be a finite number within [0, 1]', false);
35
+ }
36
+ const hasHash = typeof input.hash === 'string' && input.hash.length > 0;
37
+ const hasXdr = typeof input.xdr === 'string' && input.xdr.length > 0;
38
+ if (hasHash && hasXdr) {
39
+ return err('RECORDING_FAILED', 'provide exactly one of `hash` or `xdr`, not both', false);
40
+ }
41
+ if (!hasHash && !hasXdr) {
42
+ return err('RECORDING_FAILED', 'one of `hash` or `xdr` is required', false);
43
+ }
44
+ // === Phase 1: fetch + decode ===
45
+ let decoded;
46
+ if (hasHash) {
47
+ const fetcher = input.fetcher ?? createRpcServer(input.network);
48
+ const hash = input.hash;
49
+ if (!hash)
50
+ return err('RECORDING_FAILED', 'hash required for on-chain mode', false);
51
+ const fetched = await fetcher(hash);
52
+ if (!fetched) {
53
+ return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}`, true);
54
+ }
55
+ const events = combineEvents(fetched.events);
56
+ try {
57
+ decoded = decodeEnvelope(fetched.envelopeXdr, events, [], fetched.ledger, undefined, input.network);
58
+ }
59
+ catch (e) {
60
+ if (e instanceof DecodeError)
61
+ return err('RECORDING_FAILED', e.message, false);
62
+ throw e;
63
+ }
64
+ return finish(input.network, decoded, input.confidenceOverride);
65
+ }
66
+ // XDR mode has no raw on-chain events, so the events-based cross-check is
67
+ // skipped. parseConfidence reaches 1.0 when every invocation matches a
68
+ // known protocol interface or pinned address; a confidenceOverride is only
69
+ // needed when the caller wants to lower the gate below the default 1.0.
70
+ const xdrStr = input.xdr;
71
+ if (!xdrStr)
72
+ return err('RECORDING_FAILED', 'xdr required for simulation mode', false);
73
+ try {
74
+ decoded = decodeEnvelopeXdr(xdrStr, [], [], 0, undefined, input.network);
75
+ }
76
+ catch (e) {
77
+ if (e instanceof DecodeError)
78
+ return err('RECORDING_FAILED', e.message, false);
79
+ return err('RECORDING_FAILED', `failed to decode base64 envelope XDR: ${e.message}`, false);
80
+ }
81
+ return finish(input.network, decoded, input.confidenceOverride);
82
+ }
83
+ function combineEvents(raw) {
84
+ const a = transactionEventsToOnChainEvents(raw.transactionEventsXdr);
85
+ const b = contractEventsToOnChainEvents(raw.contractEventsXdr);
86
+ const seen = new Set();
87
+ const merged = [];
88
+ for (const e of [...a, ...b]) {
89
+ const k = `${e.contract}|${e.topics.join(',')}|${JSON.stringify(e.data)}`;
90
+ if (seen.has(k))
91
+ continue;
92
+ seen.add(k);
93
+ merged.push(e);
94
+ }
95
+ return merged;
96
+ }
97
+ /** Apply freshness + validate + refuse gate, then build the RecordedTransaction
98
+ * on success. */
99
+ function finish(network, decoded, confidenceOverride) {
100
+ // === Phase 2: extract token movements ===
101
+ const tokenMovements = extractTokenMovements(decoded.events, decoded.opaqueScVals);
102
+ // === Phase 3: validate against events (fail-closed) ===
103
+ if (decoded.events.length > 0) {
104
+ const failure = validateAgainstEvents(tokenMovements, decoded.events);
105
+ if (failure) {
106
+ const te = {
107
+ code: 'RECORDING_VALIDATION_FAILED',
108
+ message: failure.message,
109
+ severity: 'error',
110
+ retryable: false,
111
+ details: failure.details,
112
+ remediation: {
113
+ userQuestion: {
114
+ code: failure.code,
115
+ question: `Recording refused: ${failure.message}. Inspect details and re-run record_transaction against a re-fetched transaction.`,
116
+ },
117
+ },
118
+ };
119
+ return { ok: false, error: te };
120
+ }
121
+ }
122
+ // === Phase 4: parseConfidence + refuse gate ===
123
+ let confidence = computeParseConfidence({
124
+ knownContracts: decoded.knownContracts,
125
+ unknownContracts: decoded.unknownContracts,
126
+ opaqueScVals: decoded.opaqueScVals,
127
+ });
128
+ if (typeof confidenceOverride === 'number') {
129
+ confidence = { ...confidence, thresholdUsed: confidenceOverride };
130
+ }
131
+ if (isBelowThreshold(confidence)) {
132
+ const te = {
133
+ code: 'RECORDING_VALIDATION_FAILED',
134
+ message: `parseConfidence ${confidence.overall} < threshold ${confidence.thresholdUsed}`,
135
+ severity: 'error',
136
+ retryable: false,
137
+ details: confidence,
138
+ remediation: {
139
+ userQuestion: {
140
+ code: 'PARSE_CONFIDENCE_BELOW_THRESHOLD',
141
+ question: buildLowConfidenceQuestion(confidence),
142
+ },
143
+ },
144
+ };
145
+ return { ok: false, error: te };
146
+ }
147
+ const recorded = {
148
+ network,
149
+ signers: decoded.signers,
150
+ invocations: decoded.invocations,
151
+ tokenMovements,
152
+ events: decoded.events,
153
+ authEntries: decoded.authEntries,
154
+ ledgerSequence: decoded.ledgerSequence,
155
+ fetchedAt: Math.floor(Date.now() / 1000),
156
+ parseConfidence: confidence,
157
+ sourceAccount: decoded.sourceAccount,
158
+ };
159
+ return { ok: true, data: recorded };
160
+ }
161
+ function err(code, message, retryable) {
162
+ return { ok: false, error: { code, message, severity: 'error', retryable } };
163
+ }
@@ -0,0 +1,20 @@
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 a Vec whose [0] (or [1] for SAC transfer) is I128
13
+ * - data is a Vec whose [0] is a Map { 'amount': I128 }
14
+ * - data is U64 (fallback for amount fields encoded as u64) */
15
+ export declare function readAmount(data: OnChainEvent['data']): string | null;
16
+ /** Re-decode a single ContractEvent body into a TokenMovement-shaped tuple,
17
+ * given the raw xdr.ScVal form (NOT the OnChainEvent subset). Used by the
18
+ * integration test to compare against the parse path without depending on
19
+ * the OnChainEvent subset. */
20
+ export declare function parseContractEventToMovement(emitterC: string, body: xdr.ContractEventV0): TokenMovement | null;
@@ -0,0 +1,187 @@
1
+ // src/record/movements.ts - raw on-chain contract events -> TokenMovement[].
2
+ //
3
+ // SEP-41 / SAC `transfer` / `mint` / `burn` events emit topics shaped as:
4
+ // topics[0] = Symbol("transfer") | "mint" | "burn"
5
+ // topics[1] = Address (from) (transfer only - omitted for mint; burn "from" is the caller)
6
+ // topics[2] = Address (to) (transfer + mint; burn uses a single from)
7
+ // data = Vec<ScVal> { Address to, I128 amount } (for transfer on classic SAC)
8
+ // OR I128 amount directly (for some token flavours)
9
+ //
10
+ // We accept both shapes; the validator reuses these as keys to cross-check
11
+ // against the parsed invocation args.
12
+ import { decodeScAddressToAnyStrkey, i128PartsToBigInt, u64PartsToBigInt } from "./decode.js";
13
+ const TRANSFER = 'transfer';
14
+ const MINT = 'mint';
15
+ const BURN = 'burn';
16
+ /** Extract TokenMovement[] from a list of raw on-chain events. Pure: no IO,
17
+ * no network. Non-movement events are ignored; malformed movement events are
18
+ * recorded as opaque so the recorder can fail closed. */
19
+ export function extractTokenMovements(events, opaqueScVals = []) {
20
+ const out = [];
21
+ events.forEach((evt, index) => {
22
+ try {
23
+ const moved = parseSingleEvent(evt);
24
+ if (moved) {
25
+ out.push(moved);
26
+ }
27
+ else if (isMovementEvent(evt)) {
28
+ opaqueScVals.push({ path: `events[${index}]`, type: movementDecodeFailure(evt) });
29
+ }
30
+ }
31
+ catch {
32
+ opaqueScVals.push({ path: `events[${index}]`, type: 'undecodable-token-movement' });
33
+ }
34
+ });
35
+ return out;
36
+ }
37
+ function isMovementEvent(evt) {
38
+ const op = evt.topics[0];
39
+ return op === TRANSFER || op === MINT || op === BURN;
40
+ }
41
+ function movementDecodeFailure(evt) {
42
+ const address = evt.topics.slice(1).find((topic) => topic.startsWith('M'));
43
+ return address ? 'unsupported-muxed-address-topic' : 'undecodable-token-movement';
44
+ }
45
+ function parseSingleEvent(evt) {
46
+ const [head, second, third] = evt.topics;
47
+ if (!head)
48
+ return null;
49
+ const op = head;
50
+ if (op !== TRANSFER && op !== MINT && op !== BURN)
51
+ return null;
52
+ // Read the `amount` from the event data; the contract address is the emitter.
53
+ const token = evt.contract;
54
+ if (!token)
55
+ return null;
56
+ const amount = readAmount(evt.data);
57
+ if (amount === null)
58
+ return null;
59
+ if (op === TRANSFER) {
60
+ // topics[1] = from, topics[2] = to
61
+ const from = second ? addressTopic(second) : null;
62
+ const to = third ? addressTopic(third) : null;
63
+ if (!from || !to)
64
+ return null;
65
+ return { token, from, to, amount };
66
+ }
67
+ if (op === MINT) {
68
+ // topics[1] = admin (optional), topics[2] = to (sometimes). Some SEP-41 mints
69
+ // only emit one address topic. Best-effort: prefer topics[2], then data.to.
70
+ const to = third ? addressTopic(third) : readDataAddress(evt.data, 0);
71
+ if (!to)
72
+ return null;
73
+ return { token, from: 'mint', to, amount };
74
+ }
75
+ // BURN
76
+ // topics[1] = from (the burned holder). Some burns also surface a "from" in data.
77
+ const from = second ? addressTopic(second) : readDataAddress(evt.data, 0);
78
+ if (!from)
79
+ return null;
80
+ return { token, from, to: 'burn', amount };
81
+ }
82
+ /** Read an I128 amount from an event data ScVal. Supports:
83
+ * - data is I128 directly
84
+ * - data is a Vec whose [0] (or [1] for SAC transfer) is I128
85
+ * - data is a Vec whose [0] is a Map { 'amount': I128 }
86
+ * - data is U64 (fallback for amount fields encoded as u64) */
87
+ export function readAmount(data) {
88
+ if (data.type === 'i128')
89
+ return data.value;
90
+ if (data.type === 'u64')
91
+ return data.value;
92
+ if (data.type !== 'vec')
93
+ return null;
94
+ const v = data.value;
95
+ // SAC transfer event data shape (some clients): Vec<Address, I128>
96
+ for (const item of v) {
97
+ if (item.type === 'i128')
98
+ return item.value;
99
+ if (item.type === 'u64')
100
+ return item.value;
101
+ }
102
+ // Some flavours wrap in a Map. We expose Map entries as other; bail.
103
+ return null;
104
+ }
105
+ /** Extract a single address from an event data ScVal at the given vec index. */
106
+ function readDataAddress(data, index) {
107
+ if (data.type !== 'vec')
108
+ return null;
109
+ const item = data.value[index];
110
+ if (item?.type !== 'address')
111
+ return null;
112
+ return item.value;
113
+ }
114
+ /** Convert a topic-string back into an address strkey if it looks like one.
115
+ * Topics we previously encoded as `<kind>` (non-address fallback) are left
116
+ * alone. */
117
+ function addressTopic(s) {
118
+ if (s.startsWith('G') || s.startsWith('C'))
119
+ return s;
120
+ return null;
121
+ }
122
+ /** Re-decode a single ContractEvent body into a TokenMovement-shaped tuple,
123
+ * given the raw xdr.ScVal form (NOT the OnChainEvent subset). Used by the
124
+ * integration test to compare against the parse path without depending on
125
+ * the OnChainEvent subset. */
126
+ export function parseContractEventToMovement(emitterC, body) {
127
+ const topics = body.topics();
128
+ if (topics.length === 0)
129
+ return null;
130
+ const head = topics[0];
131
+ if (!head)
132
+ return null;
133
+ const op = head.switch().name === 'scvSymbol' ? head.sym().toString() : '';
134
+ if (op !== TRANSFER && op !== MINT && op !== BURN)
135
+ return null;
136
+ const data = body.data();
137
+ const amountScval = pickAmountScval(data);
138
+ const amount = amountScval
139
+ ? amountScval.switch().name === 'scvI128'
140
+ ? i128PartsToBigInt(amountScval.i128()).toString()
141
+ : amountScval.switch().name === 'scvU64'
142
+ ? u64PartsToBigInt(amountScval.u64()).toString()
143
+ : null
144
+ : null;
145
+ if (amount === null)
146
+ return null;
147
+ if (op === TRANSFER) {
148
+ const from = topics[1] && topics[1].switch().name === 'scvAddress'
149
+ ? decodeScAddressToAnyStrkey(topics[1].address())
150
+ : null;
151
+ const to = topics[2] && topics[2].switch().name === 'scvAddress'
152
+ ? decodeScAddressToAnyStrkey(topics[2].address())
153
+ : null;
154
+ if (!from || !to)
155
+ return null;
156
+ return { token: emitterC, from, to, amount };
157
+ }
158
+ if (op === MINT) {
159
+ const to = topics[2] && topics[2].switch().name === 'scvAddress'
160
+ ? decodeScAddressToAnyStrkey(topics[2].address())
161
+ : null;
162
+ if (!to)
163
+ return null;
164
+ return { token: emitterC, from: 'mint', to, amount };
165
+ }
166
+ // BURN
167
+ const from = topics[1] && topics[1].switch().name === 'scvAddress'
168
+ ? decodeScAddressToAnyStrkey(topics[1].address())
169
+ : null;
170
+ if (!from)
171
+ return null;
172
+ return { token: emitterC, from, to: 'burn', amount };
173
+ }
174
+ function pickAmountScval(data) {
175
+ const kind = data.switch().name;
176
+ if (kind === 'scvI128' || kind === 'scvU64')
177
+ return data;
178
+ if (kind !== 'scvVec')
179
+ return null;
180
+ const arr = data.vec() ?? [];
181
+ for (const item of arr) {
182
+ const k = item.switch().name;
183
+ if (k === 'scvI128' || k === 'scvU64')
184
+ return item;
185
+ }
186
+ return null;
187
+ }
@@ -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,70 @@
1
+ // src/record/rpc.ts - isolates the network side-effect from the rest of the recorder.
2
+ //
3
+ // Everything else in the recorder (decode, movements, freshness, validate) is pure.
4
+ // Unit tests inject a fetcher so they never hit the network; the integration test
5
+ // uses the real Soroban RPC through `createRpcServer`.
6
+ import { rpc } from '@stellar/stellar-sdk';
7
+ const PUBLIC_RPC_URLS = {
8
+ testnet: 'https://soroban-testnet.stellar.org',
9
+ // The brief pins testnet; mainnet is left to the caller via injection. We keep
10
+ // a public default that matches the brief's note ("e.g. https://mainnet.sorobanrpc.com").
11
+ mainnet: 'https://mainnet.sorobanrpc.com',
12
+ };
13
+ /** Build a fetcher backed by the public Soroban RPC for the given network.
14
+ * Injectable by tests via the `fetcher` parameter to `recordTransaction`. */
15
+ export function createRpcServer(network) {
16
+ const server = new rpc.Server(PUBLIC_RPC_URLS[network], { allowHttp: false });
17
+ return async (hash) => {
18
+ const resp = await server.getTransaction(hash);
19
+ if (resp.status === rpc.Api.GetTransactionStatus.NOT_FOUND) {
20
+ return null;
21
+ }
22
+ if (resp.status === rpc.Api.GetTransactionStatus.SUCCESS) {
23
+ return {
24
+ status: 'SUCCESS',
25
+ ledger: resp.ledger,
26
+ createdAt: resp.createdAt,
27
+ txHash: resp.txHash,
28
+ envelopeXdr: resp.envelopeXdr,
29
+ resultXdr: resp.resultXdr,
30
+ resultMetaXdr: resp.resultMetaXdr,
31
+ events: resp.events,
32
+ };
33
+ }
34
+ return {
35
+ status: 'FAILED',
36
+ ledger: resp.ledger,
37
+ createdAt: resp.createdAt,
38
+ txHash: resp.txHash,
39
+ envelopeXdr: resp.envelopeXdr,
40
+ resultXdr: resp.resultXdr,
41
+ resultMetaXdr: resp.resultMetaXdr,
42
+ events: resp.events,
43
+ };
44
+ };
45
+ }
46
+ /** Lightweight reachability probe used by the integration test to decide whether
47
+ * to run or self-skip. Sends a JSON-RPC `getHealth` to the public endpoint. */
48
+ export async function probeNetwork(network, timeoutMs = 3000) {
49
+ const url = PUBLIC_RPC_URLS[network];
50
+ const controller = new AbortController();
51
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
52
+ try {
53
+ const resp = await fetch(url, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }),
57
+ signal: controller.signal,
58
+ });
59
+ if (!resp.ok)
60
+ return false;
61
+ const json = (await resp.json());
62
+ return json.result?.status === 'healthy';
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
@@ -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,60 @@
1
+ // src/record/validate.ts - cross-check parsed TokenMovements against the raw
2
+ // on-chain events. NOT by re-parsing.
3
+ //
4
+ // Why events-based validation matters: the parse path is the thing we are
5
+ // trying to harden. Validating parse output by re-running the parser can only
6
+ // catch a parser bug that manifests the same way twice. Cross-checking against
7
+ // the canonical event stream is what catches real drift.
8
+ //
9
+ // Skips:
10
+ // - simulation/XDR mode: there are no raw events to compare against. The
11
+ // orchestrator reflects the reduced certainty in parseConfidence (any
12
+ // unknown contract or opaque ScVal reduces overall below 1) and the gate
13
+ // refuses the recording.
14
+ import { tokenMovementKey } from "./decode.js";
15
+ import { extractTokenMovements } from "./movements.js";
16
+ /** Cross-check parsed movements against the raw on-chain events.
17
+ * Returns null when validation passes; otherwise the first failure found.
18
+ *
19
+ * Mode:
20
+ * - on-chain mode (events.length > 0): full movement cross-check, fail-closed.
21
+ * - simulation/XDR mode (events.length === 0): SKIPPED - the caller surfaces
22
+ * the reduced certainty via parseConfidence.
23
+ *
24
+ * We intentionally do NOT require every invocation to have an attributable
25
+ * event. A Soroban call's events are commonly emitted by inner contracts
26
+ * (token SACs) that are not in the auth-derived invocation tree, so a
27
+ * per-invocation "did it emit an event" check produces false refusals on
28
+ * legitimate transactions. The real guards are the movement cross-check below
29
+ * plus parseConfidence (unknown contracts / opaque ScVals).
30
+ */
31
+ export function validateAgainstEvents(parsedMovements, events) {
32
+ // Simulation/XDR mode - skip cross-check, return null (no failure).
33
+ if (events.length === 0)
34
+ return null;
35
+ // Compute the set of movements the raw events IMPLY.
36
+ const eventMovements = extractTokenMovements(events);
37
+ const eventKeySet = new Set(eventMovements.map(tokenMovementKey));
38
+ const parsedKeySet = new Set(parsedMovements.map(tokenMovementKey));
39
+ // 1. Every parsed movement must appear in the raw events.
40
+ for (const m of parsedMovements) {
41
+ if (!eventKeySet.has(tokenMovementKey(m))) {
42
+ return {
43
+ code: 'MOVEMENT_PARSE_NOT_IN_EVENTS',
44
+ message: `parsed TokenMovement not present in raw events`,
45
+ details: { movement: m },
46
+ };
47
+ }
48
+ }
49
+ // 2. Every raw-event movement must appear in the parsed set.
50
+ for (const m of eventMovements) {
51
+ if (!parsedKeySet.has(tokenMovementKey(m))) {
52
+ return {
53
+ code: 'MOVEMENT_EVENT_NOT_PARSED',
54
+ message: `raw event implies a movement that the parse missed`,
55
+ details: { movement: m },
56
+ };
57
+ }
58
+ }
59
+ return null;
60
+ }
@@ -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;
@@ -0,0 +1,84 @@
1
+ // src/registry/identify.ts - protocol identification (fail-closed).
2
+ //
3
+ // Given a (contract, method, args) triple, decide whether the call belongs
4
+ // to a known protocol. Returns null when it does NOT - the caller MUST
5
+ // preserve the fail-closed posture for null results.
6
+ //
7
+ // Recognition rules:
8
+ // 1. SEP-41: recognised by INTERFACE - any contract calling one of the
9
+ // SEP-41 fns (transfer / mint / burn / approve) with a matching arg
10
+ // shape is a SEP-41 token. No address pin is needed.
11
+ // 2. Blend: recognised by INTERFACE - real pool `submit` / `claim` calls
12
+ // hit per-pool instances, not the factory. The arg shape must match
13
+ // the Blend pool ABI (verified against the pool contract source).
14
+ // Address recognition still applies when the call is against the
15
+ // pinned factory address, with the same arg-shape check.
16
+ // 3. SoroSwap: recognised by ADDRESS only - the router is a single pinned
17
+ // contract. FIX 4: the method must ALSO be present in the protocol's
18
+ // ABI; an unknown method on a pinned router -> null (fail-closed).
19
+ //
20
+ // Method lookup uses `Object.hasOwn` rather than `in` so prototype-chain
21
+ // names like `constructor`, `toString`, `hasOwnProperty` do NOT register as
22
+ // valid method calls. Arg-shape validation compares arg count + each arg's
23
+ // ScVal subset type against the ABI signature; mismatch -> null.
24
+ import { addressToProtocol } from "./known-addresses.js";
25
+ import { BLEND_ABI, SEP41_ABI, SOROSWAP_ABI } from "./protocols.js";
26
+ /** Identify the protocol for a single (contract, method, args) invocation.
27
+ * Returns null on ANY mismatch (method not in ABI, arg count off, arg type
28
+ * off, or unknown method on a pinned address). The caller MUST keep the
29
+ * null path fail-closed. */
30
+ export function identifyProtocol(contract, method, args, network) {
31
+ // 1) SEP-41 by interface. Any contract whose (method, args) matches a
32
+ // SEP-41 ABI signature is a SEP-41 token.
33
+ if (Object.hasOwn(SEP41_ABI, method)) {
34
+ const sig = SEP41_ABI[method];
35
+ if (sig && argsMatchAbi(sig.args, args)) {
36
+ return { protocol: 'sep41', fn: method };
37
+ }
38
+ return null;
39
+ }
40
+ // 2) Blend by interface. Real pool calls hit per-pool instances; pinned
41
+ // factory calls also match here when the method is in the pool ABI.
42
+ if (Object.hasOwn(BLEND_ABI, method)) {
43
+ const sig = BLEND_ABI[method];
44
+ if (sig && argsMatchAbi(sig.args, args)) {
45
+ return { protocol: 'blend', fn: method };
46
+ }
47
+ return null;
48
+ }
49
+ // 3) Pinned-address recognition (Blend factory + SoroSwap router/factory).
50
+ // FIX 4: the method must also be in the protocol's ABI; an unknown
51
+ // method on a pinned address -> null.
52
+ if (network) {
53
+ const pinned = addressToProtocol(contract, network);
54
+ if (pinned) {
55
+ const abi = pinned === 'blend' ? BLEND_ABI : SOROSWAP_ABI;
56
+ if (Object.hasOwn(abi, method)) {
57
+ const sig = abi[method];
58
+ if (sig && argsMatchAbi(sig.args, args)) {
59
+ return { protocol: pinned, fn: method };
60
+ }
61
+ }
62
+ }
63
+ }
64
+ void contract;
65
+ return null;
66
+ }
67
+ /** Compare the decoded args against the ABI signature. Returns true only when
68
+ * the arg count matches AND every arg's ScVal subset type matches the ABI's
69
+ * declared type. `other` is intentionally NOT a valid ABI match - it means
70
+ * the decoder couldn't classify the value, which is exactly the signal
71
+ * fail-closed should refuse. */
72
+ function argsMatchAbi(expected, actual) {
73
+ if (expected.length !== actual.length)
74
+ return false;
75
+ for (let i = 0; i < expected.length; i += 1) {
76
+ const want = expected[i];
77
+ const got = actual[i];
78
+ if (!want || !got)
79
+ return false;
80
+ if (want.type !== got.type)
81
+ return false;
82
+ }
83
+ return true;
84
+ }
@@ -0,0 +1,3 @@
1
+ export { type IdentifiedProtocol, identifyProtocol, } from './identify.ts';
2
+ export { addressToProtocol, KNOWN_ADDRESSES, type KnownAddressesByNetwork, type KnownAddressesByProtocol, } from './known-addresses.ts';
3
+ export { type AbiArg, type AbiEntry, BLEND_ABI, getAbi, PROTOCOL_ABIS, type ProtocolAbi, type ProtocolId, SEP41_ABI, SOROSWAP_ABI, } from './protocols.ts';
@@ -0,0 +1,4 @@
1
+ // src/registry/index.ts - re-export the protocol adjacency registry.
2
+ export { identifyProtocol, } from "./identify.js";
3
+ export { addressToProtocol, KNOWN_ADDRESSES, } from "./known-addresses.js";
4
+ export { BLEND_ABI, getAbi, PROTOCOL_ABIS, SEP41_ABI, SOROSWAP_ABI, } from "./protocols.js";