The decentralized automation & upkeep layer for the Stellar/Soroban ecosystem. Chainlink Keepers — but native to Soroban.
🟢 Live on Stellar testnet:
CDJOYHBS7C2PVJS47BTRDLGBNG2YOE43VX6Y3EWIZPPPKOPRNYQQ54U4— a full register → claim → execute → withdraw run is traced on-chain in docs/DEMO.md.
| Doc | What's inside |
|---|---|
| Live demo | Deployed testnet contract + full on-chain transaction trace |
| Architecture | Components, task lifecycle, storage, money invariants, trust model |
| Fuzzing & property testing | Running/adding fuzz targets, the shared invariant module, crash-to-regression convention |
| Verifier design (E04) | Proposed IKeeperVerifier interface for optional on-chain proof verification |
| Batch operations (E05) | Proposed batch_register_tasks design + integration guide |
| Storage layout survey | Task struct storage-cost findings and recommendations |
| CI | What each CI job checks and which are advisory vs. required |
| Deploying & running | Testnet deploy walkthrough and keeper-bot operator guide |
| Deployments | Canonical record of on-chain addresses |
| Contributing | How to pick up an issue and open your first PR |
| Changelog | Notable changes |
Quick start: make help lists every common command (build, test, fmt, lint, wasm, optimize, bot).
Every DeFi protocol running on Soroban has time-sensitive operations that must be triggered by an external agent:
- Liquidations — health factor drops below threshold → position must be liquidated
- Oracle price pushes — off-chain price must be written on-chain every N seconds
- Funding rate updates — perpetuals markets need periodic rate settlements
- LP rebalancing — concentrated liquidity positions fall outside active range
- TTL extensions — Soroban's storage expiry model means contract data expires unless refreshed
Today, each protocol runs its own centralised bot, creating:
| Pain | Impact |
|---|---|
| Single point of failure | Missed liquidations → bad debt, insolvency |
| High ops burden | Every team re-invents the same infrastructure |
| No economic incentives | Bots run at a loss; sustainability risk |
| Opaque | No on-chain record of who executed what and when |
A shared, permissionless, on-chain coordination layer where:
- dApps register automation tasks with an XLM reward bounty.
- Anyone can run a keeper bot to claim and execute tasks, earning rewards.
- The registry contract enforces fairness, handles escrow, and emits events.
- No trust required — keepers are economically incentivised, not whitelisted.
┌─────────────────────────────────────────────────────────┐
│ dApp / Protocol │
│ (lending protocol, DEX, perps, oracle aggregator...) │
└────────────────┬────────────────────────────────────────┘
│ register_task(reward, calldata, deadline)
▼
┌─────────────────────────────────────────────────────────┐
│ KeeperRegistry Contract │
│ ┌──────────────┐ ┌─────────────┐ ┌───────────────┐ │
│ │ Task Storage │ │ Fee Logic │ │ Auth / Pause │ │
│ └──────────────┘ └─────────────┘ └───────────────┘ │
└────────────────┬────────────────────────────────────────┘
│ events: TaskRegistered, TaskClaimed, TaskExecuted
▼
┌──────────────────────────────────────────────────────────────┐
│ Off-Chain Keeper Bots (permissionless) │
│ Bot A Bot B Bot C ... (anyone can run one) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 1. Listen to events │ │
│ │ 2. claim_task(task_id) │ │
│ │ 3. Execute underlying action (liquidate, push price…) │ │
│ │ 4. execute_task(task_id, proof) │ │
│ │ 5. withdraw_rewards() │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
- Task Registry — any Soroban contract or EOA registers tasks with XLM reward
- Permissionless claiming — first keeper to claim wins lock rights
- Lock period — prevents spam claims while giving the claimer time to execute
- Re-claim after lock expiry — unresponsive keepers lose their lock
- Execution proof — keepers submit a tx hash / state witness for transparency
- Reward escrow — XLM held in contract until task is executed or expired
- Auto-expiry — permissionless
expire_taskrefunds owner after deadline - Task cancellation — owner can cancel a Pending task and receive refund
- Protocol fee — configurable basis-point fee taken from rewards
- Upgradeable — admin can upgrade WASM via Soroban's native pattern
- Pause/unpause — emergency circuit breaker
- Full event log —
TaskRegistered,TaskClaimed,TaskExecuted,TaskExpired,TaskCancelled
- On-chain execution verifier interface — target contracts implement
IKeeperVerifierand the registry calls them to verify execution succeeded - Batch task registration —
batch_register_tasksregisters up toMAX_BATCH_SIZEtasks in one transaction under a single owner auth, with amax_total_rewardescrow ceiling (see docs/BATCH_OPERATIONS.md) - EIP-like task conditions — on-chain
checkUpkeepcallback before claiming - Keeper reputation scores — slash stake for missed executions
- Keeper staking — stake XLM or governance token for priority and dispute resolution
- Governance token ($KPRS) — vote on fee parameters, upgrades, whitelists
- Treasury contract — protocol fees flow to stakers
- Subgraph / indexer — TheGraph-style event indexing for analytics
- Cross-contract task composition — chain multiple operations as a single task
- Decentralized oracle integration — task conditions driven by Reflector/Band
- SDK libraries — TypeScript + Rust SDKs so dApps integrate in < 1 hour
- Keeper DAO — fully on-chain governance of protocol parameters
- Stellar Community Fund grant round — sustained ecosystem funding
┌────────────────────────────────────────────────────────────────────────────┐
│ Soroban Keeper Network │
├────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ KeeperRegistry Contract │ │
│ │ │ │
│ │ Instance Storage (hot, short-TTL) │ │
│ │ ┌──────────┬─────────┬────────┬─────────────┬─────────────┬──────┐ │ │
│ │ │ Admin │ FeeBps │ Paused │ TaskCounter │ RewardToken │ Fees │ │ │
│ │ └──────────┴─────────┴────────┴─────────────┴─────────────┴──────┘ │ │
│ │ │ │
│ │ Persistent Storage (task lifetime) │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ Task(id) → { owner, type, calldata, reward, deadline, │ │ │
│ │ │ status, claimer, claim_ledger, lock_ledgers } │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ KeeperReward(address) → i128 (claimable balance) │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ External (Token) │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ SAC / XLM token contract (transfer, balance) │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────┐ ┌─────────────────────────┐ ┌───────────────┐ │
│ │ dApp Contract │───▶│ register_task (XLM dep) │───▶│ TaskRegistered│ │
│ └────────────────┘ └─────────────────────────┘ │ Event │ │
│ └───────┬───────┘ │
│ ┌────────────────┐ ┌─────────────────────────┐ │ │
│ │ Keeper Bot A │───▶│ claim_task │◀───────────┘ │
│ └────────────────┘ └─────────────────────────┘ │
│ │ ┌─────────────────────────┐ ┌───────────────┐ │
│ └─────────────▶│ execute_task + proof │───▶│ TaskExecuted │ │
│ └─────────────────────────┘ │ Event │ │
│ └───────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
| As a... | I want to... | So that... |
|---|---|---|
| Lending protocol | Register a liquidation task when a position is undercollateralised | My protocol remains solvent without running my own bot |
| Oracle provider | Register periodic price-push tasks with a time deadline | Prices stay fresh without centralised infrastructure |
| Perp DEX | Register funding rate settlement tasks every 8 hours | Settlement never misses even if my team is offline |
| AMM | Register LP rebalancing tasks with custom calldata | Liquidity is always in range without manual intervention |
| Any Soroban contract | Cancel a task if the underlying condition resolves itself | I don't pay keepers for work that's no longer needed |
| As a... | I want to... | So that... |
|---|---|---|
| Keeper | Listen to on-chain events and claim profitable tasks | I earn XLM rewards for providing upkeep |
| Keeper | See the reward amount before claiming | I can calculate profitability vs gas |
| Keeper | Re-claim a task if the original claimer vanished | No task is permanently stuck |
| Keeper | Withdraw my accumulated balance in one transaction | I minimise transaction overhead |
| As a... | I want to... | So that... |
|---|---|---|
| Admin | Pause the registry in emergencies | No new tasks can be registered during an incident |
| Admin | Upgrade the WASM hash | Bug fixes and new features can be deployed without redeployment |
| Admin | Adjust fee basis points | Protocol economics can be tuned by governance |
| Admin | Sweep accumulated fees to treasury | Revenue flows to stakeholders |
register_taskMUST escrow the full reward amount from the caller.- Task ID MUST be monotonically increasing and globally unique.
deadlineMUST be strictly in the future at registration time.calldataMUST NOT exceedMAX_CALLDATA_LEN(1024 bytes), rejected withCalldataTooLargeotherwise. Emptycalldatais accepted.rewardMUST be greater than zero.- MUST emit
TaskRegisteredevent with(task_id, owner, reward, deadline).
claim_taskMUST be callable by any address (permissionless).- MUST reject if task is not in
PendingorClaimed(with expired lock) state. - MUST reject if
deadlinehas passed. - MUST record the
claimeraddress andclaim_ledger. - A second keeper MUST be able to claim after
lock_ledgershave elapsed. - MUST emit
TaskClaimedevent.
execute_taskMUST only be callable by the currentclaimer.- MUST reject if task deadline has passed.
- MUST credit
(reward * (10000 - fee_bps) / 10000)to the keeper's balance. - Protocol fee MUST remain in the contract (swept separately by admin).
- MUST emit
TaskExecutedwith net reward and proof bytes. - Task status MUST transition to
Executed(immutable after this point).
cancel_taskMUST only be callable by the task owner.- MUST only be callable when task is in
Pendingstate. - MUST refund the full reward to the owner.
- MUST emit
TaskCancelled.
expire_taskMUST be callable by anyone.- MUST only succeed when
ledger.timestamp >= task.deadline. - MUST refund the full reward to the task owner.
- MUST emit
TaskExpired.
withdraw_rewardsMUST transfer the keeper's full credited balance.- MUST zero the balance before transfer (CEI pattern).
- MUST emit
RewardsWithdrawn. - MUST revert if balance is zero.
pause/unpauseMUST gateregister_task,claim_task,execute_task,increase_reward, andextend_deadline— the first four open new escrow or reward exposure, andextend_deadlinecan keep escrow locked in a contract the admin has declared unsafe if left open.pause/unpauseMUST NOT gatecancel_task,expire_task, orwithdraw_rewards— these only let already-escrowed value flow back to whoever already owns it, which must always stay available so an admin pause can never become a fund freeze. Read-only views are likewise never gated. See thepause/unpausedoc comment incontracts/keeper-registry/src/lib.rsand thetest_pause_policy_matrix_entry_point_by_entry_pointtest incontracts/keeper-registry/src/test.rsfor the authoritative, verified matrix.set_fee_bpsMUST reject values > 10 000.transfer_adminMUST require auth from BOTH current admin AND new admin.upgradeMUST usedeployer().update_current_contract_wasm, and MUST emitUpgraded(admin + new WASM hash) before doing so.
batch_register_tasks is implemented; see
docs/BATCH_OPERATIONS.md for the full design and
integration guide.
batch_register_tasksMUST require the owner's auth once for the entire batch, not per entry.- MUST reject the whole call, with zero transfers, if the sum of the
batch's rewards exceeds the caller-supplied
max_total_reward. - MUST reject the whole call, with zero transfers, if any single entry fails
the same validation
register_taskapplies. - MUST reject a batch larger than
MAX_BATCH_SIZEwithBatchTooLarge, rather than letting it fail as opaque resource exhaustion. - MUST return task ids in the same order as the input entries.
Note that MAX_BATCH_SIZE is currently a conservative guard rather than a
measured ceiling — issue 0104 owns the empirical measurement. Read the live
value from the max_batch_size() view instead of hardcoding it.
get_tasks(ids: Vec<u64>) -> Vec<Option<Task>>MUST read every requested id in a single call, so an indexer or keeper bot does not need one RPC round trip per task.get_tasks_range(from: u64, count: u32) -> Vec<Option<Task>>MUST read the contiguous idsfrom … from + count - 1. It is the convenience form for the common "scan recent tasks" case, so a caller walking backwards fromtask_countneed not build aVec<u64>.- Both MUST accept at most
MAX_BATCH_READ(50) ids. The bound exists because each id costs exactly one Persistent storage read charged against the transaction's read-entry and read-bytes limits; atMAX_CALLDATA_LEN(1 KiB) per task, 50 reads stay comfortably inside a single simulation. - Exceeding the bound MUST return
BatchTooLargerather than truncating — a silently clipped page is indistinguishable from the genuine end of a range. - A range whose last id would exceed
u64::MAXMUST returnArithmeticOverflowrather than wrapping around to low ids. - Missing ids MUST be returned as
Nonein place, not omitted: the result is positionally aligned with the request (out.len() == ids.len(), andout[i]corresponds toids[i]). A single absent id MUST NOT fail the whole call.Vec<Option<Task>>is used rather than a compactedVec<Task>becauseTaskcarries notask_idfield — omitting missing ids would make the mapping from result back to requested id unrecoverable.Noneis a void XDR variant, so the alignment costs almost nothing on the wire. count == 0and an emptyidsMUST return an empty vector, not an error.- Duplicate ids are permitted and each is resolved independently.
- Both are read-only views and are therefore never gated by
pause.
- All state-mutating functions require
address.require_auth(). - No re-entrancy vectors: token transfers happen after all state mutations (CEI pattern).
- No unchecked arithmetic — Rust's
checked_*methods or overflow-checks = true. - Admin cannot drain escrowed task rewards; only sweeps protocol fees.
- Upgrade requires admin auth — no anonymous upgrades.
- Instance storage for hot/shared data (admin, counter, flags).
- Persistent storage for per-task data with explicit TTL management.
- No unbounded iteration — no
Vec<task_id>scanned in O(n); queries are by key. This is a constraint on storage: the contract keeps no growing list that any operation has to walk. It does not forbid a read-only view over a bounded, caller-supplied set of keys —get_tasks/get_tasks_range(FR-8) are still O(1) per key againstDataKey::Task(id), the caller supplies the keys, and the count is capped by theMAX_BATCH_READconstant. - Events are the query primitive for off-chain indexers.
- Task IDs are u64 — supports 18 quintillion tasks.
- Reward balance is aggregated per keeper — single persistent entry regardless of tasks executed.
- Storage TTL managed per entry; expired tasks are naturally evicted by the ledger.
- Tasks with expired lock periods are always re-claimable.
expire_taskis permissionless — anyone can trigger it to unblock a stuck task.- Contract pause does not affect reward withdrawal (keepers can always pull earned funds).
| Key | Type | Storage | TTL | Default when unset |
|---|---|---|---|---|
Admin |
Address |
Instance | Instance lifetime | — |
FeeBps |
u32 |
Instance | Instance lifetime | 0 (see DEFAULT_FEE_BPS) |
Paused |
bool |
Instance | Instance lifetime | false |
TaskCounter |
u64 |
Instance | Instance lifetime | 0 |
RewardToken |
Address |
Instance | Instance lifetime | — |
Task(u64) |
Task struct |
Persistent | task.ttl_ledgers |
— |
KeeperReward(Address) |
i128 |
Persistent | ~1 year (6.3M ledgers) | 0 |
Task.calldata is capped at MAX_CALLDATA_LEN = 1024 bytes, enforced at
register_task. save_task re-writes the whole Task struct (including
calldata) on every lifecycle mutation — claim_task, execute_task, the
permissionless expire_task, increase_reward, extend_deadline — and those
calls are frequently made by a keeper or third party, not the task owner. An
unbounded calldata would let an owner push arbitrarily large re-serialisation
and storage cost onto whoever touches the task next. 1024 bytes comfortably
covers a realistic encoded contract call — a target Address (~40 bytes XDR),
a function Symbol (up to 32 bytes), and several scalar or address arguments —
with headroom for XDR/Vec overhead. Empty calldata is accepted, since some
task types (e.g. a TtlExtension against a well-known key) need no extra
encoded parameters.
All events use two-topic format (verb_symbol, noun_symbol) for efficient filtering.
contracts/keeper-registry/ Soroban keeper registry contract
examples/keeper-bot/ Example keeper bot (keeper side)
examples/batch-register/ Batch registration helper (task-owner side)
a fuzz/ Fuzzing targets and shared support code
docs/ Architecture, deployment, and demo documentation
Events are the integration contract for off-chain consumers. The table below is
transcribed from the emit_* functions in contracts/keeper-registry/src/lib.rs
(all grouped under the Events banner) and lists every event the contract
emits, and nothing it does not. Build event filters from the Topics column
only — the Event names are documentation labels, not on-chain values.
Every event publishes exactly two topic symbols. Both are symbol_short!
literals, which Soroban limits to 9 characters; that is why several topics
are abbreviated (wdraw, not withdraw; verfail, not verifailed; minrwd,
not min_reward). The abbreviations are part of the on-chain interface and
cannot be "corrected" without breaking existing consumers.
| Event | Emitted by | Topics | Data (in order, with type) |
|---|---|---|---|
Initialized |
initialize |
("init", "admin") |
(admin: Address, reward_token: Address, fee_bps: u32) — emitted at most once |
TaskRegistered |
register_task |
("reg", "task") |
(task_id: u64, owner: Address, reward: i128, deadline: u64) |
RewardIncreased |
increase_reward |
("topup", "task") |
(task_id: u64, new_reward: i128) — the new total reward, not the delta |
DeadlineExtended |
extend_deadline |
("extend", "task") |
(task_id: u64, new_deadline: u64) |
VerifierUpdated |
update_verifier |
("verifier", "task") |
(task_id: u64, verifier: Option<Address>) — None clears the verifier |
TaskClaimed |
claim_task |
("claim", "task") |
(task_id: u64, keeper: Address, ledger_seq: u32) |
TaskVerificationFailed |
execute_task |
("verfail", "task") |
(task_id: u64, keeper: Address) |
TaskExecuted |
execute_task |
("exec", "task") |
(task_id: u64, keeper: Address, net_reward: i128, proof: Bytes) |
TaskCancelled |
cancel_task |
("cancel", "task") |
(task_id: u64, owner: Address) |
TaskExpired |
expire_task |
("exp", "task") |
(task_id: u64,) |
RewardsWithdrawn |
withdraw_rewards |
("wdraw", "reward") |
(keeper: Address, amount: i128) |
Paused |
pause / unpause |
("paused", "admin") |
(paused: bool,) — true from pause, false from unpause |
FeeUpdated |
set_fee_bps |
("fee", "admin") |
(old_bps: u32, new_bps: u32) |
MinRewardUpdated |
set_min_reward |
("minrwd", "admin") |
(old_min: i128, new_min: i128) |
AdminTransferred |
transfer_admin |
("admin", "xfer") |
(old_admin: Address, new_admin: Address) |
FeesSwept |
sweep_fees |
("sweep", "admin") |
(treasury: Address, amount: i128, remaining: i128) |
Upgraded |
upgrade |
("upgrade", "admin") |
(admin: Address, new_wasm_hash: BytesN<32>) — emitted before the executable is swapped |
Notes:
net_rewardinTaskExecutedis the keeper's share after the protocol fee, not the task's gross reward.TaskVerificationFailedandTaskExecutedare both emitted fromexecute_taskand are mutually exclusive for a given call: a rejected proof emits the former and returns an error, so noTaskExecutedfollows.("admin", "xfer")is the only event whose first topic is"admin"; every other admin event uses"admin"as its second topic. Filter on both topics, not just one. | Event | Topics | Data | |-------|--------|------| |TaskRegistered|("reg", "task")|(task_id, owner, reward, deadline)| |TaskClaimed|("claim", "task")|(task_id, keeper, ledger_seq)| |TaskExecuted|("exec", "task")|(task_id, keeper, net_reward, proof)| |TaskExpired|("exp", "task")|(task_id,)| |TaskCancelled|("cancel", "task")|(task_id, owner)| |RewardsWithdrawn|("withdraw", "reward")|(keeper, amount)| |Initialized|("init", "admin")|(admin, reward_token, fee_bps)— emitted at most once | |MinRewardUpdated|("minrwd", "admin")|(old_min, new_min)| |FeesSweep|("sweep", "admin")|(treasury, amount, remaining)|
register_task()
NONE ─────────────────────────────────▶ PENDING
│
┌──────────────────────────┘│
│ claim_task() │ cancel_task()
▼ ▼
CLAIMED CANCELLED
│
┌───────┴──────────┐
│ execute_task() │ expire_task() (deadline passed)
▼ ▼
EXECUTED EXPIRED
(re-claim possible if lock_ledgers elapsed without execute)
Step 1 — Approve the reward amount (ERC-20 / SEP-41 style):
// In your dApp contract, approve the registry to transfer reward tokens
token_client.approve(
&env.current_contract_address(), // from: your contract
®istry_contract_id, // spender: the registry
&reward_amount,
&(env.ledger().sequence() + 1000), // expiry ledger
);Step 2 — Register the task:
// Cross-contract call to register a task
let registry = KeeperRegistryClient::new(&env, ®istry_contract_id);
let task_id = registry.register_task(
&env.current_contract_address(), // owner
&TaskType::Liquidation,
&calldata, // encoded liquidation params
&reward_amount, // XLM in stroops
&(env.ledger().timestamp() + 3600), // deadline: 1 hour from now
&17_280u32, // TTL: ~1 day
&120u32, // lock: ~10 minutes
);Step 3 — React to execution (optional Phase 2 — verifier interface):
// Your contract implements this trait (Phase 2 only)
pub trait IKeeperVerifiable {
fn verify_execution(env: Env, task_id: u64, proof: Bytes) -> bool;
}- Task owners deposit XLM (or any SAC-wrapped token) as the reward.
- Keepers earn
reward * (1 - fee_bps/10000)per task. - Protocol fee (
fee_bps) is configurable by admin (default 3%). - Fees accumulate in the contract; admin sweeps to a treasury address.
The fee is computed with integer division, so it always rounds down:
fee = floor(reward * fee_bps / 10_000)
keeper_net = reward - fee
This is a guarantee, not an accident of the implementation. The protocol can
never collect more than the nominal fee_bps rate; it may collect very
slightly less, and the shortfall is bounded by one stroop per execution,
always in the keeper's favour. keeper_net + fee == reward holds exactly for
every input, so nothing is created or destroyed by the split.
Anyone reconciling expected protocol revenue against actual accrued fees should expect a deficit of up to one stroop per executed task. That is this rule, not a bug.
The dust threshold. For small rewards the fee rounds to zero entirely. The fee is non-zero only once:
min_reward >= ceil(10_000 / fee_bps)
At the 300 bps (3%) default that threshold is 34 stroops:
reward |
fee_bps |
fee |
keeper_net |
effective rate |
|---|---|---|---|---|
| 1 | 300 | 0 | 1 | 0% |
| 33 | 300 | 0 | 33 | 0% |
| 34 | 300 | 1 | 33 | 2.9% |
| 100 | 300 | 3 | 97 | 3% |
| 10 000 000 | 300 | 300 000 | 9 700 000 | 3% |
This connects two parameters that are otherwise set independently. Choosing a
min_reward below the threshold means the protocol earns nothing on those
tasks while still bearing their storage cost, so min_reward and fee_bps
should be chosen together. Setting fee_bps to 0 is also legal and gives the
keeper the whole reward; 10_000 (100%) is legal too, and is the one setting
where a keeper executes a task for no reward at all.
| Attribute | Value |
|---|---|
| Name | Keeper Token |
| Symbol | KPRS |
| Total Supply | 100,000,000 |
| Distribution | 40% Keepers (emissions over 4 years), 20% Team (4-year vest), 20% Ecosystem fund, 10% Early supporters, 10% Treasury |
| Utility | Vote on fee params, propose upgrades, stake for priority queue |
| Emissions | Proportional to tasks executed and stake weight |
# Rust + WASM target
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
# Soroban CLI
cargo install --locked stellar-cli --features opt
# Node.js ≥ 18 (for keeper bot)
node --versiongit clone https://github.com/soroban-tooling/soroban-keeper-network
cd soroban-keeper-network
# Run all tests
cargo test --all --features testutils
# Build WASM
cargo build --release --target wasm32-unknown-unknown --package keeper-registry# Fund a testnet account
stellar keys generate --global deployer
stellar keys fund deployer --network testnet
export DEPLOYER_SECRET_KEY=$(stellar keys show --secret deployer)
export ADMIN_ADDRESS=$(stellar keys address deployer)
# Deploy
./scripts/deploy.sh testnetcd examples/keeper-bot
npm install
cp .env.example .env
# Edit .env with your secret key and contract ID
npm run start:testnetThe owner-side counterpart: reads a JSON or CSV task list and registers the
whole list in one batch_register_tasks call. See
examples/batch-register/README.md for the
file format and the reasoning behind how it sets max_total_reward.
cd examples/batch-register
npm install
cp .env.example .env
# Edit .env with your funded owner secret key and contract ID
node index.js tasks.example.json --dry-run # validate + preview
node index.js tasks.example.json # submitThe bot dispatches off-chain execution to a per-task_type executor rather
than performing (or faking) the work inline. This exists because the
registry's trust model (see "Known Design Decisions" #1 below) has no
on-chain verification of a keeper's proof — the bot itself is the only
thing standing between "did the work" and "claimed the reward for work it
didn't do", so the reference implementation refuses unhandled task types
instead of fabricating proof for them.
An executor is an async function with this contract:
/**
* @param {object} task
* { taskId, taskType, taskTypeName, calldata (Buffer), reward, deadline }
* @param {object} ctx
* { server, keypair, networkPassphrase, log }
* @returns {Promise<Buffer|null>}
* Proof bytes on success; null if the work could not be completed.
* Returning null (or throwing) means the bot will NOT call execute_task —
* the task is left for another keeper or for expiry.
*/
async function myExecutor(task, ctx) { /* ... */ }Register one per task type in EXECUTORS (examples/keeper-bot/index.js):
const EXECUTORS = {
TtlExtension: ttlExtensionExecutor, // worked example, included
// Liquidation: myLiquidationExecutor,
};There is no default executor that fabricates a proof. A task type with
nothing registered is skipped and logged, not faked — set
SIMULATE_EXECUTION=true (development only, see .env.example) if you
need the daemon loop to complete a round without a real executor in place.
- No on-chain execution verification (MVP) — The registry trusts the claimer to submit proof. A malicious keeper could claim-and-execute-fake. Phase 2 adds an optional verifier callback.
- Fee sweep is manual — Protocol fees are batched and swept by admin. In Phase 2 this flows automatically to a staking/treasury contract.
- No slashing (MVP) — Unresponsive keepers lose their lock but face no economic penalty. Phase 2 introduces staking + slashing.
- No re-entrancy — State transitions happen before token transfers (CEI pattern throughout).
- Auth on all mutations — Every write function calls
address.require_auth(). - Overflow protection —
overflow-checks = truein release profile +checked_*arithmetic. - Bounded storage — No dynamic
Vecin storage; all reads are O(1) by key. - Upgrade is admin-gated — WASM upgrade requires admin auth; new WASM must be pre-uploaded.
| Phase | Scope | Target |
|---|---|---|
| Pre-audit | Internal review + fuzzing | Q3 2026 |
| Formal audit | keeper-registry contract |
Q4 2026 |
| Ongoing | Automated invariant testing with cargo-fuzz |
Continuous |
Security issues should be reported per SECURITY.md.
This project is designed to qualify for:
- Stellar Community Fund (SCF) — Open source infrastructure grant
- SDF Build program — Soroban DeFi tooling
- Meridian hackathon — Infrastructure track
Grant readiness checklist:
- Open source (Apache-2.0)
- On Soroban / Stellar ecosystem
- Novel infrastructure (no equivalent exists)
- Composable — designed to be used by other protocols
- Fully documented + testable
- Roadmap beyond MVP
See CONTRIBUTING.md for the full guide including branch strategy, commit conventions, and PR process.
Apache-2.0 — see the LICENSE file for full terms.