@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,224 @@
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
+
20
+ import type { xdr } from '@stellar/stellar-sdk'
21
+ import type { ToolError, ToolResponse } from '../errors.ts'
22
+ import type { Network, ParseConfidence, RecordedTransaction } from '../types.ts'
23
+ import type { DecodedTransaction } from './decode.ts'
24
+ import {
25
+ contractEventsToOnChainEvents,
26
+ DecodeError,
27
+ decodeEnvelope,
28
+ decodeEnvelopeXdr,
29
+ transactionEventsToOnChainEvents,
30
+ } from './decode.ts'
31
+ import {
32
+ buildLowConfidenceQuestion,
33
+ computeParseConfidence,
34
+ isBelowThreshold,
35
+ } from './freshness.ts'
36
+ import { extractTokenMovements } from './movements.ts'
37
+ import { createRpcServer, type RpcFetcher } from './rpc.ts'
38
+ import { validateAgainstEvents } from './validate.ts'
39
+
40
+ /** Public input shape. The brief pins:
41
+ * - exactly one of `hash` / `xdr`
42
+ * - `network` for hash mode (selects the public RPC)
43
+ * - optional injected `fetcher` for tests + custom RPC endpoints
44
+ * - optional `confidenceOverride` to relax the gate (default 1.0)
45
+ *
46
+ * In xdr mode `network` is still required so the caller documents intent
47
+ * (the synthesis downstream consumes it).
48
+ */
49
+ export interface RecordInput {
50
+ hash?: string
51
+ xdr?: string
52
+ network: Network
53
+ fetcher?: RpcFetcher
54
+ confidenceOverride?: number
55
+ }
56
+
57
+ export type RecordResult = ToolResponse<RecordedTransaction>
58
+
59
+ export async function recordTransaction(input: RecordInput): Promise<RecordResult> {
60
+ if (!input.network) {
61
+ return err('RECORDING_FAILED', 'network required', false)
62
+ }
63
+ // A confidenceOverride outside [0, 1] would disable the fail-closed gate (a
64
+ // negative threshold can never be exceeded), so reject it up front.
65
+ if (
66
+ input.confidenceOverride !== undefined &&
67
+ (!Number.isFinite(input.confidenceOverride) ||
68
+ input.confidenceOverride < 0 ||
69
+ input.confidenceOverride > 1)
70
+ ) {
71
+ return err(
72
+ 'RECORDING_FAILED',
73
+ 'confidenceOverride must be a finite number within [0, 1]',
74
+ false
75
+ )
76
+ }
77
+ const hasHash = typeof input.hash === 'string' && input.hash.length > 0
78
+ const hasXdr = typeof input.xdr === 'string' && input.xdr.length > 0
79
+ if (hasHash && hasXdr) {
80
+ return err('RECORDING_FAILED', 'provide exactly one of `hash` or `xdr`, not both', false)
81
+ }
82
+ if (!hasHash && !hasXdr) {
83
+ return err('RECORDING_FAILED', 'one of `hash` or `xdr` is required', false)
84
+ }
85
+
86
+ // === Phase 1: fetch + decode ===
87
+ let decoded: DecodedTransaction
88
+
89
+ if (hasHash) {
90
+ const fetcher = input.fetcher ?? createRpcServer(input.network)
91
+ const hash = input.hash
92
+ if (!hash) return err('RECORDING_FAILED', 'hash required for on-chain mode', false)
93
+ const fetched = await fetcher(hash)
94
+ if (!fetched) {
95
+ return err('RECORDING_FAILED', `transaction ${hash} not found on ${input.network}`, true)
96
+ }
97
+ const events = combineEvents(fetched.events)
98
+ try {
99
+ decoded = decodeEnvelope(
100
+ fetched.envelopeXdr,
101
+ events,
102
+ [],
103
+ fetched.ledger,
104
+ undefined,
105
+ input.network
106
+ )
107
+ } catch (e) {
108
+ if (e instanceof DecodeError) return err('RECORDING_FAILED', e.message, false)
109
+ throw e
110
+ }
111
+ return finish(input.network, decoded, input.confidenceOverride)
112
+ }
113
+
114
+ // XDR mode has no raw on-chain events, so the events-based cross-check is
115
+ // skipped. parseConfidence reaches 1.0 when every invocation matches a
116
+ // known protocol interface or pinned address; a confidenceOverride is only
117
+ // needed when the caller wants to lower the gate below the default 1.0.
118
+ const xdrStr = input.xdr
119
+ if (!xdrStr) return err('RECORDING_FAILED', 'xdr required for simulation mode', false)
120
+ try {
121
+ decoded = decodeEnvelopeXdr(xdrStr, [], [], 0, undefined, input.network)
122
+ } catch (e) {
123
+ if (e instanceof DecodeError) return err('RECORDING_FAILED', e.message, false)
124
+ return err(
125
+ 'RECORDING_FAILED',
126
+ `failed to decode base64 envelope XDR: ${(e as Error).message}`,
127
+ false
128
+ )
129
+ }
130
+ return finish(input.network, decoded, input.confidenceOverride)
131
+ }
132
+
133
+ function combineEvents(raw: {
134
+ transactionEventsXdr: xdr.TransactionEvent[]
135
+ contractEventsXdr: xdr.ContractEvent[][]
136
+ }): ReturnType<typeof contractEventsToOnChainEvents> {
137
+ const a = transactionEventsToOnChainEvents(raw.transactionEventsXdr)
138
+ const b = contractEventsToOnChainEvents(raw.contractEventsXdr)
139
+ const seen = new Set<string>()
140
+ const merged: typeof a = []
141
+ for (const e of [...a, ...b]) {
142
+ const k = `${e.contract}|${e.topics.join(',')}|${JSON.stringify(e.data)}`
143
+ if (seen.has(k)) continue
144
+ seen.add(k)
145
+ merged.push(e)
146
+ }
147
+ return merged
148
+ }
149
+
150
+ /** Apply freshness + validate + refuse gate, then build the RecordedTransaction
151
+ * on success. */
152
+ function finish(
153
+ network: Network,
154
+ decoded: DecodedTransaction,
155
+ confidenceOverride: number | undefined
156
+ ): RecordResult {
157
+ // === Phase 2: extract token movements ===
158
+ const tokenMovements = extractTokenMovements(decoded.events, decoded.opaqueScVals)
159
+
160
+ // === Phase 3: validate against events (fail-closed) ===
161
+ if (decoded.events.length > 0) {
162
+ const failure = validateAgainstEvents(tokenMovements, decoded.events)
163
+ if (failure) {
164
+ const te: ToolError = {
165
+ code: 'RECORDING_VALIDATION_FAILED',
166
+ message: failure.message,
167
+ severity: 'error',
168
+ retryable: false,
169
+ details: failure.details,
170
+ remediation: {
171
+ userQuestion: {
172
+ code: failure.code,
173
+ question: `Recording refused: ${failure.message}. Inspect details and re-run record_transaction against a re-fetched transaction.`,
174
+ },
175
+ },
176
+ }
177
+ return { ok: false, error: te }
178
+ }
179
+ }
180
+
181
+ // === Phase 4: parseConfidence + refuse gate ===
182
+ let confidence: ParseConfidence = computeParseConfidence({
183
+ knownContracts: decoded.knownContracts,
184
+ unknownContracts: decoded.unknownContracts,
185
+ opaqueScVals: decoded.opaqueScVals,
186
+ })
187
+ if (typeof confidenceOverride === 'number') {
188
+ confidence = { ...confidence, thresholdUsed: confidenceOverride }
189
+ }
190
+ if (isBelowThreshold(confidence)) {
191
+ const te: ToolError = {
192
+ code: 'RECORDING_VALIDATION_FAILED',
193
+ message: `parseConfidence ${confidence.overall} < threshold ${confidence.thresholdUsed}`,
194
+ severity: 'error',
195
+ retryable: false,
196
+ details: confidence,
197
+ remediation: {
198
+ userQuestion: {
199
+ code: 'PARSE_CONFIDENCE_BELOW_THRESHOLD',
200
+ question: buildLowConfidenceQuestion(confidence),
201
+ },
202
+ },
203
+ }
204
+ return { ok: false, error: te }
205
+ }
206
+
207
+ const recorded: RecordedTransaction = {
208
+ network,
209
+ signers: decoded.signers,
210
+ invocations: decoded.invocations,
211
+ tokenMovements,
212
+ events: decoded.events,
213
+ authEntries: decoded.authEntries,
214
+ ledgerSequence: decoded.ledgerSequence,
215
+ fetchedAt: Math.floor(Date.now() / 1000),
216
+ parseConfidence: confidence,
217
+ sourceAccount: decoded.sourceAccount,
218
+ }
219
+ return { ok: true, data: recorded }
220
+ }
221
+
222
+ function err(code: ToolError['code'], message: string, retryable: boolean): RecordResult {
223
+ return { ok: false, error: { code, message, severity: 'error', retryable } }
224
+ }
@@ -0,0 +1,188 @@
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
+
13
+ import type { xdr } from '@stellar/stellar-sdk'
14
+ import type { OnChainEvent, TokenMovement } from '../types.ts'
15
+ import { decodeScAddressToAnyStrkey, i128PartsToBigInt, u64PartsToBigInt } from './decode.ts'
16
+
17
+ const TRANSFER = 'transfer'
18
+ const MINT = 'mint'
19
+ const BURN = 'burn'
20
+
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
+ export function extractTokenMovements(
25
+ events: OnChainEvent[],
26
+ opaqueScVals: Array<{ path: string; type: string }> = []
27
+ ): TokenMovement[] {
28
+ const out: TokenMovement[] = []
29
+ events.forEach((evt, index) => {
30
+ try {
31
+ const moved = parseSingleEvent(evt)
32
+ if (moved) {
33
+ out.push(moved)
34
+ } else if (isMovementEvent(evt)) {
35
+ opaqueScVals.push({ path: `events[${index}]`, type: movementDecodeFailure(evt) })
36
+ }
37
+ } catch {
38
+ opaqueScVals.push({ path: `events[${index}]`, type: 'undecodable-token-movement' })
39
+ }
40
+ })
41
+ return out
42
+ }
43
+
44
+ function isMovementEvent(evt: OnChainEvent): boolean {
45
+ const op = evt.topics[0]
46
+ return op === TRANSFER || op === MINT || op === BURN
47
+ }
48
+
49
+ function movementDecodeFailure(evt: OnChainEvent): string {
50
+ const address = evt.topics.slice(1).find((topic) => topic.startsWith('M'))
51
+ return address ? 'unsupported-muxed-address-topic' : 'undecodable-token-movement'
52
+ }
53
+
54
+ function parseSingleEvent(evt: OnChainEvent): TokenMovement | null {
55
+ const [head, second, third] = evt.topics
56
+ if (!head) return null
57
+ const op = head
58
+ if (op !== TRANSFER && op !== MINT && op !== BURN) return null
59
+
60
+ // Read the `amount` from the event data; the contract address is the emitter.
61
+ const token = evt.contract
62
+ if (!token) return null
63
+
64
+ const amount = readAmount(evt.data)
65
+ if (amount === null) return null
66
+
67
+ if (op === TRANSFER) {
68
+ // topics[1] = from, topics[2] = to
69
+ const from = second ? addressTopic(second) : null
70
+ const to = third ? addressTopic(third) : null
71
+ if (!from || !to) return null
72
+ return { token, from, to, amount }
73
+ }
74
+ if (op === MINT) {
75
+ // topics[1] = admin (optional), topics[2] = to (sometimes). Some SEP-41 mints
76
+ // only emit one address topic. Best-effort: prefer topics[2], then data.to.
77
+ const to = third ? addressTopic(third) : readDataAddress(evt.data, 0)
78
+ if (!to) return null
79
+ return { token, from: 'mint', to, amount }
80
+ }
81
+ // BURN
82
+ // topics[1] = from (the burned holder). Some burns also surface a "from" in data.
83
+ const from = second ? addressTopic(second) : readDataAddress(evt.data, 0)
84
+ if (!from) return null
85
+ return { token, from, to: 'burn', amount }
86
+ }
87
+
88
+ /** Read an I128 amount from an event data ScVal. Supports:
89
+ * - data is I128 directly
90
+ * - data is a Vec whose [0] (or [1] for SAC transfer) is I128
91
+ * - data is a Vec whose [0] is a Map { 'amount': I128 }
92
+ * - data is U64 (fallback for amount fields encoded as u64) */
93
+ export function readAmount(data: OnChainEvent['data']): string | null {
94
+ if (data.type === 'i128') return data.value
95
+ if (data.type === 'u64') return data.value
96
+ if (data.type !== 'vec') return null
97
+ const v = data.value
98
+ // SAC transfer event data shape (some clients): Vec<Address, I128>
99
+ for (const item of v) {
100
+ if (item.type === 'i128') return item.value
101
+ if (item.type === 'u64') return item.value
102
+ }
103
+ // Some flavours wrap in a Map. We expose Map entries as other; bail.
104
+ return null
105
+ }
106
+
107
+ /** Extract a single address from an event data ScVal at the given vec index. */
108
+ function readDataAddress(data: OnChainEvent['data'], index: number): string | null {
109
+ if (data.type !== 'vec') return null
110
+ const item = data.value[index]
111
+ if (item?.type !== 'address') return null
112
+ return item.value
113
+ }
114
+
115
+ /** Convert a topic-string back into an address strkey if it looks like one.
116
+ * Topics we previously encoded as `<kind>` (non-address fallback) are left
117
+ * alone. */
118
+ function addressTopic(s: string): string | null {
119
+ if (s.startsWith('G') || s.startsWith('C')) return s
120
+ return null
121
+ }
122
+
123
+ /** Re-decode a single ContractEvent body into a TokenMovement-shaped tuple,
124
+ * given the raw xdr.ScVal form (NOT the OnChainEvent subset). Used by the
125
+ * integration test to compare against the parse path without depending on
126
+ * the OnChainEvent subset. */
127
+ export function parseContractEventToMovement(
128
+ emitterC: string,
129
+ body: xdr.ContractEventV0
130
+ ): TokenMovement | null {
131
+ const topics = body.topics()
132
+ if (topics.length === 0) return null
133
+ const head = topics[0]
134
+ if (!head) return null
135
+ const op = head.switch().name === 'scvSymbol' ? head.sym().toString() : ''
136
+ if (op !== TRANSFER && op !== MINT && op !== BURN) return null
137
+
138
+ const data = body.data()
139
+ const amountScval = pickAmountScval(data)
140
+ const amount = amountScval
141
+ ? amountScval.switch().name === 'scvI128'
142
+ ? i128PartsToBigInt(amountScval.i128()).toString()
143
+ : amountScval.switch().name === 'scvU64'
144
+ ? u64PartsToBigInt(amountScval.u64()).toString()
145
+ : null
146
+ : null
147
+ if (amount === null) return null
148
+
149
+ if (op === TRANSFER) {
150
+ const from =
151
+ topics[1] && topics[1].switch().name === 'scvAddress'
152
+ ? decodeScAddressToAnyStrkey(topics[1].address())
153
+ : null
154
+ const to =
155
+ topics[2] && topics[2].switch().name === 'scvAddress'
156
+ ? decodeScAddressToAnyStrkey(topics[2].address())
157
+ : null
158
+ if (!from || !to) return null
159
+ return { token: emitterC, from, to, amount }
160
+ }
161
+ if (op === MINT) {
162
+ const to =
163
+ topics[2] && topics[2].switch().name === 'scvAddress'
164
+ ? decodeScAddressToAnyStrkey(topics[2].address())
165
+ : null
166
+ if (!to) return null
167
+ return { token: emitterC, from: 'mint', to, amount }
168
+ }
169
+ // BURN
170
+ const from =
171
+ topics[1] && topics[1].switch().name === 'scvAddress'
172
+ ? decodeScAddressToAnyStrkey(topics[1].address())
173
+ : null
174
+ if (!from) return null
175
+ return { token: emitterC, from, to: 'burn', amount }
176
+ }
177
+
178
+ function pickAmountScval(data: xdr.ScVal): xdr.ScVal | null {
179
+ const kind = data.switch().name
180
+ if (kind === 'scvI128' || kind === 'scvU64') return data
181
+ if (kind !== 'scvVec') return null
182
+ const arr = data.vec() ?? []
183
+ for (const item of arr) {
184
+ const k = item.switch().name
185
+ if (k === 'scvI128' || k === 'scvU64') return item
186
+ }
187
+ return null
188
+ }
@@ -0,0 +1,88 @@
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
+
7
+ import { rpc, type xdr } from '@stellar/stellar-sdk'
8
+ import type { Network } from '../types.ts'
9
+
10
+ /** A trimmed-down view of `rpc.Api.GetSuccessfulTransactionResponse` plus the
11
+ * failed-shape variant. Both are produced by Soroban RPC `getTransaction` on
12
+ * a successful or failed tx; missing/pending returns `null`. */
13
+ export interface SorobanTxResponse {
14
+ status: 'SUCCESS' | 'FAILED'
15
+ ledger: number
16
+ createdAt: number
17
+ txHash: string
18
+ envelopeXdr: xdr.TransactionEnvelope
19
+ resultXdr?: xdr.TransactionResult
20
+ resultMetaXdr?: xdr.TransactionMeta
21
+ events: rpc.Api.TransactionEvents
22
+ }
23
+
24
+ export type RpcFetcher = (hash: string) => Promise<SorobanTxResponse | null>
25
+
26
+ const PUBLIC_RPC_URLS: Record<Network, string> = {
27
+ testnet: 'https://soroban-testnet.stellar.org',
28
+ // The brief pins testnet; mainnet is left to the caller via injection. We keep
29
+ // a public default that matches the brief's note ("e.g. https://mainnet.sorobanrpc.com").
30
+ mainnet: 'https://mainnet.sorobanrpc.com',
31
+ }
32
+
33
+ /** Build a fetcher backed by the public Soroban RPC for the given network.
34
+ * Injectable by tests via the `fetcher` parameter to `recordTransaction`. */
35
+ export function createRpcServer(network: Network): RpcFetcher {
36
+ const server = new rpc.Server(PUBLIC_RPC_URLS[network], { allowHttp: false })
37
+ return async (hash: string): Promise<SorobanTxResponse | null> => {
38
+ const resp = await server.getTransaction(hash)
39
+ if (resp.status === rpc.Api.GetTransactionStatus.NOT_FOUND) {
40
+ return null
41
+ }
42
+ if (resp.status === rpc.Api.GetTransactionStatus.SUCCESS) {
43
+ return {
44
+ status: 'SUCCESS',
45
+ ledger: resp.ledger,
46
+ createdAt: resp.createdAt,
47
+ txHash: resp.txHash,
48
+ envelopeXdr: resp.envelopeXdr,
49
+ resultXdr: resp.resultXdr,
50
+ resultMetaXdr: resp.resultMetaXdr,
51
+ events: resp.events,
52
+ }
53
+ }
54
+ return {
55
+ status: 'FAILED',
56
+ ledger: resp.ledger,
57
+ createdAt: resp.createdAt,
58
+ txHash: resp.txHash,
59
+ envelopeXdr: resp.envelopeXdr,
60
+ resultXdr: resp.resultXdr,
61
+ resultMetaXdr: resp.resultMetaXdr,
62
+ events: resp.events,
63
+ }
64
+ }
65
+ }
66
+
67
+ /** Lightweight reachability probe used by the integration test to decide whether
68
+ * to run or self-skip. Sends a JSON-RPC `getHealth` to the public endpoint. */
69
+ export async function probeNetwork(network: Network, timeoutMs = 3000): Promise<boolean> {
70
+ const url = PUBLIC_RPC_URLS[network]
71
+ const controller = new AbortController()
72
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
73
+ try {
74
+ const resp = await fetch(url, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth' }),
78
+ signal: controller.signal,
79
+ })
80
+ if (!resp.ok) return false
81
+ const json = (await resp.json()) as { result?: { status?: string } }
82
+ return json.result?.status === 'healthy'
83
+ } catch {
84
+ return false
85
+ } finally {
86
+ clearTimeout(timer)
87
+ }
88
+ }
@@ -0,0 +1,75 @@
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
+
15
+ import type { OnChainEvent, TokenMovement } from '../types.ts'
16
+ import { tokenMovementKey } from './decode.ts'
17
+ import { extractTokenMovements } from './movements.ts'
18
+
19
+ export interface ValidationFailure {
20
+ code: 'MOVEMENT_PARSE_NOT_IN_EVENTS' | 'MOVEMENT_EVENT_NOT_PARSED'
21
+ message: string
22
+ details: unknown
23
+ }
24
+
25
+ /** Cross-check parsed movements against the raw on-chain events.
26
+ * Returns null when validation passes; otherwise the first failure found.
27
+ *
28
+ * Mode:
29
+ * - on-chain mode (events.length > 0): full movement cross-check, fail-closed.
30
+ * - simulation/XDR mode (events.length === 0): SKIPPED - the caller surfaces
31
+ * the reduced certainty via parseConfidence.
32
+ *
33
+ * We intentionally do NOT require every invocation to have an attributable
34
+ * event. A Soroban call's events are commonly emitted by inner contracts
35
+ * (token SACs) that are not in the auth-derived invocation tree, so a
36
+ * per-invocation "did it emit an event" check produces false refusals on
37
+ * legitimate transactions. The real guards are the movement cross-check below
38
+ * plus parseConfidence (unknown contracts / opaque ScVals).
39
+ */
40
+ export function validateAgainstEvents(
41
+ parsedMovements: TokenMovement[],
42
+ events: OnChainEvent[]
43
+ ): ValidationFailure | null {
44
+ // Simulation/XDR mode - skip cross-check, return null (no failure).
45
+ if (events.length === 0) return null
46
+
47
+ // Compute the set of movements the raw events IMPLY.
48
+ const eventMovements = extractTokenMovements(events)
49
+ const eventKeySet = new Set(eventMovements.map(tokenMovementKey))
50
+ const parsedKeySet = new Set(parsedMovements.map(tokenMovementKey))
51
+
52
+ // 1. Every parsed movement must appear in the raw events.
53
+ for (const m of parsedMovements) {
54
+ if (!eventKeySet.has(tokenMovementKey(m))) {
55
+ return {
56
+ code: 'MOVEMENT_PARSE_NOT_IN_EVENTS',
57
+ message: `parsed TokenMovement not present in raw events`,
58
+ details: { movement: m },
59
+ }
60
+ }
61
+ }
62
+
63
+ // 2. Every raw-event movement must appear in the parsed set.
64
+ for (const m of eventMovements) {
65
+ if (!parsedKeySet.has(tokenMovementKey(m))) {
66
+ return {
67
+ code: 'MOVEMENT_EVENT_NOT_PARSED',
68
+ message: `raw event implies a movement that the parse missed`,
69
+ details: { movement: m },
70
+ }
71
+ }
72
+ }
73
+
74
+ return null
75
+ }
@@ -0,0 +1,99 @@
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
+
25
+ import type { ScVal } from '../types.ts'
26
+ import { addressToProtocol } from './known-addresses.ts'
27
+ import type { AbiArg, ProtocolId } from './protocols.ts'
28
+ import { BLEND_ABI, SEP41_ABI, SOROSWAP_ABI } from './protocols.ts'
29
+
30
+ export interface IdentifiedProtocol {
31
+ protocol: ProtocolId
32
+ fn: string
33
+ }
34
+
35
+ /** Identify the protocol for a single (contract, method, args) invocation.
36
+ * Returns null on ANY mismatch (method not in ABI, arg count off, arg type
37
+ * off, or unknown method on a pinned address). The caller MUST keep the
38
+ * null path fail-closed. */
39
+ export function identifyProtocol(
40
+ contract: string,
41
+ method: string,
42
+ args: ScVal[],
43
+ network?: 'mainnet' | 'testnet'
44
+ ): IdentifiedProtocol | null {
45
+ // 1) SEP-41 by interface. Any contract whose (method, args) matches a
46
+ // SEP-41 ABI signature is a SEP-41 token.
47
+ if (Object.hasOwn(SEP41_ABI, method)) {
48
+ const sig = SEP41_ABI[method]
49
+ if (sig && argsMatchAbi(sig.args, args)) {
50
+ return { protocol: 'sep41', fn: method }
51
+ }
52
+ return null
53
+ }
54
+
55
+ // 2) Blend by interface. Real pool calls hit per-pool instances; pinned
56
+ // factory calls also match here when the method is in the pool ABI.
57
+ if (Object.hasOwn(BLEND_ABI, method)) {
58
+ const sig = BLEND_ABI[method]
59
+ if (sig && argsMatchAbi(sig.args, args)) {
60
+ return { protocol: 'blend', fn: method }
61
+ }
62
+ return null
63
+ }
64
+
65
+ // 3) Pinned-address recognition (Blend factory + SoroSwap router/factory).
66
+ // FIX 4: the method must also be in the protocol's ABI; an unknown
67
+ // method on a pinned address -> null.
68
+ if (network) {
69
+ const pinned = addressToProtocol(contract, network)
70
+ if (pinned) {
71
+ const abi = pinned === 'blend' ? BLEND_ABI : SOROSWAP_ABI
72
+ if (Object.hasOwn(abi, method)) {
73
+ const sig = abi[method]
74
+ if (sig && argsMatchAbi(sig.args, args)) {
75
+ return { protocol: pinned, fn: method }
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ void contract
82
+ return null
83
+ }
84
+
85
+ /** Compare the decoded args against the ABI signature. Returns true only when
86
+ * the arg count matches AND every arg's ScVal subset type matches the ABI's
87
+ * declared type. `other` is intentionally NOT a valid ABI match - it means
88
+ * the decoder couldn't classify the value, which is exactly the signal
89
+ * fail-closed should refuse. */
90
+ function argsMatchAbi(expected: AbiArg[], actual: ScVal[]): boolean {
91
+ if (expected.length !== actual.length) return false
92
+ for (let i = 0; i < expected.length; i += 1) {
93
+ const want = expected[i]
94
+ const got = actual[i]
95
+ if (!want || !got) return false
96
+ if (want.type !== got.type) return false
97
+ }
98
+ return true
99
+ }