Repository navigation
feat(core): add configurable minimum-balance safety check before extension submission #587
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
Changes from all commits
cf81d70
156d642
e89a5b6
7cf8b0d
2e90a75
cad2d88
243d328
f4b9ee4
00963ca
b156a44
85f634a
0953c4e
9c13d12
2445cd8
123e3a4
ca473cc
8d5f7c5
18e5cf5
b6670d3
a4c865a
6c04823
4efe0b3
72d88ab
85074eb
fc3d916
af0c188
3913b02
84e5b6d
67d0e57
0a77b16
5ba92cb
8435912
c037e93
1d9226b
de2e29b
593ba76
786d748
fec2112
c6f8f3c
dccb85d
aba812e
e404b50
e1c0abe
48be30e
b041ec0
260f3d2
a26cbbd
6ba24cf
2dd5dc8
cecf938
e626b6e
41ddde6
3240c4e
973d7c8
9ff1fdb
12ffb9b
077a3ef
5edd582
16192a4
2fde0e7
f8d406a
823bee6
fe55c53
806f0d5
910574e
8acedb1
e86ad8d
5d6af4e
4a56ea3
1d882d7
90e8acd
19e2eac
6bbe989
e8c8245
e9613b6
d0aa8c5
5cbc2f0
f49b539
445c18f
abb29cd
474399c
1ce0d04
575424f
08b15fa
8dbd0e8
997657d
42b4b9a
60ee4a0
05265b7
e8dc112
c6fb1b6
555d4e6
755700a
7167dd3
f07c5af
0bc7845
7fc1263
b0a8a4e
13d3a62
f379e14
76d277a
48c08c1
f684ae0
e752584
dbedc6f
296c264
bba6886
e28af5c
6cf1fbc
43f762b
35dd669
ff9fb4c
8d1a05a
e366792
8842d53
2551e78
319a1a1
2eb0f91
d17c86c
aff2bfb
5075b04
2ac9b5a
c865fb6
0fafeab
e336a03
5c0f713
a5cca04
0ae1fa8
d8e9eb3
6e2f0b9
fb7c4d4
519eba0
a2753ac
cec4b4c
db1bf81
e13f561
bb66d11
f999b1c
de9c315
4c68e72
69eec94
cbabe26
9e90018
cb5ae84
fef7989
75ecc42
049819e
d1d13d3
7fe57b4
f984c1a
9a9f66d
85cece9
f57d1ab
6f75142
cc7a88c
fd3cb17
c4d85a1
c55e954
e468b79
c3765e3
d79abce
6cda20d
d4865ed
c3e3d08
17aea2c
90c9c6e
a80a30d
9ca303a
dbe9f52
9217105
b68a95c
5902b50
eff06a3
4512bec
ab63d20
36c55bd
11be0e9
777a0da
178d50e
7ace334
953c785
db777d2
72d7e8b
8363a78
94fd470
04b648e
ed558f0
d6aa987
f3b8d8d
85097d4
484234a
7fd65a4
53af1ed
e05b481
3c4b922
591fc9e
911426c
c6eb220
809a26e
2a5a26c
5e46533
2441f3c
7165e27
6d5754d
11a398a
c6a1fb1
93535ca
2c69b7c
4eb1603
08630ea
9e320e4
82bd1e9
0a6e53d
fa568df
f498cab
439316c
4df6c32
7ce208b
6df3ba2
5ebd335
9e3feaf
5f97176
fb97e8c
b76d86a
51169eb
3a43318
7d5147c
569352e
13f9a44
ef55404
fb947ae
dfbfcf0
cda333e
71e8c83
616b98d
b50e46b
14cba90
4069c5c
7c2df98
68312bc
7f3d72e
4938e16
35d9237
01b527b
62f2ce4
cb98357
9b4b72a
deadc7f
4f5d8dd
381b28d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,20 +6,24 @@ import { | |
| getEntriesForContract, | ||
| getExtensionPolicy, | ||
| getChannelAccounts, | ||
| getAlertConfigsForContract, | ||
| recordExtension, | ||
| upsertEntry, | ||
| updateLastCheckedLedger, | ||
| getAverageResourceUsage, | ||
| getBudget, | ||
| addBudgetSpent, | ||
| countExtensionsInLastHour, | ||
| getExtensionHistory, | ||
|
|
||
| } from "../db/repositories.js"; | ||
| import { ChannelAccountPool } from "./channels.js"; | ||
| import { getLogger } from "../logging/index.js"; | ||
| import { formatSecretKey } from "../utils/formatting.js"; | ||
| import { VaultResolver } from "./vault.js"; | ||
| import { loadConfig } from "../utils/config.js"; | ||
| import { deliverSingleAlert } from "../alerts/dispatcher.js"; | ||
| import { buildAlertEvent } from "../alerts/types.js"; | ||
|
|
||
| const logger = getLogger().child({ component: "Extension" }); | ||
|
|
||
|
|
@@ -32,6 +36,25 @@ const logger = getLogger().child({ component: "Extension" }); | |
| */ | ||
| export const HOURLY_RATE_LIMIT = 5; | ||
|
|
||
| /** | ||
| * Minimum time (in milliseconds) that must pass between two consecutive | ||
| * extensions of the same contract entry. Prevents the same entry being | ||
| * extended twice in quick succession when the threshold is very tight | ||
| * relative to the target TTL. | ||
| * | ||
| * Default 5 minutes — slightly below the typical polling interval so it | ||
| * does not interfere with normal operation. | ||
| */ | ||
| export const EXTENSION_COOLDOWN_MS = 5 * 60 * 1000; | ||
|
|
||
| /** | ||
| * Minimum XLM balance a channel account must hold before Sorokeep will submit | ||
| * an extension transaction through it. Covers the base reserve (1 XLM) plus | ||
| * a safety margin for transaction fees. Accounts below this threshold are | ||
| * skipped and an alert is fired rather than attempting a failing transaction. | ||
| */ | ||
| export const MINIMUM_BALANCE_XLM = 5; | ||
|
|
||
| /** | ||
| * Check whether the given contract has reached its hourly auto-extension rate limit. | ||
| * | ||
|
|
@@ -358,6 +381,44 @@ export async function runAutoExtensions( | |
| return; | ||
| } | ||
|
|
||
| // ── Cooldown check per entry (issue #510) ────────────────────── | ||
| // Filter out entries that were extended within the cooldown window | ||
| // to prevent redundant back-to-back extensions of the same entry. | ||
| const recentHistory = getExtensionHistory(db, contract.id, 1); | ||
| const lastExtendedAt = new Map<number, Date>(); | ||
| for (const record of recentHistory) { | ||
| const entryId = record.contract_entry_id; | ||
| // executed_at is stored as UTC (SQLite CURRENT_TIMESTAMP); append | ||
| // "Z" so the timestamp is parsed as UTC regardless of the host | ||
| // machine's timezone. | ||
| const executedAt = new Date(record.executed_at + "Z"); | ||
| const existing = lastExtendedAt.get(entryId); | ||
| if (!existing || executedAt > existing) { | ||
| lastExtendedAt.set(entryId, executedAt); | ||
| } | ||
| } | ||
|
|
||
| const cooldownEligible = needsExtension.filter(e => { | ||
| const lastExt = lastExtendedAt.get(e.id); | ||
| if (!lastExt) return true; // never extended → eligible | ||
| const elapsed = Date.now() - lastExt.getTime(); | ||
| if (elapsed < EXTENSION_COOLDOWN_MS) { | ||
| logger.info( | ||
| `Entry ${e.entry_key_xdr} for ${contract.id} was extended ` + | ||
| `${Math.round(elapsed / 1000)}s ago — skipping (cooldown: ${EXTENSION_COOLDOWN_MS / 1000}s)`, | ||
| ); | ||
| return false; | ||
| } | ||
| return true; | ||
| }); | ||
|
|
||
| if (cooldownEligible.length === 0) { | ||
| logger.info( | ||
| `All ${needsExtension.length} entries for ${contract.id} are within cooldown — skipping`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // Resolve secret key: prefer channel pool, fall back to policy keypair | ||
| let secretKey: string | null = null; | ||
| let slot: import("./channels.js").ChannelSlot | null = null; | ||
|
|
@@ -382,7 +443,65 @@ export async function runAutoExtensions( | |
| return; | ||
| } | ||
|
|
||
| const entryKeys = needsExtension.map(e => e.entry_key_xdr); | ||
| // ── Minimum-balance check (issue #504) ────────────────────────── | ||
| // If using a channel account, verify it has enough XLM to cover the | ||
| // transaction fee and base reserve before attempting submission. | ||
| if (slot) { | ||
| const accounts = getChannelAccounts(db, network); | ||
| const channelAccount = accounts.find(a => a.public_key === slot!.publicKey); | ||
| const balance = channelAccount?.balance_xlm; | ||
|
|
||
| if (balance === null || balance === undefined) { | ||
| const msg = `Contract ${contract.id}: Channel account ${slot.publicKey} balance is unknown — skipping extension. Run 'sorokeep channels list' to refresh balances.`; | ||
| logger.warn(msg); | ||
| result.errors.push(msg); | ||
| pool!.release(slot.publicKey); | ||
| return; | ||
| } | ||
|
Comment on lines
+449
to
+460
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find where balance_xlm actually gets updated from network data
rg -n 'updateChannelBalance\(' src -g '!**/tests/**'
# Confirm the actual behavior of 'channels list'
rg -n -A5 'command\("list"\)' src/commands/channels.tsRepository: AbdulmalikAlayande/sorokeep Length of output: 722 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== candidate files =="
git ls-files | rg 'src/(core/extension\.ts|commands/channels\.ts|db/repositories\.ts|core/channels\.ts)$' || true
echo
echo "== extension warning context =="
sed -n '430,475p' src/core/extension.ts 2>/dev/null || true
echo
echo "== channels list implementation =="
sed -n '1,90p' src/commands/channels.ts 2>/dev/null || true
echo
echo "== getChannelAccounts / updateChannelBalance refs =="
rg -n "getChannelAccounts|updateChannelBalance|balance_xlm|xlm|SorobanRPC|getLedgerEntries|getBalance" src -g '!**/tests/**' | head -n 200 || trueRepository: AbdulmalikAlayande/sorokeep Length of output: 19176 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== core/channels.ts balance refresh implementation =="
sed -n '1,190p' src/core/channels.ts
echo
echo "== updateChannelBalance definition =="
sed -n '990,1012p' src/db/repositories.ts
echo
echo "== all updateChannelBalance call sites outside tests =="
rg -n 'updateChannelBalance\(' src -g '!**/tests/**'Repository: AbdulmalikAlayande/sorokeep Length of output: 7543 Correct the unknown-balance guidance.
🤖 Prompt for AI Agents |
||
|
|
||
| if (balance < MINIMUM_BALANCE_XLM) { | ||
| const msg = `Contract ${contract.id}: Channel account ${slot.publicKey} balance ${balance} XLM is below minimum ${MINIMUM_BALANCE_XLM} XLM — skipping extension.`; | ||
| logger.warn(msg); | ||
| result.errors.push(msg); | ||
|
|
||
| // Fire alert through the contract's configured alert channels | ||
| // using the first entry that needed extension as context | ||
| const sampleEntry = needsExtension[0]!; | ||
| const contractRecord = getContract(db, contract.id); | ||
| const alertConfigs = getAlertConfigsForContract(db, contract.id); | ||
|
Comment on lines
+450
to
+471
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win Avoid re-fetching data already available in scope.
Reuse the already-fetched values instead of issuing new queries per eligible task. ♻️ Proposed fix if (slot) {
- const accounts = getChannelAccounts(db, network);
- const channelAccount = accounts.find(a => a.public_key === slot!.publicKey);
+ const channelAccount = channelAccounts.find(a => a.public_key === slot!.publicKey);
const balance = channelAccount?.balance_xlm; const sampleEntry = needsExtension[0]!;
- const contractRecord = getContract(db, contract.id);
+ const contractRecord = contract;
const alertConfigs = getAlertConfigsForContract(db, contract.id);Confirm #!/bin/bash
ast-grep run --pattern 'interface Contract { $$$ }' --lang typescript src🤖 Prompt for AI Agents |
||
| for (const config of alertConfigs) { | ||
| const event = buildAlertEvent({ | ||
| type: "threshold_crossed", | ||
| contractId: contract.id, | ||
| contractName: contractRecord?.name ?? null, | ||
| network, | ||
| entryKeyXdr: sampleEntry.entry_key_xdr, | ||
| entryType: sampleEntry.entry_type, | ||
| entryLabel: sampleEntry.label, | ||
| configuredLedgers: MINIMUM_BALANCE_XLM, | ||
| remainingTTL: sampleEntry.live_until_ledger | ||
| ? Math.max(0, sampleEntry.live_until_ledger - latestLedger) | ||
| : 0, | ||
| firedAtLedger: latestLedger, | ||
| }); | ||
|
Comment on lines
+473
to
+486
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
sed -n '1,60p' src/alerts/types.ts
rg -n 'threshold_crossed|alert_resolved|low_balance' src/alerts --type tsRepository: AbdulmalikAlayande/sorokeep Length of output: 6912 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== extension relevant section =="
sed -n '430,520p' src/core/extension.ts
echo
echo "== constants and config usages =="
rg -n "MINIMUM_BALANCE_XLM|threshold_ledgers|buildAlertEvent|deliverSingleAlert|alert_config" src/core/extension.ts src -g '*.ts'
echo
echo "== alerts builder/contracts =="
sed -n '70,130p' src/alerts/types.ts
rg -n "function buildAlertEvent|type AlertEvent|interface .*Event|makeAlertEvent" src/alerts --type ts
echo
echo "== test coverage section =="
sed -n '995,1095p' tests/core/extension.test.tsRepository: AbdulmalikAlayande/sorokeep Length of output: 31145 Pass the TTL threshold into the low-balance alert event.
🤖 Prompt for AI Agents |
||
| deliverSingleAlert( | ||
| config.channel_type, | ||
| config.channel_target, | ||
| event, | ||
| config.webhook_secret, | ||
| ).catch((err: unknown) => { | ||
| logger.warn( | ||
| `Low-balance alert delivery failed for channel ${config.channel_type}: ${err instanceof Error ? err.message : String(err)}`, | ||
| ); | ||
| }); | ||
| } | ||
|
Comment on lines
+487
to
+497
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check other call sites of deliverSingleAlert/buildAlertEvent for a dedup/state-tracking wrapper
rg -n -B3 -A15 'deliverSingleAlert\(' src --type ts
rg -n 'alert_history|already_fired|last_alert|alert_state' src --type tsRepository: AbdulmalikAlayande/sorokeep Length of output: 6511 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files 'src/**/*.ts' | sed -n '1,220p'
echo
echo "== extension outline/section =="
wc -l src/core/extension.ts
ast-grep outline src/core/extension.ts --match runAutoExtensions --view expanded || true
sed -n '440,510p' src/core/extension.ts
echo
echo "== promises/runAutoExtensions call sites =="
rg -n -B4 -A12 'runAutoExtensions|Promise\.all\(eligibleTasks|runPolling|cron|process\.on\(' src --type ts
echo
echo "== alert state/dedup/search =="
rg -n 'alert_history|already_fired|last_alert|alert_state|sentAlert|alert_sent|cooldown|ttl|firedAtLedger|FiredAtLedger|buildAlertEvent|buildTestEvent|send\(.*event|send\(' src --type ts | sed -n '1,260p'
echo
echo "== dispatcher relevant section =="
sed -n '150,210p' src/alerts/dispatcher.tsRepository: AbdulmalikAlayande/sorokeep Length of output: 26494 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== daemon startDaemon section =="
wc -l src/daemon/loop.ts
sed -n '1,260p' src/daemon/loop.ts
echo
echo "== monitor full alert resolution loop for dedupe =="
sed -n '220,310p' src/core/monitor.ts
echo
echo "== extension task loop full and alert firing section =="
sed -n '360,502p' src/core/extension.ts
echo
echo "== repositories alert fired helpers =="
sed -n '330,380p' src/db/repositories.ts
rg -n -B8 -A20 'function readAlertFiredRows|const readAlertFiredRows|export .*readAlertFiredRows|firedAtLedger|fired_at_ledger|ttl_at_fire' src/db/repositories.ts src/core/monitor.ts src/core/extension.ts | sed -n '1,240p'Repository: AbdulmalikAlayande/sorokeep Length of output: 38844 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== daemon commands usage =="
sed -n '1,130p' src/commands/daemon.ts
echo
echo "== entrypoint / program references =="
rg -n 'startDaemon|command\("\s*daemon|run.*daemon|executeCycle|process\.exit|process\.on\("SIG|SIGTERM|SIGINT' src --type ts
echo
echo "== CLI entrypoint =="
sed -n '1,180p' src/cli/program.tsRepository: AbdulmalikAlayande/sorokeep Length of output: 16440 Check low-balance alert spam before making this a blocking await. This path calls 🤖 Prompt for AI Agents |
||
|
|
||
| pool!.release(slot.publicKey); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| const entryKeys = cooldownEligible.map(e => e.entry_key_xdr); | ||
|
|
||
| logger.info( | ||
| `Auto-extending ${entryKeys.length} entries for ${contract.id} ` + | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: AbdulmalikAlayande/sorokeep
Length of output: 262
🏁 Script executed:
Repository: AbdulmalikAlayande/sorokeep
Length of output: 24227
🏁 Script executed:
Repository: AbdulmalikAlayande/sorokeep
Length of output: 256
Make the minimum balance threshold configurable.
MINIMUM_BALANCE_XLMis a hardcoded constant, while issue#504requires a configurable threshold covering transaction fees and the Stellar base reserve. Read the threshold fromloadConfig()with5as the default instead of fixing it in source.♻️ Proposed direction
🤖 Prompt for AI Agents