Skip to content

Commit 4f95a5d

Browse files
committed
feat: on-chain vs persisted vault reconciliation job
Add reconciliation job to detect drift between persisted vault state and on-chain state from Soroban contract. - Add get_vault function to soroban.ts for reading on-chain vault state - Add reconcileVaults() to transactionETL.ts for batched vault comparison - Add vault.reconcile job type and handler - Integrate drift anomaly reporting via abuse-monitor taxonomy - Document reconciliation process in docs/etl.md - Add comprehensive test coverage for reconciliation functionality The reconciliation job is read-only, batched, idempotent, and resumable. It reports drift without auto-correcting to allow manual intervention.
1 parent 2e0e09b commit 4f95a5d

7 files changed

Lines changed: 909 additions & 0 deletions

File tree

docs/etl.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,111 @@ npx jest --testPathPattern="etlWorker|transactionETL" --run
119119
# With coverage
120120
npx jest --testPathPattern="etlWorker|transactionETL" --coverage --run
121121
```
122+
123+
---
124+
125+
## Vault State Reconciliation
126+
127+
### Overview
128+
129+
The vault reconciliation job compares persisted vault state in the database with on-chain state from the Soroban contract to detect drift caused by missed Horizon events or failed ETL batches. This is a **read-only** operation that reports anomalies without auto-correcting them.
130+
131+
### Reconciliation Job
132+
133+
**Job Type**: `vault.reconcile`
134+
135+
**Handler**: `TransactionETLService.reconcileVaults()`
136+
137+
**Schedule**: Run periodically (e.g., daily or hourly) via the job system.
138+
139+
### How It Works
140+
141+
1. **Fetches persisted vaults** from the `vaults` table (all vaults or a subset via `vaultIds` option)
142+
2. **Reads on-chain state** via `soroban.getVault()` for each vault in batches
143+
3. **Compares key fields**:
144+
- `status` (normalized for case-insensitive comparison)
145+
- `amount`
146+
- `verifier` address
147+
- `success_destination` address
148+
- `failure_destination` address
149+
4. **Reports drift** via structured logs using the abuse-monitor taxonomy:
150+
- `vault.vault_missing_onchain` - vault exists in DB but not on-chain
151+
- `vault.vault_state_drift` - one or more fields differ between DB and on-chain
152+
- `vault.vault_reconciliation_error` - error during reconciliation (e.g., RPC timeout)
153+
154+
### Job Payload
155+
156+
```typescript
157+
interface VaultReconcileJobPayload {
158+
vaultIds?: string[] // Optional: specific vault IDs to reconcile
159+
batchSize?: number // Optional: batch size for RPC calls (default: 50)
160+
}
161+
```
162+
163+
### Example Usage
164+
165+
```ts
166+
// Enqueue a reconciliation job for all vaults
167+
await enqueueJob('vault.reconcile', { batchSize: 50 })
168+
169+
// Enqueue a reconciliation job for specific vaults
170+
await enqueueJob('vault.reconcile', { vaultIds: ['vault-1', 'vault-2'] })
171+
```
172+
173+
### Result
174+
175+
The reconciliation returns:
176+
177+
```typescript
178+
{
179+
totalVaults: number // Total vaults to reconcile
180+
checked: number // Successfully checked vaults
181+
driftDetected: number // Vaults with state drift
182+
missingOnChain: number // Vaults missing on-chain
183+
errors: number // Reconciliation errors
184+
}
185+
```
186+
187+
### Idempotency and Resumability
188+
189+
- **Idempotent**: Running the same reconciliation multiple times produces consistent results
190+
- **Resumable**: If aborted via `AbortSignal`, the job can be re-run; no partial state is persisted
191+
- **Bounded**: Processes vaults in configurable batches to avoid RPC rate limiting
192+
193+
### Configuration
194+
195+
Requires Soroban configuration (same as vault submission):
196+
197+
- `SOROBAN_CONTRACT_ID`
198+
- `SOROBAN_NETWORK_PASSPHRASE`
199+
- `SOROBAN_SOURCE_ACCOUNT`
200+
- `SOROBAN_RPC_URL`
201+
- `SOROBAN_SECRET_KEY`
202+
203+
If Soroban is not configured, the job skips reconciliation and returns zero counts.
204+
205+
### Drift Response
206+
207+
When drift is detected:
208+
209+
1. **Log the anomaly** with full context (vault ID, drifted fields, persisted vs on-chain values)
210+
2. **Do NOT auto-correct** - manual intervention is required to replay missed events
211+
3. **Alert operators** via monitoring systems that consume the structured logs
212+
213+
### Testing
214+
215+
```bash
216+
# Run reconciliation tests
217+
bun test src/tests/transactionETL.reconcile.test.ts
218+
219+
# With coverage
220+
bun test src/tests/transactionETL.reconcile.test.ts --coverage
221+
```
222+
223+
Test coverage includes:
224+
- Status drift detection
225+
- Missing on-chain vault
226+
- RPC timeout handling
227+
- Fully consistent run (zero drift)
228+
- Batch processing with abort signal
229+

src/jobs/handlers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NotificationService } from '../services/notifications/factory.js'
22
import { processJob as processExportJob } from '../services/exportQueue.js'
33
import type { JobHandler, JobType } from './types.js'
44
import { markVaultExpiries } from '../services/vault.js'
5+
import { TransactionETLService } from '../services/transactionETL.js'
56

67
type JobHandlerRegistry = {
78
[K in JobType]: JobHandler<K>
@@ -59,4 +60,21 @@ export const defaultJobHandlers: JobHandlerRegistry = {
5960
`exportJobId=${payload.exportJobId} attempt=${context.attempt}`,
6061
)
6162
},
63+
'vault.reconcile': async (payload, context) => {
64+
const etlConfig = {
65+
horizonUrl: process.env.HORIZON_URL || 'https://horizon-testnet.stellar.org',
66+
networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE || 'Test SDF Network ; September 2015',
67+
batchSize: payload.batchSize || 50,
68+
maxRetries: 3,
69+
}
70+
const etlService = new TransactionETLService(etlConfig)
71+
const result = await etlService.reconcileVaults({
72+
vaultIds: payload.vaultIds,
73+
batchSize: payload.batchSize,
74+
})
75+
logJob(
76+
'vault.reconcile',
77+
`vaultIds=${payload.vaultIds?.length || 'all'} batchSize=${payload.batchSize || 50} checked=${result.checked}/${result.totalVaults} drift=${result.driftDetected} missing=${result.missingOnChain} errors=${result.errors} attempt=${context.attempt}`,
78+
)
79+
},
6280
}

src/jobs/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const JOB_TYPES = [
44
'oracle.call',
55
'analytics.recompute',
66
'export.generate',
7+
'vault.reconcile',
78
] as const
89

910
export type JobType = (typeof JOB_TYPES)[number]
@@ -36,12 +37,18 @@ export interface ExportGenerateJobPayload {
3637
exportJobId: string
3738
}
3839

40+
export interface VaultReconcileJobPayload {
41+
vaultIds?: string[]
42+
batchSize?: number
43+
}
44+
3945
export interface JobPayloadByType {
4046
'notification.send': NotificationJobPayload
4147
'deadline.check': DeadlineCheckJobPayload
4248
'oracle.call': OracleCallJobPayload
4349
'analytics.recompute': AnalyticsRecomputeJobPayload
4450
'export.generate': ExportGenerateJobPayload
51+
'vault.reconcile': VaultReconcileJobPayload
4552
}
4653

4754
export interface JobContext {
@@ -114,6 +121,11 @@ export const isPayloadForJobType = (
114121
)
115122
case 'export.generate':
116123
return isNonEmptyString(payload.exportJobId)
124+
case 'vault.reconcile':
125+
return (
126+
(payload.vaultIds === undefined || Array.isArray(payload.vaultIds)) &&
127+
(payload.batchSize === undefined || typeof payload.batchSize === 'number')
128+
)
117129
default:
118130
return false
119131
}

src/security/abuse-monitor.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,20 @@ export function __resetSecurityMonitorForTests(): void {
193193
processedEvents = 0
194194
}
195195

196+
// ─── Vault drift anomaly logging ───────────────────────────────────────────
197+
198+
type VaultDriftEventType =
199+
| 'vault_missing_onchain'
200+
| 'vault_state_drift'
201+
| 'vault_reconciliation_error'
202+
203+
export function logVaultDriftAnomaly(
204+
event: VaultDriftEventType,
205+
data: Record<string, unknown>,
206+
): void {
207+
logSecurityEvent(`vault.${event}`, data)
208+
}
209+
196210
function getIpState(ip: string, now: number): IpState {
197211
const existing = ipStates.get(ip)
198212
if (existing) {

src/services/soroban.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ export interface SorobanClient {
4848
config: SorobanConfig,
4949
args: Record<string, unknown>,
5050
): Promise<{ txHash: string }>
51+
getVault(
52+
config: SorobanConfig,
53+
vaultId: string,
54+
): Promise<OnChainVaultState | null>
55+
}
56+
57+
export interface OnChainVaultState {
58+
vault_id: string
59+
amount: string
60+
verifier: string
61+
success_destination: string
62+
failure_destination: string
63+
status: 'active' | 'completed' | 'failed' | 'cancelled'
5164
}
5265

5366
/**
@@ -116,6 +129,41 @@ export const defaultSorobanClient: SorobanClient = {
116129

117130
return { txHash: response.hash }
118131
},
132+
async getVault(config, vaultId) {
133+
const {
134+
Contract,
135+
rpc: SorobanRpc,
136+
nativeToScVal,
137+
scValToNative,
138+
} = await import('@stellar/stellar-sdk')
139+
140+
const server = new SorobanRpc.Server(config.rpcUrl)
141+
const contract = new Contract(config.contractId)
142+
143+
try {
144+
const callOp = contract.call('get_vault', nativeToScVal(vaultId, { type: 'string' }))
145+
146+
const result = await server.simulateTransaction(callOp)
147+
148+
if (result.result === undefined || result.result === null) {
149+
return null
150+
}
151+
152+
const decoded = scValToNative(result.result)
153+
154+
return {
155+
vault_id: decoded.vault_id || vaultId,
156+
amount: decoded.amount || '0',
157+
verifier: decoded.verifier || '',
158+
success_destination: decoded.success_destination || '',
159+
failure_destination: decoded.failure_destination || '',
160+
status: decoded.status || 'active',
161+
}
162+
} catch (error) {
163+
log('error', 'soroban.get_vault_error', { vaultId, error: error instanceof Error ? error.message : 'Unknown error' })
164+
return null
165+
}
166+
},
119167
}
120168

121169
// Allow overriding the client (for tests)
@@ -129,6 +177,8 @@ export const resetSorobanClient = (): void => {
129177
_client = defaultSorobanClient
130178
}
131179

180+
export const getSorobanClient = (): SorobanClient => _client
181+
132182
// ─── Structured logging helper (no PII) ─────────────────────────────────────
133183

134184
const log = (level: 'info' | 'warn' | 'error', event: string, data: Record<string, unknown> = {}): void => {

0 commit comments

Comments
 (0)