Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions .github/workflows/keeper.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
name: keeper

# InvoFi keeper automation (Task 12).
# Runs every 6 hours against the live testnet deployment:
# InvoFi keeper automation (Task 12 & Event-driven upgrade).
# Runs against the live testnet deployment:
# - reacts to inv_reg / off_acc contract events
# - marks Financed invoices overdue once their due_date passes
# - best-effort TTL extension for active invoice storage
# - retains full-sweep fallback
# See invofi/scripts/keeper.ts for details and env var docs.
#
# Required repo configuration (once):
# Secret: KEEPER_SECRET_KEY β€” secret key of a funded testnet account that
# pays transaction fees. Ask in the repo's
# issues if you need a funded key.
# Variable: REGISTRY_CONTRACT_ID, REPAYMENT_CONTRACT_ID β€” override the
# Variable: REGISTRY_CONTRACT_ID, REPAYMENT_CONTRACT_ID, FINANCING_CONTRACT_ID β€” override the
# defaults below if you deployed your own contract set.

on:
Expand Down Expand Up @@ -39,12 +41,32 @@ jobs:
cache: npm
cache-dependency-path: invofi/scripts/package-lock.json
- run: npm ci
- run: npm test
- run: npm run type-check
- name: Download checkpoint artifact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
with:
name: keeper-checkpoint
path: invofi/scripts
continue-on-error: true
- name: Run keeper
# Note: continuous long-running daemon (KEEPER_MODE=event-driven) is intended
# for persistent container/VPS hosting. In scheduled GitHub Actions runs, we
# execute KEEPER_MODE=event-catchup to process recent contract events before the sweep.
env:
KEEPER_MODE: event-catchup
RPC_URL: https://soroban-testnet.stellar.org
NETWORK_PASSPHRASE: Test SDF Network ; September 2015
REGISTRY_CONTRACT_ID: ${{ vars.REGISTRY_CONTRACT_ID || 'CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7' }}
REPAYMENT_CONTRACT_ID: ${{ vars.REPAYMENT_CONTRACT_ID || 'CCDATW5GMVDOPK55Q4MLXV5SGA3VLXPD67ABLBNMHWFF6BLL2IZBUVEP' }}
FINANCING_CONTRACT_ID: ${{ vars.FINANCING_CONTRACT_ID || 'CBGRA3457ZFXYZNEQLO4YGUQ3OBEWOE6US6ZREHK6NF2DLZYBO73IFVW' }}
KEEPER_SECRET_KEY: ${{ secrets.KEEPER_SECRET_KEY }}
run: npm run keeper
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Upload checkpoint artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: keeper-checkpoint
path: invofi/scripts/.keeper-checkpoint.json
overwrite: true
continue-on-error: true
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ InvoFi lives across **two repositories**, split so the fast-moving app layer and
> - reputation: [`CCHKVUWGTQ56U53C5U7ZSOFDTTMGLMOFCL22DME5UMXIYWQNUYXOYPDN`](https://stellar.expert/explorer/testnet/contract/CCHKVUWGTQ56U53C5U7ZSOFDTTMGLMOFCL22DME5UMXIYWQNUYXOYPDN)
> - position token: `POS` minted to lenders on acceptance ([`CBIXYAJPEOOVIALBUTA7X2H26WXSI5JDZCTE23RUMQR4QFJNMPL6767Z`](https://stellar.expert/explorer/testnet/contract/CBIXYAJPEOOVIALBUTA7X2H26WXSI5JDZCTE23RUMQR4QFJNMPL6767Z))
>
> A keeper automation (6-hourly GitHub Action) scans testnet, bumps contract-data TTLs,
> and marks past-due Financed invoices Overdue β€” see `invofi/scripts/keeper.ts`.
> A keeper automation (event-driven Soroban RPC getEvents polling for `inv_reg`/`off_acc` + 6-hourly fallback sweep)
> bumps contract-data TTLs and marks past-due Financed invoices Overdue β€” see `invofi/scripts/keeper.ts`.
>
> Deploy your own via the **Deploy Contracts to Testnet** workflow in [invofi-contracts](https://github.com/Stellar-VaultLink/invofi-contracts) and set the three `NEXT_PUBLIC_*_CONTRACT_ID` variables in Vercel. Without a contract configured the app runs in alpha mode (off-chain only).

Expand Down Expand Up @@ -151,7 +151,7 @@ npm install && npm run dev
β”‚ reads protocol_stats β”‚ β”‚ checkpointed event replay β†’ protocol_stats β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

keeper (T12) β€” 6-hourly GitHub Action: mark_overdue + TTL bumps (Soroban RPC)
keeper (T12) β€” Event-driven RPC subscriptions (`inv_reg`/`off_acc`) + 6-hourly fallback sweep: mark_overdue + TTL bumps
```

No always-on backend server to manage. 100% free hosting.
Expand Down Expand Up @@ -530,7 +530,7 @@ Both identities are auto-funded via Friendbot on testnet. See
- [x] Marketplace sorting (newest, amount, due date) and Stellar Expert explorer links
- [x] Insurance coverage pool with **payout on default**
- [x] On-chain **reputation scoring** for originators
- [x] Keeper automation β€” 6-hourly TTL bump + overdue marking
- [x] Keeper automation β€” event-driven Soroban RPC subscriptions (`inv_reg`, `off_acc`) + 6-hourly fallback sweep
- [x] SEP-41 token movement β€” `accept_offer` funds the business, `repay_invoice` repays principal + yield
- [x] Split into 5 auditable contract crates β€” registry / financing / repayment / insurance / reputation
- [x] Emergency pause / circuit breaker β€” admin-gated `pause` on every state-mutating function
Expand Down
3 changes: 1 addition & 2 deletions docs/10-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Last updated: August 2026. Checkbox status reflects what is merged to `main`
### Infrastructure & Automation

- [x] Frontend testnet config β€” registry/financing/repayment contract IDs (`NEXT_PUBLIC_{REGISTRY,FINANCING,REPAYMENT}_CONTRACT_ID`)
- [x] Keeper automation β€” 6-hourly overdue marking + TTL bumps
- [x] Keeper automation β€” event-driven Soroban RPC getEvents polling (`inv_reg`, `off_acc`) + 6-hourly fallback sweep
- [x] Event indexer β€” checkpointed replay β†’ `protocol_stats`; **temporarily bypassed pending the Supabase/DB migration** β€” re-enabled when the indexer is rewired to the new database
- [x] Contributors auto-table on merge (no opt-in comment needed), bot-driven PRs, issues open to all
- [x] One-click Testnet deploy via GitHub Actions (invofi-contracts)
Expand All @@ -59,5 +59,4 @@ Last updated: August 2026. Checkbox status reflects what is merged to `main`

- [ ] Lender verification (threshold-based SEP-12 onboarding, Phase 4 of compliance.md)
- [ ] On-chain (event-sourced) position listings β€” the alternative deferred in ADR-0004, if listings ever need to be readable without InvoFi's frontend
- [ ] Event-driven keeper (Soroban RPC event subscriptions instead of polling)
- [ ] Historical time-series charts on `/stats`
36 changes: 36 additions & 0 deletions docs/adr/0005-event-driven-keeper.md
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).
163 changes: 163 additions & 0 deletions invofi/scripts/keeper.test.ts
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;
}
}
});
});
Loading