Skip to content

feat(backend): connect the inactivity watchdog to on-chain Soroban payout execution - #1045

Merged
ONEONUORA merged 3 commits into
Fracverse:masterfrom
Yunusabdul38:issue-1039-watchdog-onchain
Aug 20, 2026
Merged

ONEONUORA merged 3 commits into
Fracverse:masterfrom
Yunusabdul38:issue-1039-watchdog-onchain

Conversation

@Yunusabdul38

Copy link
Copy Markdown
Contributor

Closes #1039

What was missing

InactivityWatchdogService swept expired plans, flipped their status to TRIGGERED in PostgreSQL and enqueued a plan.triggered webhook — but never touched the chain. No inheritance was unlocked and no off-ramp payout was initiated. StellarSubmitClient could 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 invocation

StellarSubmitClient gains an optional Soroban context, configured from the environment:

Variable Required Default
SOROBAN_RPC_URL yes
INHERITANCE_CONTRACT_ID yes
STELLAR_SIGNER_SECRET yes
STELLAR_NETWORK_PASSPHRASE no testnet
SOROBAN_POLL_INTERVAL_MS no 1000
SOROBAN_POLL_TIMEOUT_SECS no 60

with_soroban() validates the S… and C… strkeys immediately, so a misconfigured signer fails at startup rather than on the first expired plan (main.rs exits rather than running with a half-configured signer).

invoke_contract() runs the full flow: read the signer's sequence from Horizon → build an InvokeHostFunction transaction → simulateTransaction over JSON-RPC for the footprint, minResourceFee and auth entries → re-assemble, sign for the network → sendTransaction → poll getTransaction until it lands, fails, or times out. trigger_inheritance(plan_id) wraps it for this contract's trigger_inheritance(caller, plan_id).

Events are decoded out of TransactionMeta V3 and V4 (protocol 23 moved contract events onto the operation meta), with helpers to match on topics and read a u64 out of a struct event payload.

backend/src/inactivity_watchdog.rs — two-phase sweep

  1. Expired plans are claimed into a new TRIGGERING status under the existing advisory lock, so a concurrent worker cannot pick them up.
  2. Each is triggered on-chain outside the transaction. A plan reaches TRIGGERED only if the contract's INHERIT/TRIGGER event is present and carries the plan_id we asked for. Anything else — no event, wrong plan, submission rejected — lands in TRIGGER_FAILED with the reason stored in last_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, increment inheritx_watchdog_onchain_triggers_total{outcome="failure"}, and enqueue a new plan.trigger_failed webhook. plan.triggered now carries trigger_tx_hash and onchain_plan_id.

Crash recovery: a plan left in TRIGGERING by 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

  • Migration 20260820000000 adds onchain_plan_id (the contract's u64 plan id, unique where present, so one DB plan cannot double-submit), trigger_tx_hash, trigger_attempts, trigger_started_at, last_trigger_error.
  • POST /api/plans accepts an optional onchain_plan_id and 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.rs builds 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, and main.rs only logs a warning, so this was invisible:

  1. 20260624000000_add_inactivity_watchdog_fields computed inactivity_deadline_at as last_ping + INTERVAL, but last_ping is a BIGINT of Unix seconds — operator does not exist: bigint + interval. This is the watchdog's own migration, so on a fresh database status and inactivity_deadline_at do not exist and every sweep errors.
  2. 20260628000000 was used by two migrations (add_kyc_records and create_admins) — sqlx rejects the duplicate version with VersionMismatch.
  3. 20260729000000_create_plans_beneficiaries_payouts re-creates plans and beneficiaries, which already exist — relation "plans" already exists.

The first commit repairs all three: derive the deadline with to_timestamp(), renumber create_admins to 20260628000001 (order preserved), and reduce the third migration to the only table that was actually new (payout_logs, now referencing the existing beneficiaries). 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

  • Unit: network-id/signature-hint derivation, event decoding from V3/V4 meta, topic + contract + plan_id matching, strkey config validation, retry classification, backoff doubling and capping.
  • Database-backed (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 no onchain_plan_id, stale re-claim and fresh in-flight plans.
  • CI's PostgreSQL service was declared but unused and had no health check. Added one, and set WATCHDOG_TEST_DATABASE_URL on the test step so the new tests actually run. Deliberately not DATABASE_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) and cargo build --release all clean.

Follow-ups (not in scope here)

  • trigger_inheritance returns InheritanceAlreadyTriggered when the chain already triggered the plan. Today that is recorded as TRIGGER_FAILED; decoding the contract error code out of resultXdr and treating it as success would be a nice refinement.
  • The contract's trigger_inheritance freezes loans and records trigger state; the anchor off-ramp in stellar_anchor.rs is still driven separately by POST /api/payouts.

`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 ONEONUORA left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution @Yunusabdul38

@ONEONUORA
ONEONUORA merged commit 226cd45 into Fracverse:master Aug 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

backend: Connect Inactivity Watchdog to On-Chain Soroban Payout Execution

2 participants