Skip to content

Commit 027b1d5

Browse files
committed
Gate bare mint/burn behind a positive pool signal, and scope LP shares by network
Closes the over-capture: `mint` and `burn` were accepted as LP-share events from every contract. Those are generic SEP-41 events that every token emits, so every USDC mint and every ordinary token burn was being written into LpShareTransfer with poolId = that token's contract, double-stored beside its own token-transfer row. `deposit`/`withdraw` are pool-specific and stay unconditional — emitting them is itself a claim to be a pool. Bare mint/burn is now only honoured from a contract already established as a pool. A contract joins that set by: - emitting an explicit deposit/withdraw, in this batch or a previous one (parseLpShareEvents scans the batch for the explicit dialect before decoding anything, so a pool's first deposit and the mint alongside it are not split by their order within one batch); - already appearing as a poolId in the table, loaded at loop start so a restart does not forget and silently stop recording bare events; - being named in LP_POOL_CONTRACT_IDS, for a pool that only ever emits the bare dialect. Tests: a plain token mint from an unknown contract is not recorded, the explicit dialect is accepted from anyone, a same-batch deposit promotes the contract, and a carried-over pool keeps working. Network scoping, which this branch predates: - LpShareTransfer gains a `network` column with @@unique([network, eventId]) in place of the global unique, and network-leading indexes. A global unique on eventId would let a testnet event suppress its mainnet namesake, and createMany's skipDuplicates would swallow it without a trace. - The migration is renumbered to 20260901130000, after add_network, for the same reason as the tombstone one: add_network back-filled the tables that existed when it was written, and this was not one of them. - upsertLpShareTransfers takes the network; rollbackToLedger deletes LP rows network-scoped alongside the others. Merge fixes: dropped the duplicate pre-network ContractTombstone model and the superseded June tombstone migration (both arrived via the #155 stack, which is now on main), and repaired a stale `totalIndexed` reference that survived the merge as a bare identifier. tsc clean; full suite 382 passed.
2 parents 039008c + 17e7b98 commit 027b1d5

75 files changed

Lines changed: 7742 additions & 680 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,15 @@ POLL_INTERVAL_MS=6000
5959
# Example (watch XLM + USDC on mainnet):
6060
# SAC_CONTRACT_IDS=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC,CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA
6161
#
62+
# Per-network watch lists (for dual-network deployments when NETWORKS=testnet,mainnet):
63+
# SAC_CONTRACT_IDS_TESTNET=CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4
64+
# SAC_CONTRACT_IDS_MAINNET=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
65+
#
6266
# Soroban RPC's getEvents accepts multiple contract IDs in a single request —
6367
# there is no N+1 query penalty for watching multiple contracts.
6468
SAC_CONTRACT_IDS=
69+
SAC_CONTRACT_IDS_TESTNET=
70+
SAC_CONTRACT_IDS_MAINNET=
6571

6672
# ─── NFT Contract IDs ───────────────────────────────────────────────────────
6773
# Comma-separated list of CAP-46 NFT contract IDs to explicitly watch.
@@ -70,7 +76,13 @@ SAC_CONTRACT_IDS=
7076
#
7177
# Example (watch a known testnet NFT contract):
7278
# NFT_CONTRACT_IDS=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
79+
#
80+
# Per-network NFT watch lists (for dual-network deployments):
81+
# NFT_CONTRACT_IDS_TESTNET=
82+
# NFT_CONTRACT_IDS_MAINNET=
7383
NFT_CONTRACT_IDS=
84+
NFT_CONTRACT_IDS_TESTNET=
85+
NFT_CONTRACT_IDS_MAINNET=
7486

7587
# Legacy alias — CONTRACT_IDS is still accepted for backwards-compatibility.
7688
# SAC_CONTRACT_IDS takes precedence when both are set.
@@ -96,3 +108,24 @@ CACHE_KEY_PREFIX="wraith:cache:"
96108
# Per-route TTLs in milliseconds.
97109
CACHE_TTL_POPULAR_MS=60000
98110
CACHE_TTL_SEARCH_MS=15000
111+
112+
# ─── Contract tombstones ────────────────────────────────────────────────────
113+
# How many poll cycles between contract-liveness checks (#137). Each check
114+
# costs one RPC call per unique watched contract, so this is deliberately far
115+
# rarer than a poll: a contract TTL is measured in weeks, and noticing an
116+
# expiry ten minutes late costs nothing. Set to 0 to disable the check.
117+
# Default: 100 cycles (~10 min at the default 6s poll interval).
118+
TOMBSTONE_CHECK_EVERY_CYCLES=100
119+
120+
# ─── LP-share tracking ──────────────────────────────────────────────────────
121+
# Contracts to treat as liquidity pools even before one of their explicit
122+
# deposit/withdraw events has been observed (#136).
123+
#
124+
# Bare SEP-41 mint/burn is only recorded as an LP-share movement for contracts
125+
# already established as pools — otherwise every stablecoin mint on the chain
126+
# would land in LpShareTransfer as a liquidity deposit. A pool normally
127+
# identifies itself by emitting deposit/withdraw; list one here only if it
128+
# emits nothing but bare mint/burn.
129+
LP_POOL_CONTRACT_IDS=
130+
LP_POOL_CONTRACT_IDS_TESTNET=
131+
LP_POOL_CONTRACT_IDS_MAINNET=

.github/workflows/canary.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
name: Canary Deployment
22

3+
# Manual/dispatch only. This workflow deploys a canary and polls it for a 5m
4+
# observation window (canary-monitor -> ops/canary/monitor.sh against
5+
# secrets.CANARY_URL); without a live deployment target + CANARY_URL secret the
6+
# monitor always fails and trips the rollback job. Re-add `push: branches:[main]`
7+
# once the production canary environment and CANARY_URL secret are configured.
38
on:
4-
push:
5-
branches: [main]
69
workflow_dispatch:
710
inputs:
811
image_tag:

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
integration:
2626
name: Integration tests
2727
runs-on: ubuntu-latest
28+
timeout-minutes: 25
2829
steps:
2930
- uses: actions/checkout@v4
3031
- uses: actions/setup-node@v4

.github/workflows/db-backup.yml

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
name: Nightly DB backup
2+
3+
# Nightly logical backup of the mainnet and testnet Postgres databases, so a
4+
# lost or capped managed instance has a minutes-not-days restore path. See
5+
# docs/backup-restore.md for the restore procedure. This is the durability
6+
# fallback noted in docs/DUAL_NETWORK.md (#165) — Wraith's data is also
7+
# re-derivable by re-indexing from chain, so this backup exists to make that
8+
# unnecessary, not because it's the only copy.
9+
10+
on:
11+
schedule:
12+
# Nightly at 03:00 UTC.
13+
- cron: "0 3 * * *"
14+
workflow_dispatch:
15+
inputs:
16+
network:
17+
description: "Network to back up"
18+
required: false
19+
default: "both"
20+
type: choice
21+
options:
22+
- both
23+
- testnet
24+
- mainnet
25+
26+
jobs:
27+
backup-testnet:
28+
name: pg_dump (testnet)
29+
if: ${{ github.event.inputs.network == '' || github.event.inputs.network == 'both' || github.event.inputs.network == 'testnet' }}
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 20
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- name: Check secrets are configured
36+
id: check
37+
env:
38+
DATABASE_URL_TESTNET: ${{ secrets.DATABASE_URL_TESTNET }}
39+
BACKUP_PASSPHRASE: ${{ secrets.BACKUP_PASSPHRASE }}
40+
run: |
41+
if [[ -z "$DATABASE_URL_TESTNET" ]]; then
42+
echo "::warning::DATABASE_URL_TESTNET is not set — skipping testnet backup."
43+
echo "configured=false" >> "$GITHUB_OUTPUT"
44+
elif [[ -z "$BACKUP_PASSPHRASE" ]]; then
45+
# Skip rather than dump: this repository is public, so an
46+
# unencrypted artifact would be world-readable. A missing backup is
47+
# recoverable — Wraith re-derives its data by re-indexing — whereas
48+
# a published one is not.
49+
echo "::warning::BACKUP_PASSPHRASE is not set — skipping testnet backup rather than writing an unencrypted dump."
50+
echo "configured=false" >> "$GITHUB_OUTPUT"
51+
else
52+
echo "configured=true" >> "$GITHUB_OUTPUT"
53+
fi
54+
55+
- name: Install postgresql-client
56+
if: steps.check.outputs.configured == 'true'
57+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
58+
59+
- name: Dump and compress
60+
if: steps.check.outputs.configured == 'true'
61+
env:
62+
NETWORK: testnet
63+
DATABASE_URL: ${{ secrets.DATABASE_URL_TESTNET }}
64+
BACKUP_PASSPHRASE: ${{ secrets.BACKUP_PASSPHRASE }}
65+
run: |
66+
chmod +x ops/backup/dump.sh
67+
ops/backup/dump.sh
68+
69+
- name: Upload backup artifact
70+
if: steps.check.outputs.configured == 'true'
71+
uses: actions/upload-artifact@v4
72+
with:
73+
name: wraith-db-backup-testnet-${{ github.run_id }}
74+
# Encrypted files only. The glob is deliberately not `*.dump.gz*` —
75+
# if encryption ever fails to run, this must find nothing and fail the
76+
# job, not quietly publish the cleartext dump.
77+
path: backups/*.dump.gz.gpg
78+
retention-days: 14
79+
if-no-files-found: error
80+
81+
backup-mainnet:
82+
name: pg_dump (mainnet)
83+
if: ${{ github.event.inputs.network == '' || github.event.inputs.network == 'both' || github.event.inputs.network == 'mainnet' }}
84+
runs-on: ubuntu-latest
85+
timeout-minutes: 20
86+
steps:
87+
- uses: actions/checkout@v4
88+
89+
- name: Check secrets are configured
90+
id: check
91+
env:
92+
DATABASE_URL_MAINNET: ${{ secrets.DATABASE_URL_MAINNET }}
93+
BACKUP_PASSPHRASE: ${{ secrets.BACKUP_PASSPHRASE }}
94+
run: |
95+
if [[ -z "$DATABASE_URL_MAINNET" ]]; then
96+
echo "::warning::DATABASE_URL_MAINNET is not set — skipping mainnet backup."
97+
echo "configured=false" >> "$GITHUB_OUTPUT"
98+
elif [[ -z "$BACKUP_PASSPHRASE" ]]; then
99+
# Skip rather than dump: this repository is public, so an
100+
# unencrypted artifact would be world-readable. A missing backup is
101+
# recoverable — Wraith re-derives its data by re-indexing — whereas
102+
# a published one is not.
103+
echo "::warning::BACKUP_PASSPHRASE is not set — skipping mainnet backup rather than writing an unencrypted dump."
104+
echo "configured=false" >> "$GITHUB_OUTPUT"
105+
else
106+
echo "configured=true" >> "$GITHUB_OUTPUT"
107+
fi
108+
109+
- name: Install postgresql-client
110+
if: steps.check.outputs.configured == 'true'
111+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
112+
113+
- name: Dump and compress
114+
if: steps.check.outputs.configured == 'true'
115+
env:
116+
NETWORK: mainnet
117+
DATABASE_URL: ${{ secrets.DATABASE_URL_MAINNET }}
118+
BACKUP_PASSPHRASE: ${{ secrets.BACKUP_PASSPHRASE }}
119+
run: |
120+
chmod +x ops/backup/dump.sh
121+
ops/backup/dump.sh
122+
123+
- name: Upload backup artifact
124+
if: steps.check.outputs.configured == 'true'
125+
uses: actions/upload-artifact@v4
126+
with:
127+
name: wraith-db-backup-mainnet-${{ github.run_id }}
128+
# Encrypted files only. The glob is deliberately not `*.dump.gz*` —
129+
# if encryption ever fails to run, this must find nothing and fail the
130+
# job, not quietly publish the cleartext dump.
131+
path: backups/*.dump.gz.gpg
132+
retention-days: 14
133+
if-no-files-found: error

.github/workflows/typedoc.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ jobs:
99
name: Generate & publish API docs
1010
runs-on: ubuntu-latest
1111
permissions:
12-
contents: read
13-
pages: write
14-
id-token: write
12+
# peaceiris/actions-gh-pages pushes to the gh-pages branch, which needs
13+
# write access to repo contents. The default GITHUB_TOKEN is read-only.
14+
contents: write
1515
steps:
1616
- uses: actions/checkout@v4
1717
- uses: actions/setup-node@v4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ docs/
55
*.env.local
66
bench/results.json
77
.aider*
8+
coverage/

Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# ── Stage 1: Install all deps + build TypeScript ─────────────────────────────
22
FROM node:20-alpine AS builder
33

4+
# Prisma's engines need OpenSSL on Alpine (musl); without it engine loading
5+
# fails with "Error loading shared library" / libssl detection warnings.
6+
RUN apk add --no-cache openssl
7+
48
WORKDIR /app
59
COPY package*.json ./
610
RUN npm ci
@@ -12,6 +16,9 @@ RUN npm run build
1216
# ── Stage 2: Production-only image ────────────────────────────────────────────
1317
FROM node:20-alpine AS runner
1418

19+
# OpenSSL for the Prisma query/schema engines at runtime (see builder stage).
20+
RUN apk add --no-cache openssl
21+
1522
WORKDIR /app
1623

1724
# The app runs Prisma schema sync on startup, so the runtime image needs the
@@ -23,4 +30,9 @@ RUN npm ci
2330
COPY --from=builder /app/dist ./dist
2431
COPY --from=builder /app/prisma ./prisma
2532

33+
# Generate the Prisma client into this stage's node_modules — the fresh `npm ci`
34+
# above installs @prisma/client but not the generated client, so the app crashed
35+
# on boot with "@prisma/client did not initialize yet".
36+
RUN npm run db:generate
37+
2638
CMD ["node", "dist/index.js"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Wraith contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)