Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ jobs:
POSTGRES_PASSWORD: password
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout code
Expand All @@ -52,6 +57,12 @@ jobs:

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

- name: Build
run: cargo build --release
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
-- Issue #820: persist proof-of-life inactivity timers for the watchdog worker.
--
-- `plans.last_ping` is a BIGINT of Unix seconds (see the core tables
-- migration) and the API binds it as such, so the deadline is derived with
-- to_timestamp() rather than by adding an INTERVAL to a timestamp.

ALTER TABLE plans
ADD COLUMN IF NOT EXISTS status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE';

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

ALTER TABLE plans
Expand All @@ -15,9 +20,7 @@ ALTER TABLE plans

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

CREATE INDEX IF NOT EXISTS idx_plans_inactivity_deadline_claimable
ON plans (inactivity_deadline_at)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
DROP TABLE payout_logs;
DROP TABLE beneficiaries;
DROP TABLE plans;
DROP INDEX IF EXISTS payout_logs_beneficiary_id_idx;
DROP TABLE IF EXISTS payout_logs;
Original file line number Diff line number Diff line change
@@ -1,30 +1,16 @@
CREATE TABLE plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
apy_rate_bps INT NOT NULL,
min_amount NUMERIC(19, 4) NOT NULL,
max_amount NUMERIC(19, 4),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE beneficiaries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_wallet_address TEXT NOT NULL,
beneficiary_wallet_address TEXT NOT NULL,
share_percentage NUMERIC(5, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Issue #1017: payout audit log.
--
-- `plans` and `beneficiaries` are already created by the core tables
-- migration, and re-creating them here aborted the whole migration chain
-- ("relation \"plans\" already exists"), so this migration now only adds the
-- table that was genuinely new. It references the existing `beneficiaries`.

CREATE TABLE payout_logs (
CREATE TABLE IF NOT EXISTS payout_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
beneficiary_id UUID NOT NULL REFERENCES beneficiaries(id),
beneficiary_id UUID NOT NULL REFERENCES beneficiaries (id) ON DELETE CASCADE,
amount NUMERIC(19, 4) NOT NULL,
payout_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
transaction_hash TEXT NOT NULL
);

CREATE INDEX beneficiaries_owner_wallet_idx ON beneficiaries (owner_wallet_address);
CREATE INDEX payout_logs_beneficiary_id_idx ON payout_logs (beneficiary_id);
CREATE INDEX IF NOT EXISTS payout_logs_beneficiary_id_idx ON payout_logs (beneficiary_id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
DROP INDEX IF EXISTS idx_plans_trigger_in_flight;
DROP INDEX IF EXISTS plans_onchain_plan_id_key;

ALTER TABLE plans
DROP CONSTRAINT IF EXISTS plans_onchain_plan_id_non_negative;

ALTER TABLE plans
DROP COLUMN IF EXISTS last_trigger_error,
DROP COLUMN IF EXISTS trigger_started_at,
DROP COLUMN IF EXISTS trigger_attempts,
DROP COLUMN IF EXISTS trigger_tx_hash,
DROP COLUMN IF EXISTS onchain_plan_id;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- Issue #1039: the inactivity watchdog now executes the payout on-chain before
-- flipping a plan to TRIGGERED, so it needs the Soroban plan id to call
-- `trigger_inheritance` with, plus somewhere to record the attempt.

ALTER TABLE plans
ADD COLUMN IF NOT EXISTS onchain_plan_id BIGINT,
ADD COLUMN IF NOT EXISTS trigger_tx_hash TEXT,
ADD COLUMN IF NOT EXISTS trigger_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS trigger_started_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS last_trigger_error TEXT;

ALTER TABLE plans
ADD CONSTRAINT plans_onchain_plan_id_non_negative
CHECK (onchain_plan_id IS NULL OR onchain_plan_id >= 0)
NOT VALID;

ALTER TABLE plans
VALIDATE CONSTRAINT plans_onchain_plan_id_non_negative;

-- One database plan per on-chain plan: triggering the same contract plan from
-- two rows would double-submit the payout.
CREATE UNIQUE INDEX IF NOT EXISTS plans_onchain_plan_id_key
ON plans (onchain_plan_id)
WHERE onchain_plan_id IS NOT NULL;

-- Lets the watchdog cheaply find submissions left in flight by a crashed worker.
CREATE INDEX IF NOT EXISTS idx_plans_trigger_in_flight
ON plans (trigger_started_at)
WHERE status = 'TRIGGERING';
33 changes: 22 additions & 11 deletions backend/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub struct Plan {
pub earn_yield: bool,
pub yield_rate_bps: u32,
pub is_active: bool,
/// Identifier of this plan inside the Soroban inheritance contract.
/// Required for the inactivity watchdog to trigger the payout on-chain;
/// plans created without one can only be triggered manually.
pub onchain_plan_id: Option<u64>,
}

pub struct AppState {
Expand Down Expand Up @@ -312,6 +316,7 @@ pub struct PlanRow {
pub yield_rate_bps: i32,
pub accrued_yield: rust_decimal::Decimal,
pub created_at: chrono::DateTime<chrono::Utc>,
pub onchain_plan_id: Option<i64>,
}

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
Expand Down Expand Up @@ -345,6 +350,7 @@ pub struct PlanResponse {
pub yield_rate_bps: i32,
pub accrued_yield: f64,
pub created_at: chrono::DateTime<chrono::Utc>,
pub onchain_plan_id: Option<i64>,
pub beneficiaries: Vec<BeneficiaryResponse>,
}

Expand Down Expand Up @@ -432,6 +438,7 @@ fn plan_row_to_response(row: PlanRow, beneficiaries: Vec<BeneficiaryResponse>) -
yield_rate_bps: row.yield_rate_bps,
accrued_yield,
created_at: row.created_at,
onchain_plan_id: row.onchain_plan_id,
beneficiaries,
}
}
Expand Down Expand Up @@ -622,9 +629,10 @@ async fn create_plan(
accrued_yield,
last_ping,
is_active,
status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
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
status,
onchain_plan_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
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
"#
)
.bind(&payload.owner)
Expand All @@ -638,6 +646,7 @@ async fn create_plan(
.bind(payload.last_ping)
.bind(payload.is_active)
.bind("ACTIVE")
.bind(payload.onchain_plan_id.map(|id| id as i64))
.fetch_one(&mut *tx)
.await {
Ok(row) => row,
Expand Down Expand Up @@ -715,6 +724,7 @@ async fn create_plan(
yield_rate_bps: plan_row.yield_rate_bps,
accrued_yield: 0.0, // No yield accrued at creation
created_at: plan_row.created_at,
onchain_plan_id: plan_row.onchain_plan_id,
beneficiaries: inserted_beneficiaries,
};

Expand Down Expand Up @@ -787,7 +797,7 @@ async fn update_plan(

// 3. Check if plan exists
let _plan_row = match sqlx::query_as::<_, PlanRow>(
"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"
"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"
)
.bind(plan_id)
.fetch_optional(&mut *tx)
Expand Down Expand Up @@ -903,7 +913,7 @@ async fn update_plan(

// 7. Fetch updated plan with beneficiaries
let updated_plan_row = match sqlx::query_as::<_, PlanRow>(
"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"
"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"
)
.bind(plan_id)
.fetch_one(&state.db_pool)
Expand Down Expand Up @@ -960,6 +970,7 @@ async fn update_plan(
yield_rate_bps: updated_plan_row.yield_rate_bps,
accrued_yield: 0.0,
created_at: updated_plan_row.created_at,
onchain_plan_id: updated_plan_row.onchain_plan_id,
beneficiaries: inserted_beneficiaries,
};

Expand Down Expand Up @@ -1012,7 +1023,7 @@ async fn get_plans(
r#"
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
status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id
FROM plans
WHERE owner_address = $1
ORDER BY created_at DESC
Expand Down Expand Up @@ -1040,7 +1051,7 @@ async fn get_plans(
r#"
SELECT DISTINCT p.id, p.owner_address, p.token_address, p.amount,
p.grace_period, p.grace_period_seconds, p.earn_yield,
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at, p.onchain_plan_id
FROM plans p
INNER JOIN beneficiaries b ON b.plan_id = p.id
WHERE b.wallet_address = $1
Expand Down Expand Up @@ -1070,7 +1081,7 @@ async fn get_plans(
r#"
SELECT DISTINCT p.id, p.owner_address, p.token_address, p.amount,
p.grace_period, p.grace_period_seconds, p.earn_yield,
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at
p.last_ping, p.is_active, p.status, p.yield_rate_bps, p.accrued_yield, p.created_at, p.onchain_plan_id
FROM plans p
LEFT JOIN beneficiaries b ON b.plan_id = p.id
WHERE p.owner_address = $1 OR b.wallet_address = $2
Expand Down Expand Up @@ -1100,7 +1111,7 @@ async fn get_plans(
r#"
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
status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id
FROM plans
ORDER BY created_at DESC
"#,
Expand Down Expand Up @@ -1287,7 +1298,7 @@ async fn trigger_payout(

// 2. Fetch the active plan for the owner
let plan = match sqlx::query_as::<_, PlanRow>(
"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",
"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",
)
.bind(&payload.owner)
.fetch_optional(&mut *tx)
Expand Down Expand Up @@ -2020,7 +2031,7 @@ pub async fn get_plan_report(
r#"
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
status, yield_rate_bps, accrued_yield, created_at, onchain_plan_id
FROM plans WHERE id = $1
"#,
)
Expand Down
1 change: 1 addition & 0 deletions backend/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ mod tests {
yield_rate_bps: 500,
accrued_yield: 42.5,
created_at: Utc::now(),
onchain_plan_id: Some(7),
beneficiaries: vec![BeneficiaryResponse {
id: Uuid::new_v4(),
plan_id: Uuid::new_v4(),
Expand Down
7 changes: 7 additions & 0 deletions backend/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use rust_decimal::prelude::FromPrimitive;
use rust_decimal::Decimal;

use crate::stellar_submit::SorobanConfig;

pub struct Config {
pub port: u16,
pub database_url: String,
Expand All @@ -12,6 +14,10 @@ pub struct Config {
pub stellar_horizon_url: String,
pub anchor_api_url: String,
pub fiat_daily_limit_default: rust_decimal::Decimal,
/// Soroban contract settings used to execute inheritance payouts on-chain.
/// `None` when the deployment has not configured a signer, in which case
/// the inactivity watchdog only updates PostgreSQL.
pub soroban: Option<SorobanConfig>,
}

impl Config {
Expand Down Expand Up @@ -60,6 +66,7 @@ impl Config {
stellar_horizon_url,
anchor_api_url,
fiat_daily_limit_default,
soroban: SorobanConfig::from_env(),
})
}
}
Loading
Loading