A maintenance standard for Soroban contract state.
Every entry a Soroban contract stores has a time-to-live measured in ledgers. When it runs out, persistent data is archived and has to be restored at a cost, and a contract's instance and code become unusable until someone restores them. Since Protocol 23 the restore is automatic during a normal call, so nothing is lost forever — but the cost is real, and there is no way for a contract to watch its own entries approach expiry, because a contract cannot read its own TTL at runtime.
Today every protocol handles this privately, with a script someone remembers to run. There is no shared tooling, because there is no standard way for a contract to say which of its stored keys are the ones that matter.
LedgerKeep is that standard. It has three parts:
maintainable— a Rust crate a contract adopts with one macro. It generates a permissionlessextend_allfunction that extends the contract's own critical keys, and records who did it and when.registry— a permissionless on-chain directory. A contract publishes the list of keys it wants maintained, so external tooling can find work to do without reading the contract's source.rent_vault— a funding contract. A protocol pre-funds it, and it pays a tip to whoever performs the maintenance.
Observation of TTL — knowing when a key is close to expiry — happens off-chain in a companion
command-line keeper, ledgerkeep-cli. This
repository is the on-chain half.
┌───────────────────────┐
│ registry │ who opted in, and which keys they care about
└───────────┬───────────┘
│ 1. read manifests
│
┌───────────┴───────────┐
│ off-chain keeper │ ledgerkeep-cli — the only piece that can see TTL
└────┬─────────────┬────┘
│ │
│ 2. │ 3. claim(target, keeper)
│ extend_all │
▼ ▼
┌──────────┐ ┌──────────────┐
│ your │ │ rent_vault │ 4. cross-calls lk_state() to confirm the
│ contract │◄──┤ │ work happened and who did it, then pays
└──────────┘ └──────────────┘
A keeper reads the registry to find contracts and their declared keys, calls extend_all on one,
then claims a tip from that contract's vault. The vault confirms the work happened and who did it
by cross-calling lk_state() on the maintained contract.
One honest limit worth stating up front: the vault can prove maintenance occurred and who
did it, but not that it was necessary — no contract can read TTL at runtime to check. It caps
payouts at one tip per interval, set by the vault owner. It is a rate limit, not a proof of need.
The keys a contract publishes to the registry are also advisory: nothing on-chain forces them to
match the keys the contract's macro actually extends. The CLI checks for that drift by simulating
extend_all and comparing observed TTLs.
Testnet. Verify each on the explorer before trusting it.
| Contract | ID | Explorer |
|---|---|---|
| registry | CB7K56KG3KHC43FROV534M55FMVGBW24NUFQSXSRMH7OS54242GFYMGN |
view |
| rent_vault | CACRDSINFHJFMH4ADZO3PA376VZQW7PXPWCZAFIFFEB5X4ZJLFJZMUTF |
view |
| long_escrow (example) | CASBZNG6KRKZYRQ22TVOGEYSRDIV7QSCJDFIMSII5LA7XXKIUXOX6NZ6 |
view |
These exist so you can verify the contracts behave as described. Do not point your own deployment at them: they were deployed by a throwaway identity that no longer exists, so the escrow's roles and the vault's owner are unrecoverable, and testnet is periodically reset. Run
scripts/deploy_testnet.shto get your own.
A contract adopts LedgerKeep with one macro call and two thin wrappers. This is the whole integration:
use maintainable::{MaintainableError, MaintenanceState};
maintainable::impl_maintainable!(
threshold: 100_000,
extend_to: 500_000,
persistent: [DataKey::Balance, DataKey::Milestones],
);
#[contractimpl]
impl MyContract {
pub fn extend_all(env: Env, keeper: Address) -> Result<u32, MaintainableError> {
__lk_extend_all(&env, keeper)
}
pub fn lk_state(env: Env) -> Result<MaintenanceState, MaintainableError> {
maintainable::lk_state(&env)
}
}The keys are inlined at compile time, so there is no on-chain key list to store or spoof.
extend_all is permissionless: anyone can call it, and the worst a caller can do is pay a fee to
extend a key that did not need it.
Appearing in the registry takes one more function, and it has to live in the contract. Registration
cannot be done from the command line: the registry authorizes by contract address, and no private
key exists for one, so the call must originate inside the contract being registered. See
register_with in examples/long_escrow for the pattern.
That example is a fixture for demonstrating the standard, not a production escrow — the Stellar ecosystem already has mature escrow infrastructure, and this does not compete with it.
Prerequisites: Rust 1.93.0 and the Stellar CLI. The pinned toolchain and the wasm32v1-none target
are declared in rust-toolchain.toml, so rustup installs both on the first build.
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install the Stellar CLI as a prebuilt binary
curl -sSfL https://github.com/stellar/stellar-cli/releases/download/v27.1.0/stellar-cli-27.1.0-x86_64-unknown-linux-gnu.tar.gz \
| sudo tar xz -C /usr/local/bin stellar
# Create an identity to sign with. `testnet` is a built-in network name.
stellar keys generate alice --network testnet --fundDo not run
cargo install --locked stellar-clifrom inside this repository. It compiles the CLI from source under this repository's pinned toolchain, which takes 10–20 minutes and ties the CLI's minimum Rust version to ours. The prebuilt binary has neither problem.
Build and test:
stellar contract build # builds all contracts to wasm32v1-none
cargo test # runs the test suiteBuild with
stellar contract build, notcargo build.wasm32v1-noneis the only target the Soroban runtime supports, and the Stellar CLI applies build settings the runtime requires. It also refuses Rust 1.81, 1.82, 1.83 and 1.91.0, which have known bad wasm codegen — hence the 1.93.0 pin.
Deploy to testnet:
./scripts/deploy_testnet.sh alice testnet
# paste the export block it prints into your shell, then:
./scripts/init_testnet.sh alice testnetdeploy_testnet.sh builds first, deploys registry → rent_vault → long_escrow in dependency order,
and prints every contract ID in one block at the end. init_testnet.sh initializes the vault and
the escrow, registers the escrow against the registry, opens a vault pointing at it, and then reads
the registry back so you can see what landed.
crates/maintainable the standard: macro, state, client
contracts/registry permissionless manifest directory
contracts/rent_vault maintenance funding and payouts
examples/long_escrow a contract that adopts the standard
scripts/ testnet deploy and init
Issues and pull requests are welcome. CONTRIBUTING.md has the build steps, the commit format, and what a pull request needs to pass.
The short version: commits follow Conventional Commits —
type(scope): description, one logical change per commit. cargo fmt --all -- --check,
cargo clippy --all-targets -- -D warnings, cargo test --all and stellar contract build must
all be clean before anything merges; CI enforces all four.
The contracts are not audited. Do not use them on mainnet with real funds without your own review. To report a vulnerability, see SECURITY.md — please do not open a public issue for one.
| Name | Role | GitHub | Contact |
|---|---|---|---|
| Dillon Ofili | Maintainer | @0dillon | `dillonofili667@gmail.com |
Apache-2.0. See LICENSE.