feat(backend): connect the inactivity watchdog to on-chain Soroban payout execution - #1045
Merged
ONEONUORA merged 3 commits intoAug 20, 2026
Merged
Conversation
`sqlx::migrate!()` aborted on the first failing migration, so on an empty database nothing after the core tables was ever created — including the inactivity watchdog's own `status` / `inactivity_deadline_at` columns, which the watchdog then queried on every sweep. Three separate breakages: - `20260624000000_add_inactivity_watchdog_fields` derived `inactivity_deadline_at` by adding an INTERVAL to `last_ping`, but `last_ping` is a BIGINT of Unix seconds (core tables migration, and the API binds it as an i64), so the statement failed with "operator does not exist: bigint + interval". Derive the deadline with `to_timestamp()` instead, and drop the no-op attempt to re-add `last_ping` as a timestamp. - `20260628000000` was used by two migrations (`add_kyc_records` and `create_admins`); sqlx keys migrations by version and rejected the duplicate with VersionMismatch. Renumber `create_admins` to `20260628000001`, preserving the original order. - `20260729000000_create_plans_beneficiaries_payouts` re-created `plans` and `beneficiaries`, which the core tables migration already creates, failing with "relation \"plans\" already exists". Only `payout_logs` was new, and nothing in the codebase references the duplicate definitions' columns, so keep just that table and point it at the existing `beneficiaries`. Verified by applying the full chain to an empty PostgreSQL database through `DbManager::run_migrations`.
…yout execution Closes #1039 The inactivity watchdog only ever flipped an expired plan's status to TRIGGERED in PostgreSQL and enqueued a `plan.triggered` webhook; the funds were never unlocked on-chain. It now calls `trigger_inheritance` on the Soroban inheritance contract and only records the plan as triggered once the contract confirms it. stellar_submit.rs — StellarSubmitClient learns to invoke Soroban contracts: - `SorobanConfig::from_env()` reads SOROBAN_RPC_URL, INHERITANCE_CONTRACT_ID, STELLAR_SIGNER_SECRET (plus optional passphrase and poll settings), and `with_soroban()` validates the strkeys up front so a misconfigured signer fails at startup rather than on the first expired plan. - `invoke_contract()` builds an InvokeHostFunction transaction, simulates it over JSON-RPC to pick up the footprint, resource fee and auth entries, signs it for the configured network, submits it and polls `getTransaction` until it lands or the timeout expires. - `contract_events()` / `find_event()` / `event_u64_field()` decode the emitted events out of TransactionMeta V3 and V4. inactivity_watchdog.rs — the sweep is now two-phase: - Expired plans are claimed into a new TRIGGERING status under the existing advisory lock, then triggered on-chain outside the transaction. A plan only becomes TRIGGERED after the contract's INHERIT/TRIGGER event is found and its plan_id matches the one we asked for; anything else lands in TRIGGER_FAILED with the reason recorded. - Failed submissions are retried with exponential backoff (INACTIVITY_WATCHDOG_ONCHAIN_MAX_ATTEMPTS / _BACKOFF_MS / _MAX_BACKOFF_MS). Only transport and inclusion failures are retried — a contract-level rejection will be rejected identically next time. - Alerting: failures log at error level, increment the new `inheritx_watchdog_onchain_triggers_total{outcome="failure"}` counter, and enqueue a `plan.trigger_failed` webhook. `plan.triggered` now carries the transaction hash. - A plan left in TRIGGERING by a crashed worker is re-claimed once it goes stale (INACTIVITY_WATCHDOG_TRIGGER_STALE_AFTER_SECS, default 15 minutes). - With no Soroban configuration the watchdog keeps its previous behaviour and says so loudly at startup. Supporting changes: - New migration adds `onchain_plan_id` (the contract's u64 plan id, unique where present), plus `trigger_tx_hash`, `trigger_attempts`, `trigger_started_at` and `last_trigger_error`. - `POST /api/plans` accepts an optional `onchain_plan_id` and it is returned on plan responses, so a plan can actually be linked to its contract plan. - main.rs builds one Stellar client and shares it between the API and the watchdog. Tests: unit coverage for the signing payload, event decoding and matching, config validation, retry classification and backoff; plus database-backed sweep tests. CI's PostgreSQL service now has a health check and the tests get WATCHDOG_TEST_DATABASE_URL so those run (deliberately not DATABASE_URL, which other tests expect to be unreachable).
…en one happened
`inheritx_watchdog_onchain_triggers_total{outcome="success"}` was also
incremented on the fallback path taken when no Soroban signer is configured,
where the watchdog never contacts the chain. Gate it on an actual transaction
hash so the series only reflects real submissions.
ONEONUORA
approved these changes
Aug 20, 2026
ONEONUORA
left a comment
Contributor
There was a problem hiding this comment.
Thank you for your contribution @Yunusabdul38
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #1039
What was missing
InactivityWatchdogServiceswept expired plans, flipped their status toTRIGGEREDin PostgreSQL and enqueued aplan.triggeredwebhook — but never touched the chain. No inheritance was unlocked and no off-ramp payout was initiated.StellarSubmitClientcould only relay a pre-signed envelope to Horizon; it had no way to build or sign a Soroban invocation.What this does
backend/src/stellar_submit.rs— Soroban invocationStellarSubmitClientgains an optional Soroban context, configured from the environment:SOROBAN_RPC_URLINHERITANCE_CONTRACT_IDSTELLAR_SIGNER_SECRETSTELLAR_NETWORK_PASSPHRASESOROBAN_POLL_INTERVAL_MS1000SOROBAN_POLL_TIMEOUT_SECS60with_soroban()validates theS…andC…strkeys immediately, so a misconfigured signer fails at startup rather than on the first expired plan (main.rsexits rather than running with a half-configured signer).invoke_contract()runs the full flow: read the signer's sequence from Horizon → build anInvokeHostFunctiontransaction →simulateTransactionover JSON-RPC for the footprint,minResourceFeeand auth entries → re-assemble, sign for the network →sendTransaction→ pollgetTransactionuntil it lands, fails, or times out.trigger_inheritance(plan_id)wraps it for this contract'strigger_inheritance(caller, plan_id).Events are decoded out of
TransactionMetaV3 and V4 (protocol 23 moved contract events onto the operation meta), with helpers to match on topics and read au64out of a struct event payload.backend/src/inactivity_watchdog.rs— two-phase sweepTRIGGERINGstatus under the existing advisory lock, so a concurrent worker cannot pick them up.TRIGGEREDonly if the contract'sINHERIT/TRIGGERevent is present and carries theplan_idwe asked for. Anything else — no event, wrong plan, submission rejected — lands inTRIGGER_FAILEDwith the reason stored inlast_trigger_error.Retry: exponential backoff via
INACTIVITY_WATCHDOG_ONCHAIN_MAX_ATTEMPTS(3),_ONCHAIN_BACKOFF_MS(1000) and_ONCHAIN_MAX_BACKOFF_MS(30000). Only transport/inclusion failures are retried; a contract-level rejection will be rejected identically next time, so it fails fast.Alerting: failures log at
error, incrementinheritx_watchdog_onchain_triggers_total{outcome="failure"}, and enqueue a newplan.trigger_failedwebhook.plan.triggerednow carriestrigger_tx_hashandonchain_plan_id.Crash recovery: a plan left in
TRIGGERINGby a worker that died mid-submission is re-claimed once it goes stale (INACTIVITY_WATCHDOG_TRIGGER_STALE_AFTER_SECS, default 900).Backwards compatible: with no Soroban configuration the watchdog behaves exactly as before and warns loudly at startup that payouts are not being executed on-chain.
Supporting changes
20260820000000addsonchain_plan_id(the contract'su64plan id, unique where present, so one DB plan cannot double-submit),trigger_tx_hash,trigger_attempts,trigger_started_at,last_trigger_error.POST /api/plansaccepts an optionalonchain_plan_idand returns it on plan responses — without a way to set it the integration would be inert. Existing clients are unaffected (the field is optional).main.rsbuilds one Stellar client and shares it between the API and the watchdog.Baseline fix — please read (first commit)
While verifying the new migration I found the migration chain has never applied to an empty database.
sqlx::migrate!()aborts on the first failure, andmain.rsonly logs a warning, so this was invisible:20260624000000_add_inactivity_watchdog_fieldscomputedinactivity_deadline_ataslast_ping + INTERVAL, butlast_pingis aBIGINTof Unix seconds —operator does not exist: bigint + interval. This is the watchdog's own migration, so on a fresh databasestatusandinactivity_deadline_atdo not exist and every sweep errors.20260628000000was used by two migrations (add_kyc_recordsandcreate_admins) — sqlx rejects the duplicate version withVersionMismatch.20260729000000_create_plans_beneficiaries_payoutsre-createsplansandbeneficiaries, which already exist —relation "plans" already exists.The first commit repairs all three: derive the deadline with
to_timestamp(), renumbercreate_adminsto20260628000001(order preserved), and reduce the third migration to the only table that was actually new (payout_logs, now referencing the existingbeneficiaries). Nothing in the codebase referenced the duplicate definitions' columns.Since none of these migrations ever applied anywhere, editing them in place is safe — there are no recorded checksums to invalidate. Happy to split this commit into its own PR if you'd prefer to review it separately — but without it the column this feature needs can never be created.
Testing
plan_idmatching, strkey config validation, retry classification, backoff doubling and capping.backend/tests/inactivity_watchdog_db_test.rs): the sweep's claim/finalise SQL and the full migration chain, covering the no-chain path, a plan with noonchain_plan_id, stale re-claim and fresh in-flight plans.WATCHDOG_TEST_DATABASE_URLon the test step so the new tests actually run. Deliberately notDATABASE_URL— existing tests assert behaviour when the database is unreachable.Locally, on the pinned 1.88 toolchain:
cargo fmt --check,cargo clippy --all-targets --all-features -- -D warnings,cargo test(88 passing) andcargo build --releaseall clean.Follow-ups (not in scope here)
trigger_inheritancereturnsInheritanceAlreadyTriggeredwhen the chain already triggered the plan. Today that is recorded asTRIGGER_FAILED; decoding the contract error code out ofresultXdrand treating it as success would be a nice refinement.trigger_inheritancefreezes loans and records trigger state; the anchor off-ramp instellar_anchor.rsis still driven separately byPOST /api/payouts.