-
Notifications
You must be signed in to change notification settings - Fork 33
feat(keeper): upgrade keeper with Soroban RPC event subscriptions (in⦠#212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
samjay8
merged 6 commits into
Stellar-VaultLink:main
from
Unclebaffa:feature/event-driven-keeper
Aug 18, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
87a86a9
feat(keeper): event-driven Soroban RPC subscriptions and polling fallβ¦
Unclebaffa 7391499
feat(keeper): address review feedback for event-driven keeper
Unclebaffa f8c5c30
feat(keeper): add start ledger option and durable checkpoint artifacts
Unclebaffa 0779bb4
fix(ci): pin artifact actions in keeper workflow
Unclebaffa 04019f3
chore: remove out-of-scope changes
Unclebaffa fbffc02
feat(keeper): address CodeRabbit review items for event-driven keeper
Unclebaffa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # ADR-0005: Event-driven keeper upgrade (RPC event subscriptions) | ||
|
|
||
| **Status:** Accepted (2026-08-18) | ||
|
|
||
| ## Context | ||
|
|
||
| The InvoFi keeper (`invofi/scripts/keeper.ts`) performs two critical protocol maintenance tasks: | ||
| 1. `mark_overdue`: Calling `repayment.mark_overdue` on past-due Financed invoices. | ||
| 2. `bump_ttl`: Extending contract storage footprint TTL for active invoices so state entries never expire on Soroban testnet/mainnet. | ||
|
|
||
| Previously (Task 12), the keeper operated exclusively as a 6-hourly batch job via GitHub Actions. As invoice volume increases, scanning all pages of invoices on every run introduces up to a 6-hour delay before past-due invoices are marked overdue or newly registered/financed invoices receive TTL bumps. | ||
|
|
||
| ## Decision | ||
|
|
||
| 1. **Soroban RPC Event Subscriptions (`getEvents`)**: | ||
| Upgrade the keeper to run in an event-driven mode (`KEEPER_MODE=event-driven` or `--mode=event-driven`). The keeper continuously polls Soroban RPC `getEvents` with ledger cursor tracking for contract events emitted by `invofi-registry` and `invofi-financing`. | ||
|
|
||
| 2. **Targeted Event Handlers**: | ||
| - **`inv_reg`** (Invoice Registered): When a new invoice is created on-chain, the keeper instantly receives the event and performs an immediate best-effort TTL bump (`bumpTtl(invoiceId)`). | ||
| - **`off_acc`** (Offer Accepted -> Financed): When an offer is accepted and an invoice transitions to `Financed`, the keeper instantly performs a TTL bump, checks if `due_date < now`, and calls `repayment.mark_overdue` immediately if past-due. | ||
| - **`off_def`** (Offer Defaulted): Default events published by repayment contract are recognized by event parsing. | ||
|
|
||
| 3. **Polling Fallback Retained**: | ||
| The full-sweep paginated invoice scan is retained both: | ||
| - As a periodic background fallback loop (defaulting to every 6 hours) in continuous daemon mode. | ||
| - As a one-shot execution mode (`KEEPER_MODE=sweep`, default fallback) for scheduled cron jobs. | ||
| This provides bounded recovery based on the configured fallback sweep interval (default 6h) against network partitions, process restarts, or missing events beyond RPC retention limits. | ||
|
|
||
| 4. **Ledger Cursor Checkpointing**: | ||
| In event-driven mode, the keeper maintains a disk checkpoint (`.keeper-checkpoint.json`) of the last processed ledger sequence, allowing seamless catch-up after restarts without missed events. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Reaction latency for newly registered or financed invoices drops from hours (up to 6h) to under a minute (~10s ledger poll). | ||
| - Incremental event processing provides rapid reaction time while the full sweep runs only as a periodic fallback. | ||
| - The keeper remains backward compatible with existing 6-hourly GitHub Actions cron jobs (`npm run keeper` in `sweep` mode). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| process.env.NODE_ENV = 'test'; | ||
|
|
||
| import assert from 'node:assert/strict'; | ||
| import test, { describe } from 'node:test'; | ||
| import { | ||
| parseRawEvent, | ||
| statusNum, | ||
| parseKeeperMode, | ||
| parseStartLedger, | ||
| STATUS, | ||
| processEvents, | ||
| } from './keeper.js'; | ||
| import { nativeToScVal } from '@stellar/stellar-sdk'; | ||
|
|
||
| describe('Keeper Unit Tests', () => { | ||
| test('statusNum parses status variants correctly', () => { | ||
| assert.equal(statusNum('Pending'), STATUS.Pending); | ||
| assert.equal(statusNum('Financed'), STATUS.Financed); | ||
| assert.equal(statusNum(1), STATUS.Financed); | ||
| assert.equal(statusNum('3'), STATUS.Overdue); | ||
| assert.equal(statusNum('InvalidStatus'), -1); | ||
| }); | ||
|
|
||
| test('parseRawEvent correctly decodes inv_reg event', () => { | ||
| const topic0 = nativeToScVal('inv_reg', { type: 'symbol' }); | ||
| const topic1 = nativeToScVal('INV-101', { type: 'symbol' }); | ||
| const value = nativeToScVal(['GABC...', 5000n, 1700000000n]); | ||
|
|
||
| const rawEvent = { | ||
| type: 'contract', | ||
| contractId: 'CCREGISTRY...', | ||
| topic: [topic0, topic1], | ||
| value, | ||
| ledger: 12345, | ||
| ledgerClosedAt: '2026-08-18T10:00:00Z', | ||
| id: 'evt-1', | ||
| pagingToken: 'pt-1', | ||
| inSuccessfulContractCall: true, | ||
| txHash: 'hash-1', | ||
| } as any; | ||
|
|
||
| const parsed = parseRawEvent(rawEvent); | ||
| assert.notEqual(parsed, null); | ||
| assert.equal(parsed?.type, 'inv_reg'); | ||
| assert.equal(parsed?.invoiceId, 'INV-101'); | ||
| assert.equal(parsed?.ledger, 12345); | ||
| }); | ||
|
|
||
| test('parseRawEvent correctly decodes off_acc event', () => { | ||
| const topic0 = nativeToScVal('off_acc', { type: 'symbol' }); | ||
| const topic1 = nativeToScVal('INV-303', { type: 'symbol' }); | ||
| const value = nativeToScVal(['INV-303', 'GLENDER...', 10000n]); | ||
|
|
||
| const rawEvent = { | ||
| type: 'contract', | ||
| contractId: 'CCFINANCING...', | ||
| topic: [topic0, topic1], | ||
| value, | ||
| ledger: 12346, | ||
| ledgerClosedAt: '2026-08-18T10:00:05Z', | ||
| id: 'evt-2', | ||
| pagingToken: 'pt-2', | ||
| inSuccessfulContractCall: true, | ||
| txHash: 'hash-2', | ||
| } as any; | ||
|
|
||
| const parsed = parseRawEvent(rawEvent); | ||
| assert.notEqual(parsed, null); | ||
| assert.equal(parsed?.type, 'off_acc'); | ||
| assert.equal(parsed?.invoiceId, 'INV-303'); | ||
| assert.equal(parsed?.ledger, 12346); | ||
| }); | ||
|
|
||
| test('parseRawEvent returns null on decoder failure paths', () => { | ||
| const validTopic0 = nativeToScVal('inv_reg', { type: 'symbol' }); | ||
| const validTopic1 = nativeToScVal('INV-100', { type: 'symbol' }); | ||
| const validValue = nativeToScVal(['GABC...', 5000n]); | ||
|
|
||
| // 1. Unrecognized event name | ||
| const unrecTopic0 = nativeToScVal('unknown_event', { type: 'symbol' }); | ||
| assert.equal( | ||
| parseRawEvent({ topic: [unrecTopic0, validTopic1], value: validValue } as any), | ||
| null, | ||
| ); | ||
|
|
||
| // 2. Missing topic 1 | ||
| assert.equal( | ||
| parseRawEvent({ topic: [validTopic0], value: validValue } as any), | ||
| null, | ||
| ); | ||
|
|
||
| // 3. Non-string topic 1 (e.g. u32 ScVal) | ||
| const intTopic1 = nativeToScVal(9999, { type: 'u32' }); | ||
| assert.equal( | ||
| parseRawEvent({ topic: [validTopic0, intTopic1], value: validValue } as any), | ||
| null, | ||
| ); | ||
|
|
||
| // 4. Undecodable value | ||
| const badValue = { _switch: { value: -9999 } } as any; | ||
| assert.equal( | ||
| parseRawEvent({ topic: [validTopic0, validTopic1], value: badValue } as any), | ||
| null, | ||
| ); | ||
| }); | ||
|
|
||
| test('processEvents with empty event list preserves counter contract', async () => { | ||
| const dummyKp = { publicKey: () => 'GBDUMMY...' } as any; | ||
| const result = await processEvents([], dummyKp); | ||
| assert.deepEqual(result, { processed: 0, ttlBumps: 0, markedOverdue: 0 }); | ||
| }); | ||
|
|
||
| test('parseKeeperMode handles CLI flags, env vars, and default fallbacks', () => { | ||
| const originalArgv = process.argv; | ||
| const originalEnvMode = process.env.KEEPER_MODE; | ||
|
|
||
| try { | ||
| // Clear inputs -> default fallback 'sweep' | ||
| process.argv = ['node', 'keeper.js']; | ||
| delete process.env.KEEPER_MODE; | ||
| assert.equal(parseKeeperMode(), 'sweep'); | ||
|
|
||
| // ENV var fallback | ||
| process.env.KEEPER_MODE = 'event-driven'; | ||
| assert.equal(parseKeeperMode(), 'event-driven'); | ||
|
|
||
| // CLI flag overrides ENV var | ||
| process.argv = ['node', 'keeper.js', '--mode=event-catchup']; | ||
| assert.equal(parseKeeperMode(), 'event-catchup'); | ||
| } finally { | ||
| process.argv = originalArgv; | ||
| if (originalEnvMode !== undefined) { | ||
| process.env.KEEPER_MODE = originalEnvMode; | ||
| } else { | ||
| delete process.env.KEEPER_MODE; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| test('parseStartLedger handles CLI flags, env vars, and default fallbacks', () => { | ||
| const originalArgv = process.argv; | ||
| const originalEnvStart = process.env.KEEPER_START_LEDGER; | ||
|
|
||
| try { | ||
| process.argv = ['node', 'keeper.js']; | ||
| delete process.env.KEEPER_START_LEDGER; | ||
| assert.equal(parseStartLedger(), undefined); | ||
|
|
||
| process.env.KEEPER_START_LEDGER = '50000'; | ||
| assert.equal(parseStartLedger(), 50000); | ||
|
|
||
| process.argv = ['node', 'keeper.js', '--start-ledger=60000']; | ||
| assert.equal(parseStartLedger(), 60000); | ||
| } finally { | ||
| process.argv = originalArgv; | ||
| if (originalEnvStart !== undefined) { | ||
| process.env.KEEPER_START_LEDGER = originalEnvStart; | ||
| } else { | ||
| delete process.env.KEEPER_START_LEDGER; | ||
| } | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.