Skip to content

Add unique constraint on onchain_subscription_id/onchain_escrow_id to prevent double-execution - #61

Merged
abayomicornelius merged 6 commits into
StellarSend:mainfrom
Temi-suwa18:fix/unique-onchain-ids
Aug 16, 2026
Merged

Add unique constraint on onchain_subscription_id/onchain_escrow_id to prevent double-execution#61
abayomicornelius merged 6 commits into
StellarSend:mainfrom
Temi-suwa18:fix/unique-onchain-ids

Conversation

@Temi-suwa18

Copy link
Copy Markdown
Contributor

Summary

Closes #56

subscriptions.onchain_subscription_id and escrows.onchain_escrow_id had no uniqueness constraint at either the DB or application layer, despite both being used as the literal on-chain contract argument identifying which real on-chain entity a keeper (subscriptions) or client-signed action (escrows) targets. If two DB rows ever shared the same onchain_subscription_id, SubscriptionService::run_due_executions's FOR UPDATE SKIP LOCKED sweep would select both independently, invoking execute_subscription twice against the same real on-chain subscription in a single keeper pass.

Changes

  • Migration 011_unique_onchain_ids.sql: partial UNIQUE index on subscriptions.onchain_subscription_id and escrows.onchain_escrow_id, each WHERE ... IS NOT NULL (both columns are nullable — a row can be created before its on-chain id is known, and multiple NULLs must remain permitted). If either table already has pre-existing duplicate non-null values, the migration fails to apply rather than silently installing a constraint that doesn't hold — surfacing the conflict for manual review, per the issue's own guidance.
  • SubscriptionService::create / EscrowService::create: now explicitly catch the unique-violation (via an is_unique_violation helper mirroring the existing pattern in services::batch) and return AppError::Conflict (409), instead of falling through to a generic 500.
  • Tests (7 total, #[ignore]d #[tokio::test]s requiring a real Postgres via DATABASE_URL, same convention as the existing reconciliation::db_tests):
    • Duplicate onchain_subscription_id / onchain_escrow_id on create is rejected with Conflict.
    • NULL ids remain unrestricted (multiple rows with no on-chain id yet).
    • Informational (not a required fix per the issue): with the new index temporarily dropped inside a transaction that's always rolled back (reproducing the pre-fix schema), two subscription rows sharing one onchain_subscription_id are both selected by run_due_executions's exact due-selection query — demonstrating why the constraint needs to exist at the schema level.

Also fixed: migration 005 never applied cleanly (pre-existing, unrelated to #56)

While setting up a local Postgres to actually run and verify the new tests, migrations/005_add_indexes.sql turned out to reference a payments table with from_address/to_address columns that never existed under those names — the real table (001_initial.sql) is transactions, with source_account/destination_account. This blocked the entire migration chain from applying to any fresh database. Fixed to match the real schema (own commit, clearly separated from the #56 work).

Test plan

  • All 11 migrations apply cleanly to a fresh Postgres 16 database.
  • cargo test --bin stellarsend -- --include-ignored (real Postgres) — 34/34 relevant tests passing, including all 7 new ones. (2 pre-existing, unrelated reconciliation::db_tests failures reproduce identically on a clean upstream/main checkout before any of these changes — a missing seed-user FK and an environment-dependent assertion — confirmed out of scope.)
  • cargo clippy --all-targets — no new warnings (one pre-existing warning in untouched escrow.rs code).
  • cargo fmt --check — no new formatting issues introduced (pre-existing drift elsewhere in the repo, untouched).
  • This repo's actual CI (cargo-deny check advisories / cargo-deny check bans licenses sources, per .github/workflows/deny.yml) — both pass locally; no dependency changes in this PR (Cargo.toml/Cargo.lock untouched).

Pre-existing bug, unrelated to StellarSend#56 but blocking the entire migration
chain from ever applying cleanly to a fresh database: migration 005
indexed a 'payments' table with from_address/to_address columns that
never existed under those names. The actual table (001_initial.sql) is
'transactions', with source_account/destination_account. Fixed to
match the real schema.
…id (StellarSend#56)

Neither subscriptions.onchain_subscription_id nor escrows.onchain_escrow_id
had a uniqueness constraint, despite both being used as the literal
on-chain contract argument identifying which real on-chain entity a
keeper (subscriptions) or client-signed action (escrows) targets. Two
DB rows sharing the same onchain_subscription_id would both be
selected by run_due_executions's FOR UPDATE SKIP LOCKED sweep,
double-executing the same real subscription in a single keeper pass.

Partial (not plain UNIQUE) since both columns are nullable — a row can
be created before its on-chain id is known, and multiple NULLs must
remain permitted. If either table already has pre-existing duplicate
non-null values, this migration fails to apply rather than silently
installing a constraint that doesn't hold, surfacing the conflict for
manual review instead of masking it.
…iptionService::create (StellarSend#56)

Previously a unique-violation on the new partial index would fall
through the generic sqlx::Error -> AppError::Database mapping (500).
Explicitly catches it via is_unique_violation (mirroring
services::batch's existing pattern) and returns AppError::Conflict
instead, per StellarSend#56's acceptance criteria.
…cutions selection risk (StellarSend#56)

Three DB-integration tests (real Postgres, #[ignore]d per the existing
reconciliation::db_tests convention):
- creating a second subscription with an already-used
  onchain_subscription_id is rejected with Conflict.
- NULL onchain_subscription_id remains unrestricted (multiple rows
  with no on-chain id yet).
- informational: with the new unique index temporarily dropped inside
  a rolled-back transaction (reproducing the pre-fix schema), two rows
  sharing one onchain_subscription_id are both selected by
  run_due_executions's exact due-selection query — motivating why the
  constraint needs to exist at the schema level, not just detected
  keeper-side. Not a required fix in this issue, per its own acceptance
  criteria.
…e::create (StellarSend#56)

Same fix as SubscriptionService::create: explicitly catches the new
partial index's unique-violation via is_unique_violation and returns
AppError::Conflict instead of falling through to a generic 500.
StellarSend#56)

Two DB-integration tests (real Postgres, #[ignore]d, same convention as
subscription::db_tests): creating a second escrow with an already-used
onchain_escrow_id is rejected with Conflict, and NULL onchain_escrow_id
remains unrestricted.
@abayomicornelius

Copy link
Copy Markdown
Contributor

All ci passed

@abayomicornelius
abayomicornelius merged commit 57f5132 into StellarSend:main Aug 16, 2026
2 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.

No unique constraint on onchain_subscription_id/onchain_escrow_id lets two DB rows reference the same on-chain entity, risking double-execution

2 participants