Skip to content

Commit 226cd45

Browse files
authored
feat(backend): connect the inactivity watchdog to on-chain Soroban payout execution (#1045)
* fix(backend): repair the migration chain so it applies from scratch `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`. * feat(backend): connect the inactivity watchdog to on-chain Soroban payout 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). * fix(backend): only count a watchdog trigger as an on-chain success when 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.
1 parent 1b41650 commit 226cd45

17 files changed

Lines changed: 1935 additions & 91 deletions

.github/workflows/backend.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ jobs:
2828
POSTGRES_PASSWORD: password
2929
ports:
3030
- 5432:5432
31+
options: >-
32+
--health-cmd pg_isready
33+
--health-interval 10s
34+
--health-timeout 5s
35+
--health-retries 5
3136
3237
steps:
3338
- name: Checkout code
@@ -52,6 +57,12 @@ jobs:
5257

5358
- name: Run tests
5459
run: cargo test
60+
env:
61+
# Opts the inactivity watchdog into the database-backed tests, which
62+
# also verify the migration chain applies from scratch. Deliberately
63+
# not DATABASE_URL: other tests assert behaviour when the database is
64+
# unreachable.
65+
WATCHDOG_TEST_DATABASE_URL: postgres://postgres:password@localhost:5432/test
5566

5667
- name: Build
5768
run: cargo build --release

backend/migrations/20260624000000_add_inactivity_watchdog_fields.sql

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
-- Issue #820: persist proof-of-life inactivity timers for the watchdog worker.
2+
--
3+
-- `plans.last_ping` is a BIGINT of Unix seconds (see the core tables
4+
-- migration) and the API binds it as such, so the deadline is derived with
5+
-- to_timestamp() rather than by adding an INTERVAL to a timestamp.
6+
7+
ALTER TABLE plans
8+
ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE';
29

310
ALTER TABLE plans
4-
ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
5-
ADD COLUMN IF NOT EXISTS last_ping TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
611
ADD COLUMN IF NOT EXISTS grace_period_seconds BIGINT NOT NULL DEFAULT 7776000;
712

813
ALTER TABLE plans
@@ -15,9 +20,7 @@ ALTER TABLE plans
1520

1621
ALTER TABLE plans
1722
ADD COLUMN IF NOT EXISTS inactivity_deadline_at TIMESTAMP WITH TIME ZONE
18-
GENERATED ALWAYS AS (
19-
last_ping + (grace_period_seconds::double precision * INTERVAL '1 second')
20-
) STORED;
23+
GENERATED ALWAYS AS (to_timestamp(last_ping + grace_period_seconds)) STORED;
2124

2225
CREATE INDEX IF NOT EXISTS idx_plans_inactivity_deadline_claimable
2326
ON plans (inactivity_deadline_at)

backend/migrations/20260628000000_create_admins.down.sql renamed to backend/migrations/20260628000001_create_admins.down.sql

File renamed without changes.

backend/migrations/20260628000000_create_admins.up.sql renamed to backend/migrations/20260628000001_create_admins.up.sql

File renamed without changes.
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
DROP TABLE payout_logs;
2-
DROP TABLE beneficiaries;
3-
DROP TABLE plans;
1+
DROP INDEX IF EXISTS payout_logs_beneficiary_id_idx;
2+
DROP TABLE IF EXISTS payout_logs;
Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,16 @@
1-
CREATE TABLE plans (
2-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3-
name TEXT NOT NULL,
4-
description TEXT,
5-
apy_rate_bps INT NOT NULL,
6-
min_amount NUMERIC(19, 4) NOT NULL,
7-
max_amount NUMERIC(19, 4),
8-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
10-
);
11-
12-
CREATE TABLE beneficiaries (
13-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
14-
owner_wallet_address TEXT NOT NULL,
15-
beneficiary_wallet_address TEXT NOT NULL,
16-
share_percentage NUMERIC(5, 2) NOT NULL,
17-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
18-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
19-
);
1+
-- Issue #1017: payout audit log.
2+
--
3+
-- `plans` and `beneficiaries` are already created by the core tables
4+
-- migration, and re-creating them here aborted the whole migration chain
5+
-- ("relation \"plans\" already exists"), so this migration now only adds the
6+
-- table that was genuinely new. It references the existing `beneficiaries`.
207

21-
CREATE TABLE payout_logs (
8+
CREATE TABLE IF NOT EXISTS payout_logs (
229
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23-
beneficiary_id UUID NOT NULL REFERENCES beneficiaries(id),
10+
beneficiary_id UUID NOT NULL REFERENCES beneficiaries (id) ON DELETE CASCADE,
2411
amount NUMERIC(19, 4) NOT NULL,
2512
payout_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2613
transaction_hash TEXT NOT NULL
2714
);
2815

29-
CREATE INDEX beneficiaries_owner_wallet_idx ON beneficiaries (owner_wallet_address);
30-
CREATE INDEX payout_logs_beneficiary_id_idx ON payout_logs (beneficiary_id);
16+
CREATE INDEX IF NOT EXISTS payout_logs_beneficiary_id_idx ON payout_logs (beneficiary_id);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
DROP INDEX IF EXISTS idx_plans_trigger_in_flight;
2+
DROP INDEX IF EXISTS plans_onchain_plan_id_key;
3+
4+
ALTER TABLE plans
5+
DROP CONSTRAINT IF EXISTS plans_onchain_plan_id_non_negative;
6+
7+
ALTER TABLE plans
8+
DROP COLUMN IF EXISTS last_trigger_error,
9+
DROP COLUMN IF EXISTS trigger_started_at,
10+
DROP COLUMN IF EXISTS trigger_attempts,
11+
DROP COLUMN IF EXISTS trigger_tx_hash,
12+
DROP COLUMN IF EXISTS onchain_plan_id;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Issue #1039: the inactivity watchdog now executes the payout on-chain before
2+
-- flipping a plan to TRIGGERED, so it needs the Soroban plan id to call
3+
-- `trigger_inheritance` with, plus somewhere to record the attempt.
4+
5+
ALTER TABLE plans
6+
ADD COLUMN IF NOT EXISTS onchain_plan_id BIGINT,
7+
ADD COLUMN IF NOT EXISTS trigger_tx_hash TEXT,
8+
ADD COLUMN IF NOT EXISTS trigger_attempts INTEGER NOT NULL DEFAULT 0,
9+
ADD COLUMN IF NOT EXISTS trigger_started_at TIMESTAMP WITH TIME ZONE,
10+
ADD COLUMN IF NOT EXISTS last_trigger_error TEXT;
11+
12+
ALTER TABLE plans
13+
ADD CONSTRAINT plans_onchain_plan_id_non_negative
14+
CHECK (onchain_plan_id IS NULL OR onchain_plan_id >= 0)
15+
NOT VALID;
16+
17+
ALTER TABLE plans
18+
VALIDATE CONSTRAINT plans_onchain_plan_id_non_negative;
19+
20+
-- One database plan per on-chain plan: triggering the same contract plan from
21+
-- two rows would double-submit the payout.
22+
CREATE UNIQUE INDEX IF NOT EXISTS plans_onchain_plan_id_key
23+
ON plans (onchain_plan_id)
24+
WHERE onchain_plan_id IS NOT NULL;
25+
26+
-- Lets the watchdog cheaply find submissions left in flight by a crashed worker.
27+
CREATE INDEX IF NOT EXISTS idx_plans_trigger_in_flight
28+
ON plans (trigger_started_at)
29+
WHERE status = 'TRIGGERING';

backend/src/api.rs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ pub struct Plan {
5353
pub earn_yield: bool,
5454
pub yield_rate_bps: u32,
5555
pub is_active: bool,
56+
/// Identifier of this plan inside the Soroban inheritance contract.
57+
/// Required for the inactivity watchdog to trigger the payout on-chain;
58+
/// plans created without one can only be triggered manually.
59+
pub onchain_plan_id: Option<u64>,
5660
}
5761

5862
pub struct AppState {
@@ -312,6 +316,7 @@ pub struct PlanRow {
312316
pub yield_rate_bps: i32,
313317
pub accrued_yield: rust_decimal::Decimal,
314318
pub created_at: chrono::DateTime<chrono::Utc>,
319+
pub onchain_plan_id: Option<i64>,
315320
}
316321

317322
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
@@ -345,6 +350,7 @@ pub struct PlanResponse {
345350
pub yield_rate_bps: i32,
346351
pub accrued_yield: f64,
347352
pub created_at: chrono::DateTime<chrono::Utc>,
353+
pub onchain_plan_id: Option<i64>,
348354
pub beneficiaries: Vec<BeneficiaryResponse>,
349355
}
350356

@@ -432,6 +438,7 @@ fn plan_row_to_response(row: PlanRow, beneficiaries: Vec<BeneficiaryResponse>) -
432438
yield_rate_bps: row.yield_rate_bps,
433439
accrued_yield,
434440
created_at: row.created_at,
441+
onchain_plan_id: row.onchain_plan_id,
435442
beneficiaries,
436443
}
437444
}
@@ -622,9 +629,10 @@ async fn create_plan(
622629
accrued_yield,
623630
last_ping,
624631
is_active,
625-
status
626-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
627-
RETURNING id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at
632+
status,
633+
onchain_plan_id
634+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
635+
RETURNING id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id
628636
"#
629637
)
630638
.bind(&payload.owner)
@@ -638,6 +646,7 @@ async fn create_plan(
638646
.bind(payload.last_ping)
639647
.bind(payload.is_active)
640648
.bind("ACTIVE")
649+
.bind(payload.onchain_plan_id.map(|id| id as i64))
641650
.fetch_one(&mut *tx)
642651
.await {
643652
Ok(row) => row,
@@ -715,6 +724,7 @@ async fn create_plan(
715724
yield_rate_bps: plan_row.yield_rate_bps,
716725
accrued_yield: 0.0, // No yield accrued at creation
717726
created_at: plan_row.created_at,
727+
onchain_plan_id: plan_row.onchain_plan_id,
718728
beneficiaries: inserted_beneficiaries,
719729
};
720730

@@ -787,7 +797,7 @@ async fn update_plan(
787797

788798
// 3. Check if plan exists
789799
let _plan_row = match sqlx::query_as::<_, PlanRow>(
790-
"SELECT id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at FROM plans WHERE id = $1"
800+
"SELECT id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id FROM plans WHERE id = $1"
791801
)
792802
.bind(plan_id)
793803
.fetch_optional(&mut *tx)
@@ -903,7 +913,7 @@ async fn update_plan(
903913

904914
// 7. Fetch updated plan with beneficiaries
905915
let updated_plan_row = match sqlx::query_as::<_, PlanRow>(
906-
"SELECT id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at FROM plans WHERE id = $1"
916+
"SELECT id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id FROM plans WHERE id = $1"
907917
)
908918
.bind(plan_id)
909919
.fetch_one(&state.db_pool)
@@ -960,6 +970,7 @@ async fn update_plan(
960970
yield_rate_bps: updated_plan_row.yield_rate_bps,
961971
accrued_yield: 0.0,
962972
created_at: updated_plan_row.created_at,
973+
onchain_plan_id: updated_plan_row.onchain_plan_id,
963974
beneficiaries: inserted_beneficiaries,
964975
};
965976

@@ -1012,7 +1023,7 @@ async fn get_plans(
10121023
r#"
10131024
SELECT id, owner_address, token_address, amount, grace_period,
10141025
grace_period_seconds, earn_yield, last_ping, is_active,
1015-
status, yield_rate_bps, accrued_yield, created_at
1026+
status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id
10161027
FROM plans
10171028
WHERE owner_address = $1
10181029
ORDER BY created_at DESC
@@ -1040,7 +1051,7 @@ async fn get_plans(
10401051
r#"
10411052
SELECT DISTINCT p.id, p.owner_address, p.token_address, p.amount,
10421053
p.grace_period, p.grace_period_seconds, p.earn_yield,
1043-
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at
1054+
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at, p.onchain_plan_id
10441055
FROM plans p
10451056
INNER JOIN beneficiaries b ON b.plan_id = p.id
10461057
WHERE b.wallet_address = $1
@@ -1070,7 +1081,7 @@ async fn get_plans(
10701081
r#"
10711082
SELECT DISTINCT p.id, p.owner_address, p.token_address, p.amount,
10721083
p.grace_period, p.grace_period_seconds, p.earn_yield,
1073-
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at
1084+
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at, p.onchain_plan_id
10741085
FROM plans p
10751086
LEFT JOIN beneficiaries b ON b.plan_id = p.id
10761087
WHERE p.owner_address = $1 OR b.wallet_address = $2
@@ -1100,7 +1111,7 @@ async fn get_plans(
11001111
r#"
11011112
SELECT id, owner_address, token_address, amount, grace_period,
11021113
grace_period_seconds, earn_yield, last_ping, is_active,
1103-
status, yield_rate_bps, accrued_yield, created_at
1114+
status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id
11041115
FROM plans
11051116
ORDER BY created_at DESC
11061117
"#,
@@ -1287,7 +1298,7 @@ async fn trigger_payout(
12871298

12881299
// 2. Fetch the active plan for the owner
12891300
let plan = match sqlx::query_as::<_, PlanRow>(
1290-
"SELECT id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at FROM plans WHERE owner_address = $1 AND is_active = true FOR UPDATE",
1301+
"SELECT id, owner_address, token_address, amount, grace_period, grace_period_seconds, earn_yield, last_ping, is_active, status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id FROM plans WHERE owner_address = $1 AND is_active = true FOR UPDATE",
12911302
)
12921303
.bind(&payload.owner)
12931304
.fetch_optional(&mut *tx)
@@ -2020,7 +2031,7 @@ pub async fn get_plan_report(
20202031
r#"
20212032
SELECT id, owner_address, token_address, amount, grace_period,
20222033
grace_period_seconds, earn_yield, last_ping, is_active,
2023-
status, yield_rate_bps, accrued_yield, created_at
2034+
status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id
20242035
FROM plans WHERE id = $1
20252036
"#,
20262037
)

backend/src/cache.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ mod tests {
319319
yield_rate_bps: 500,
320320
accrued_yield: 42.5,
321321
created_at: Utc::now(),
322+
onchain_plan_id: Some(7),
322323
beneficiaries: vec![BeneficiaryResponse {
323324
id: Uuid::new_v4(),
324325
plan_id: Uuid::new_v4(),

0 commit comments

Comments
 (0)