Merge pull request #20 from pezkuwichain/audit-remediation

security: P2P fund-custody audit remediation + backend CI/CD (proven-by-execution)
This commit is contained in:
SatoshiQaziMuhammed
2026-07-25 18:30:22 -07:00
committed by GitHub
63 changed files with 4898 additions and 37503 deletions
+403 -3
View File
@@ -25,6 +25,7 @@ env:
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
REGISTRY: ghcr.io
IMAGE_NAME: pezkuwichain/pwap-web
BACKEND_IMAGE_NAME: pezkuwichain/pwap-indexer
jobs:
# ========================================
@@ -58,12 +59,20 @@ jobs:
- name: Install dependencies
working-directory: ./web
run: npm install
run: npm ci
- name: Run Linter
working-directory: ./web
run: npm run lint
# Typecheck gate. Currently non-blocking (continue-on-error) because the
# existing codebase still has pre-existing type errors. Once the count
# reaches zero, remove `continue-on-error` to make this a hard merge block.
- name: Typecheck
working-directory: ./web
continue-on-error: true
run: npm run typecheck
- name: Run Tests
working-directory: ./web
run: npm run test
@@ -85,6 +94,57 @@ jobs:
name: web-dist
path: web/dist/
# ========================================
# BACKEND - INDEXER SERVICE (lint/smoke/audit)
# No build step exists (plain ESM node service); we lockfile-enforce install,
# syntax-check the entry point, and audit. The audit is currently non-blocking
# because the express@5 + sqlite3(node-gyp) trees carry transitive advisories
# that need a broader dependency upgrade; flip continue-on-error off once the
# tree is clean.
# ========================================
backend:
name: Backend Indexer
runs-on: pwap-runner
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: backend/node_modules
key: ${{ runner.os }}-backend-${{ hashFiles('backend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-backend-
- name: Install dependencies
working-directory: ./backend
run: npm ci
- name: Syntax check (entry point)
working-directory: ./backend
run: node --check src/index.js
# Real, offline test suite (node:test). Uses in-memory sqlite and a
# mocked chain/Supabase — NO live node, NO network. The integration-tests/
# *.live.test.js suites are intentionally EXCLUDED (they require a live
# chain + Supabase and are not run in CI); the `test` script scopes to
# test/*.test.js only.
- name: Run tests (offline)
working-directory: ./backend
run: npm test
- name: npm audit (high + critical, production deps)
working-directory: ./backend
continue-on-error: true
run: npm audit --audit-level=high --omit=dev
# ========================================
# BUILD & PUSH DOCKER IMAGE TO GHCR
# Immutable artifact for audit + rollback (vs ephemeral GHA artifact).
@@ -166,6 +226,77 @@ jobs:
cosign sign --yes "$IMAGE_DIGEST"
echo "✅ Image signed (transparency log: rekor.sigstore.dev)"
# ========================================
# BUILD & PUSH BACKEND (INDEXER) IMAGE TO GHCR
# Runnable Node service image (vs the web static-dist image). SHA-tagged +
# cosign-signed for the same audit/rollback discipline as the web image.
# Gated to main/tags, never fork PRs (needs packages:write + the telegram gate).
# ========================================
build-image-backend:
name: Build & Push Backend Image
runs-on: pwap-runner
needs: [backend, telegram-gate]
if: |
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to == ''))
permissions:
contents: read
packages: write
id-token: write # cosign keyless signing via Sigstore OIDC
outputs:
image_sha: ${{ steps.meta.outputs.image_sha }}
image_digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Install cosign
uses: sigstore/cosign-installer@v3
with:
cosign-release: 'v2.4.1'
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "image_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "image=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}" >> $GITHUB_OUTPUT
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
${{ steps.meta.outputs.image }}:latest
cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:cache
cache-to: type=registry,ref=${{ steps.meta.outputs.image }}:cache,mode=max
provenance: false
- name: Sign image with cosign (keyless, Sigstore Fulcio)
env:
COSIGN_EXPERIMENTAL: '1'
run: |
IMAGE_DIGEST="${{ steps.meta.outputs.image }}@${{ steps.build.outputs.digest }}"
echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io -u "${{ github.actor }}" --password-stdin
echo "Signing $IMAGE_DIGEST"
cosign sign --yes "$IMAGE_DIGEST"
echo "✅ Backend image signed (transparency log: rekor.sigstore.dev)"
# ========================================
# TELEGRAM CEO APPROVAL GATE
# Runs on self-hosted pwap-runner (DEV VPS) where pexsec-bot.service
@@ -608,6 +739,275 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CEO_CHAT_ID}" --data-urlencode "text=$MSG"
# ========================================
# DEPLOY BACKEND (INDEXER) TO ITS HOST
# Runs a container from the SHA-tagged GHCR image on the backend VPS.
# Same discipline as the web deploy: cosign verify → deploy → health check →
# auto-rollback to previous SHA → Telegram notify. Gated behind the SAME
# telegram-gate approval as the web deploy. Only main/tags, never fork PRs.
#
# STATEFUL DB: the sqlite file lives in the named docker volume
# `pwap-indexer-db` (mounted at /data). Deploy replaces the container/image
# but NEVER the volume — indexer state survives every deploy and rollback.
#
# Host param via secret BACKEND_VPS_HOST (no hardcoded IP). If the backend
# shares the web DEV VPS, set BACKEND_VPS_HOST = VPS_HOST (see backend/DEPLOY.md).
# ========================================
deploy-backend:
name: Deploy Backend (indexer)
runs-on: pwap-runner
needs: [telegram-gate, build-image-backend]
if: |
always() &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) &&
needs.telegram-gate.result == 'success' &&
((github.event_name == 'push' && needs.build-image-backend.result == 'success') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
permissions:
contents: read
packages: read
steps:
- name: Determine image SHA
id: sha
run: |
if [ -n "${{ github.event.inputs.rollback_to }}" ]; then
echo "sha=${{ github.event.inputs.rollback_to }}" >> $GITHUB_OUTPUT
echo "Rolling back backend to: ${{ github.event.inputs.rollback_to }}"
else
echo "sha=${{ needs.build-image-backend.outputs.image_sha }}" >> $GITHUB_OUTPUT
fi
- name: Install cosign (for verify)
uses: sigstore/cosign-installer@v3
with:
cosign-release: 'v2.4.1'
- name: Log in to GHCR (runner-side, for verify)
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify image signature (cosign keyless)
env:
COSIGN_EXPERIMENTAL: '1'
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.sha.outputs.sha }}"
echo "${{ secrets.GITHUB_TOKEN }}" | cosign login ghcr.io -u "${{ github.actor }}" --password-stdin
echo "Verifying signature for $IMAGE"
cosign verify "$IMAGE" \
--certificate-identity-regexp "^https://github.com/pezkuwichain/pwap/.github/workflows/quality-gate.yml@" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
> /dev/null
echo "✅ Signature valid — backend image was built by trusted CI"
- name: Deploy container to backend host (health-checked, auto-rollback)
id: deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.BACKEND_VPS_HOST }}
username: ${{ secrets.BACKEND_VPS_USER }}
key: ${{ secrets.BACKEND_VPS_SSH_KEY }}
port: ${{ secrets.BACKEND_VPS_SSH_PORT || 22 }}
envs: IMAGE_REF,NEW_SHA,GHCR_USER,GHCR_TOKEN
command_timeout: 10m
script_stop: true
script: |
set -e
REGISTRY_IMAGE="ghcr.io/pezkuwichain/pwap-indexer"
IMAGE="$REGISTRY_IMAGE:$NEW_SHA"
STATE_DIR="/opt/pwap-indexer"
NAME="pwap-indexer"
mkdir -p "$STATE_DIR"
PREV="$(cat "$STATE_DIR/.deploy-sha" 2>/dev/null || echo "")"
echo "Deploying $IMAGE (previous: ${PREV:-none})"
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
docker pull "$IMAGE"
run_container () {
local img="$1"
docker rm -f "$NAME" >/dev/null 2>&1 || true
# Named volume pwap-indexer-db = stateful sqlite DB. NEVER removed
# by this script — it is created on first run and reused forever.
docker run -d --name "$NAME" --restart unless-stopped \
-p 3001:3001 \
-v pwap-indexer-db:/data \
-e DB_PATH=/data/transactions.db \
-e PORT=3001 \
--env-file "$STATE_DIR/.env" \
"$img"
}
healthy () {
for i in $(seq 1 12); do
if curl -fsS --max-time 5 "http://localhost:3001/health" >/dev/null 2>&1; then
return 0
fi
echo "health attempt $i/12 failed, retry in 5s..."
sleep 5
done
return 1
}
run_container "$IMAGE"
if healthy; then
echo "$NEW_SHA" > "$STATE_DIR/.deploy-sha"
echo "✅ Backend healthy on $NEW_SHA"
docker image prune -f >/dev/null 2>&1 || true
exit 0
fi
echo "❌ Health check failed for $NEW_SHA"
if [ -n "$PREV" ]; then
echo "🔄 Rolling back to $PREV"
docker pull "$REGISTRY_IMAGE:$PREV"
run_container "$REGISTRY_IMAGE:$PREV"
if healthy; then
echo "$PREV" > "$STATE_DIR/.deploy-sha"
echo "✅ Rolled back to $PREV — backend healthy"
exit 1 # still fail the job: the new SHA did not deploy
fi
echo "🚨 Rollback to $PREV ALSO failed"
exit 1
fi
echo "No previous SHA to roll back to."
exit 1
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.sha.outputs.sha }}
NEW_SHA: ${{ steps.sha.outputs.sha }}
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post-deploy notification
if: success()
run: echo "✅ Deployed backend image ${{ steps.sha.outputs.sha }} to backend host"
- name: Notify failure (Telegram)
if: failure()
env:
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
NEW_SHA: ${{ steps.sha.outputs.sha }}
run: |
MSG="❌ pwap/backend indexer: deploy ($NEW_SHA) failed. If a previous SHA existed the host auto-rolled-back to it (DB volume untouched). Manual: gh workflow run quality-gate.yml -f rollback_to=<sha>"
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=${CEO_CHAT_ID}" --data-urlencode "text=$MSG"
# ========================================
# DEPLOY - SUPABASE (edge functions + migrations)
# Same Telegram-gated cutover as the frontend/backend: syncs the edge functions
# into the self-hosted edge-runtime volume (hot-reloaded, no restart) and applies
# any un-recorded migrations transactionally against the fund-custody Postgres.
# Runs in parallel with deploy-app so functions, migrations and the new frontend
# go live in the same post-approval window (minimal fail-closed gap).
# ========================================
deploy-supabase:
name: Deploy Supabase (functions + migrations)
runs-on: pwap-runner
needs: [telegram-gate]
if: |
always() &&
needs.telegram-gate.result == 'success' &&
github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pack supabase tree
run: |
# __tests__ are Deno tests — kept out of the runtime bundle entirely.
tar czf supabase-deploy.tgz -C web/supabase \
--exclude='__tests__' functions migrations deploy
- name: Upload bundle to staging
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.SUPABASE_VPS_HOST }}
username: ${{ secrets.SUPABASE_VPS_USER }}
key: ${{ secrets.SUPABASE_VPS_SSH_KEY }}
port: ${{ secrets.SUPABASE_VPS_SSH_PORT || 22 }}
source: "supabase-deploy.tgz"
target: /opt/supabase-self-hosted/deploy-staging
- name: Sync edge functions + apply migrations (health-checked)
id: apply
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SUPABASE_VPS_HOST }}
username: ${{ secrets.SUPABASE_VPS_USER }}
key: ${{ secrets.SUPABASE_VPS_SSH_KEY }}
port: ${{ secrets.SUPABASE_VPS_SSH_PORT || 22 }}
command_timeout: 15m
script: |
set -euo pipefail
BASE=/opt/supabase-self-hosted/deploy-staging
STAGING="$BASE/tree"
VOL=/opt/supabase-self-hosted/docker/volumes/functions
DB=supabase-db
echo "── 0. Unpack bundle ──"
rm -rf "$STAGING" && mkdir -p "$STAGING"
tar xzf "$BASE/supabase-deploy.tgz" -C "$STAGING"
echo "── 1. Sync edge functions (additive, no --delete: telegram-* preserved) ──"
# __tests__ are Deno test dirs — never ship them into the runtime volume.
rsync -a --exclude='__tests__' "$STAGING/functions/" "$VOL/"
echo " synced: $(ls "$STAGING/functions" | grep -v __tests__ | tr '\n' ' ')"
echo "── 2. Pre-flight: required edge-runtime secrets ──"
EF_ENV="$(docker exec supabase-edge-functions env 2>/dev/null || true)"
miss=""
for k in SUPABASE_URL SUPABASE_SERVICE_ROLE_KEY PLATFORM_PRIVATE_KEY; do
grep -q "^$k=" <<<"$EF_ENV" || miss="$miss $k"
done
if [ -n "$miss" ]; then
echo "::warning::edge-runtime missing secrets:$miss (fund functions may fail until set)"
fi
echo "── 3. Apply pending migrations (transactional, tracked) ──"
bash "$STAGING/deploy/apply-migrations.sh" "$STAGING/migrations" "$DB"
echo "── 4. Verify the fund-custody guards actually took effect ──"
q() { docker exec -i "$DB" psql -U postgres -tAq -c "$1"; }
fail=0
for fn in "release_escrow_internal" "lock_escrow_internal" "refund_escrow_internal" "request_withdraw"; do
# any signature match; expect NO anon EXECUTE
oid="$(q "SELECT oid FROM pg_proc WHERE proname='$fn' LIMIT 1;")"
if [ -n "$oid" ]; then
priv="$(q "SELECT has_function_privilege('anon', $oid, 'EXECUTE');")"
echo " anon EXECUTE $fn = $priv"
[ "$priv" = "f" ] || { echo "::error::anon still has EXECUTE on $fn"; fail=1; }
fi
done
trg="$(q "SELECT tgname FROM pg_trigger WHERE tgname IN ('trg_freeze_trade_financials','trg_freeze_offer_financials');")"
n_trg="$(printf '%s' "$trg" | grep -c . || true)"
echo " freeze triggers present: ${trg:-<none>} (count=$n_trg)"
[ "$n_trg" = "2" ] || { echo "::error::trade/offer freeze triggers missing (expected 2, got $n_trg)"; fail=1; }
rm -rf "$BASE"
[ "$fail" = "0" ] && echo "✅ Supabase deploy verified" || { echo "❌ post-deploy verification failed"; exit 1; }
- name: Telegram notify
if: always()
env:
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
OUTCOME: ${{ steps.apply.outcome }}
run: |
if [ "$OUTCOME" = "success" ]; then
MSG="✅ pwap Supabase: edge functions synced + migrations applied & verified (${{ github.sha }})."
else
MSG="🚨 pwap Supabase deploy FAILED (${{ github.sha }}) — functions/migrations may be partially applied. Check the run."
fi
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d chat_id="${CEO_CHAT_ID}" -d text="$MSG" >/dev/null || true
# ========================================
# SECURITY CHECKS (BLOCKING)
# npm audit (high + critical) + TruffleHog secret scan
@@ -631,7 +1031,7 @@ jobs:
- name: Web — npm audit (high + critical, production deps only)
working-directory: ./web
run: |
npm install
npm ci
# Audit only production dependencies. Build tooling (vite, esbuild,
# vite-plugin-node-polyfills → elliptic, etc.) ships to no user, and
# advisories on those dev deps kept blocking production deploys.
@@ -660,7 +1060,7 @@ jobs:
ci-gate:
name: CI Gate ✅
runs-on: pwap-runner
needs: [web, security-audit]
needs: [web, backend, security-audit]
if: always()
steps:
+16
View File
@@ -0,0 +1,16 @@
node_modules
npm-debug.log
# Never bake stateful DB or secrets into the image
transactions.db
*.db
.env
.env.*
!.env.example
# Tests + docs are not needed at runtime
test
integration-tests
jest.config.js
.eslintrc.cjs
*.md
.git
.github
+19 -6
View File
@@ -1,9 +1,22 @@
# PezkuwiChain WebSocket Endpoint
WS_ENDPOINT=wss://ws.pezkuwichain.io
# Sudo account seed phrase for auto-approval
# This account will sign approve_kyc transactions when threshold is reached
SUDO_SEED=your_seed_phrase_here
# ── Indexer service (src/index.js) ──────────────────────────────
# PezkuwiChain WebSocket endpoint
WS_ENDPOINT=wss://rpc.pezkuwichain.io
# Server port
PORT=3001
# Stateful sqlite DB path. In Docker this is set to /data/transactions.db
# (a mounted volume). Locally it defaults to ./transactions.db.
# DB_PATH=/data/transactions.db
# ── KYC / council service (src/server.js) ───────────────────────
# Supabase project (required by server.js — it exits if missing)
SUPABASE_URL=your_supabase_url_here
SUPABASE_ANON_KEY=your_supabase_anon_key_here
# Founder SS58 address — authorizes /api/council/add-member (signature-gated)
FOUNDER_ADDRESS=your_founder_ss58_address_here
# Sudo account seed — signs approveKyc when the vote threshold is reached.
# Keep secret; use a dedicated key for KYC approvals only.
SUDO_SEED=your_seed_phrase_here
+6
View File
@@ -0,0 +1,6 @@
# Stateful indexer DB — never commit (lives on a mounted volume in prod)
transactions.db
*.db
*.db-journal
*.sqlite
node_modules/
+89
View File
@@ -0,0 +1,89 @@
# Backend (indexer) — Deploy Runbook
The backend is a runnable Node service (`src/index.js`) that indexes chain
transfers into a **stateful** local sqlite DB and serves a small read API. It is
built into a SHA-tagged, cosign-signed GHCR image and run as a Docker container
on the backend host by the `build-image-backend` + `deploy-backend` jobs in
`.github/workflows/quality-gate.yml`.
> The KYC/council service (`src/server.js`) is a separate surface (Supabase +
> sudo signer). It is not containerized by this pipeline; only the indexer is.
## Pipeline shape (mirrors the web deploy)
```
backend (npm ci + npm test + audit)
└─ telegram-gate (CEO approves in Telegram — SAME gate as web)
└─ build-image-backend (docker build ./backend → GHCR :<sha> + :latest, cosign sign)
└─ deploy-backend (cosign verify → ssh host → docker run → /health → auto-rollback)
```
- **Trigger:** only `main` or tags, `push`/`workflow_dispatch`. Never fork PRs
(the `if:` requires `refs/heads/main`|`refs/tags/*` + `packages:write`, which
fork PRs never get).
- **Rollback:** `gh workflow run quality-gate.yml -f rollback_to=<sha>` — skips
build, re-runs the old signed image. The deploy step also **auto-rolls-back**
to the previously-live SHA on a failed health check.
## STATEFUL DB — do not wipe (critical)
The indexer's sqlite file **is** the service's memory. It lives in the Docker
**named volume `pwap-indexer-db`**, mounted at `/data`, with
`DB_PATH=/data/transactions.db`.
- The image never contains the DB (see `.dockerignore` + `Dockerfile` `VOLUME`).
- `deploy-backend` does `docker rm -f` + `docker run` on every deploy/rollback
but **never touches the volume** — state survives every deploy and rollback.
- Never run `docker volume rm pwap-indexer-db` as part of a deploy. Back it up
before host maintenance:
`docker run --rm -v pwap-indexer-db:/data -v "$PWD:/backup" busybox tar czf /backup/indexer-db.tgz -C /data .`
## One-time host setup
On the backend host (as the `BACKEND_VPS_USER`):
```bash
mkdir -p /opt/pwap-indexer
# Runtime env for the container (owner-managed, NOT in git):
cat > /opt/pwap-indexer/.env <<'EOF'
WS_ENDPOINT=wss://rpc.pezkuwichain.io
PORT=3001
# DB_PATH is set by the deploy to /data/transactions.db — leave unset here.
EOF
```
Docker must be installed and the deploy user able to run it. Port `3001` is
published on the host; front it with the existing nginx/Cloudflare if it needs
to be public (remember `set_real_ip_from` behind the CF proxy).
## Required GitHub secrets
New (backend-specific):
| Secret | Purpose |
| --- | --- |
| `BACKEND_VPS_HOST` | Backend host address (no hardcoded IP in the workflow). **If the backend shares the web DEV VPS, set this equal to `VPS_HOST`.** |
| `BACKEND_VPS_USER` | SSH user on the backend host. |
| `BACKEND_VPS_SSH_KEY` | SSH private key for that user. |
| `BACKEND_VPS_SSH_PORT` | Optional; defaults to `22`. |
Reused from the existing web pipeline (no new value needed):
`GITHUB_TOKEN` (GHCR push/pull + cosign), `PEXSEC_BOT_TOKEN`,
`TELEGRAM_CEO_CHAT_ID`.
## Post-deploy smoke checks
```bash
curl -fsS http://<host>:3001/health # {"status":"ok"}
curl -fsS http://<host>:3001/api/stats # {"total": <n>} — grows as blocks index
docker inspect --format '{{.State.Health.Status}}' pwap-indexer # healthy
docker volume inspect pwap-indexer-db # confirm the DB volume exists/persists
```
## Notes / can't-run-in-this-environment
- **cosign keyless signing/verification** only works inside GitHub Actions
(needs the Sigstore OIDC token). It cannot be exercised locally/offline.
- The offline test suite (`npm test`, `test/*.test.js`) needs no chain/DB. The
`integration-tests/*.live.test.js` suites need a live chain + Supabase and are
**excluded** from CI on purpose.
+47
View File
@@ -0,0 +1,47 @@
# pwap indexer service — runnable Node container (not a static-dist image).
#
# Two stages on the SAME debian base so the natively-compiled sqlite3 binding
# built in stage 1 is ABI-compatible with the runtime in stage 2 (do NOT mix
# alpine/musl here — sqlite3 would fail to load).
#
# STATEFUL DATA: the indexer's sqlite file is the service's memory. It is kept
# OUT of the image, under /data, which MUST be a mounted volume. A deploy
# replaces the container/image but never the volume — see backend/DEPLOY.md.
# ─── Stage 1: install production deps (compiles sqlite3) ────────
FROM node:20-bookworm AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# --omit=dev: the test-only devDeps (supertest, @pezkuwi/keyring) never ship.
RUN npm ci --omit=dev
# ─── Stage 2: runtime ──────────────────────────────────────────
FROM node:20-bookworm-slim
ENV NODE_ENV=production
ENV PORT=3001
# DB on the volume, never in the image layer.
ENV DB_PATH=/data/transactions.db
WORKDIR /app
# curl only, for the HEALTHCHECK probe.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
# /data holds the stateful sqlite DB — declared as a volume and owned by the
# non-root runtime user so the container can write to the mount.
RUN mkdir -p /data && chown -R node:node /app /data
VOLUME ["/data"]
USER node
EXPOSE 3001
# Probe the in-container /health endpoint added to the indexer API.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD sh -c 'curl -fsS "http://localhost:${PORT:-3001}/health" || exit 1'
CMD ["node", "src/index.js"]
+647 -9
View File
@@ -11,11 +11,19 @@
"@pezkuwi/api": "^16.5.23",
"@pezkuwi/util": "^14.0.13",
"@pezkuwi/util-crypto": "^14.0.13",
"@supabase/supabase-js": "^2.45.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.3.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@pezkuwi/keyring": "^14.0.13",
"supertest": "^7.0.0"
}
},
"node_modules/@bizinikiwi/connect": {
@@ -143,6 +151,16 @@
"node": ">=10"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pezkuwi/api": {
"version": "16.5.23",
"resolved": "https://registry.npmjs.org/@pezkuwi/api/-/api-16.5.23.tgz",
@@ -761,6 +779,12 @@
"node": ">=18"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
@@ -789,6 +813,90 @@
"integrity": "sha512-TWDurLiPxndFgKjVavCniytBIw+t4ViOi7TYp9h/D0NMmkEc9klFTo+827eyEJ0lELpqO207Ey7uGxUa+BS1jQ==",
"license": "Apache-2.0"
},
"node_modules/@supabase/auth-js": {
"version": "2.110.8",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz",
"integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.110.8",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz",
"integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.110.8",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz",
"integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.110.8",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz",
"integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.110.8",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz",
"integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.110.8",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz",
"integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.110.8",
"@supabase/functions-js": "2.110.8",
"@supabase/postgrest-js": "2.110.8",
"@supabase/realtime-js": "2.110.8",
"@supabase/storage-js": "2.110.8"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -824,6 +932,18 @@
"license": "ISC",
"optional": true
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -909,6 +1029,29 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1119,6 +1262,35 @@
"color-support": "bin.js"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1173,6 +1345,13 @@
"node": ">=6.6.0"
}
},
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -1199,6 +1378,15 @@
"node": ">= 12"
}
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1240,6 +1428,16 @@
"node": ">=4.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -1265,6 +1463,17 @@
"node": ">=8"
}
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -1392,6 +1601,22 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -1407,12 +1632,30 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1465,6 +1708,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-copy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
"license": "MIT"
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -1515,6 +1770,46 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -1527,6 +1822,24 @@
"node": ">=12.20.0"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1601,6 +1914,15 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1697,6 +2019,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -1705,9 +2043,9 @@
"optional": true
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1716,6 +2054,12 @@
"node": ">= 0.4"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -1782,6 +2126,15 @@
"ms": "^2.0.0"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -1918,6 +2271,15 @@
"license": "ISC",
"optional": true
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -2005,6 +2367,29 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2359,6 +2744,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2425,6 +2819,132 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-http": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz",
"integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==",
"license": "MIT",
"dependencies": {
"get-caller-file": "^2.0.5",
"pino": "^9.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0"
}
},
"node_modules/pino-pretty": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-11.3.0.tgz",
"integrity": "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==",
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
"fast-copy": "^3.0.2",
"fast-safe-stringify": "^2.1.1",
"help-me": "^5.0.0",
"joycon": "^3.1.1",
"minimist": "^1.2.6",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pump": "^3.0.0",
"readable-stream": "^4.0.0",
"secure-json-parse": "^2.4.0",
"sonic-boom": "^4.0.1",
"strip-json-comments": "^3.1.1"
},
"bin": {
"pino-pretty": "bin.js"
}
},
"node_modules/pino-pretty/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/pino-pretty/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/pino-pretty/node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2451,6 +2971,31 @@
"node": ">=10"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -2519,6 +3064,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -2572,6 +3123,15 @@
"node": ">= 6"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
@@ -2644,12 +3204,27 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -2895,6 +3470,24 @@
"node": ">= 10"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sqlite": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz",
@@ -2993,10 +3586,46 @@
"node": ">=0.10.0"
}
},
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/tar": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -3082,6 +3711,15 @@
"node": ">=18"
}
},
"node_modules/thread-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -3215,9 +3853,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+12 -2
View File
@@ -6,10 +6,12 @@
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
"dev": "node --watch src/index.js",
"test": "node --test test/*.test.js"
},
"overrides": {
"tar": "^7.4.3",
"tar": "^7.5.22",
"ws": "^8.21.1",
"@pezkuwi/api": "^16.5.23",
"@pezkuwi/api-augment": "^16.5.23",
"@pezkuwi/api-base": "^16.5.23",
@@ -34,10 +36,18 @@
"@pezkuwi/api": "^16.5.23",
"@pezkuwi/util": "^14.0.13",
"@pezkuwi/util-crypto": "^14.0.13",
"@supabase/supabase-js": "^2.45.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.3.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@pezkuwi/keyring": "^14.0.13",
"supertest": "^7.0.0"
}
}
+198
View File
@@ -0,0 +1,198 @@
// Council / KYC HTTP surface — signature-gated, fund/authz-relevant.
//
// Extracted from server.js as an injectable factory so the endpoints can be
// exercised in CI with NO live chain and NO live Supabase:
// - `supabase` is injected (tests pass an in-memory fake).
// - `getApi()` / `getSudo()` are injected getters (tests pass a stubbed
// tx surface, so the on-chain approveKyc path runs without a network).
// Real @pezkuwi/util-crypto signature verification is used as-is — it is pure
// crypto and runs fully offline, so auth rejection/acceptance is tested for real.
import express from 'express'
import cors from 'cors'
import { signatureVerify } from '@pezkuwi/util-crypto'
const THRESHOLD_PERCENT = 0.6
export function createApp ({ supabase, getApi = () => null, getSudo = () => null, logger = console }) {
const app = express()
app.use(cors())
app.use(express.json())
// ========================================
// COUNCIL MANAGEMENT
// ========================================
app.post('/api/council/add-member', async (req, res) => {
const { newMemberAddress, signature, message } = req.body
const founderAddress = process.env.FOUNDER_ADDRESS
if (!founderAddress) {
logger.error('Founder address is not configured.')
return res.status(500).json({ error: { key: 'errors.server.founder_not_configured' } })
}
if (process.env.NODE_ENV !== 'test') {
const { isValid } = signatureVerify(message, signature, founderAddress)
if (!isValid) {
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
}
if (!message.includes(`addCouncilMember:${newMemberAddress}`)) {
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
}
}
if (!newMemberAddress || newMemberAddress.length < 47) {
return res.status(400).json({ error: { key: 'errors.request.invalid_address' } })
}
try {
const { error } = await supabase
.from('council_members')
.insert([{ address: newMemberAddress }])
if (error) {
if (error.code === '23505') { // Unique violation
return res.status(409).json({ error: { key: 'errors.council.member_exists' } })
}
throw error
}
res.status(200).json({ success: true })
} catch (error) {
logger.error({ err: error, newMemberAddress }, 'Error adding council member')
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
}
})
// ========================================
// KYC VOTING
// ========================================
app.post('/api/kyc/propose', async (req, res) => {
const { userAddress, proposerAddress, signature, message } = req.body
try {
if (process.env.NODE_ENV !== 'test') {
const { isValid } = signatureVerify(message, signature, proposerAddress)
if (!isValid) {
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
}
if (!message.includes(`proposeKYC:${userAddress}`)) {
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
}
}
const { data: councilMember, error: memberError } = await supabase
.from('council_members').select('address').eq('address', proposerAddress).single()
if (memberError || !councilMember) {
return res.status(403).json({ error: { key: 'errors.auth.proposer_not_member' } })
}
const { error: proposalError } = await supabase
.from('kyc_proposals').insert({ user_address: userAddress, proposer_address: proposerAddress })
if (proposalError) {
if (proposalError.code === '23505') {
return res.status(409).json({ error: { key: 'errors.kyc.proposal_exists' } })
}
throw proposalError
}
const { data: proposal } = await supabase
.from('kyc_proposals').select('id').eq('user_address', userAddress).single()
await supabase.from('votes')
.insert({ proposal_id: proposal.id, voter_address: proposerAddress, is_aye: true })
await checkAndExecute(userAddress)
res.status(201).json({ success: true, proposalId: proposal.id })
} catch (error) {
logger.error({ err: error, ...req.body }, 'Error proposing KYC')
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
}
})
async function checkAndExecute (userAddress) {
try {
const api = getApi()
const sudoAccount = getSudo()
const { count: totalMembers, error: countError } = await supabase
.from('council_members').select('*', { count: 'exact', head: true })
if (countError) throw countError
if (totalMembers === 0) return
const { data: proposal, error: proposalError } = await supabase
.from('kyc_proposals').select('id, executed').eq('user_address', userAddress).single()
if (proposalError || !proposal || proposal.executed) return
const { count: ayesCount, error: ayesError } = await supabase
.from('votes').select('*', { count: 'exact', head: true })
.eq('proposal_id', proposal.id).eq('is_aye', true)
if (ayesError) throw ayesError
const requiredVotes = Math.ceil(totalMembers * THRESHOLD_PERCENT)
if (ayesCount >= requiredVotes) {
if (!sudoAccount || !api) {
logger.error({ userAddress }, 'Cannot execute: No sudo account or API connection')
return
}
logger.info({ userAddress }, 'Threshold reached! Executing approveKyc...')
const tx = api.tx.identityKyc.approveKyc(userAddress)
await tx.signAndSend(sudoAccount, async ({ status, dispatchError, events }) => {
if (status.isFinalized) {
if (dispatchError) {
const decoded = api.registry.findMetaError(dispatchError.asModule)
const errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`
logger.error({ userAddress, error: errorMsg }, 'Approval failed')
return
}
const approvedEvent = events.find(({ event }) => api.events.identityKyc.KycApproved.is(event))
if (approvedEvent) {
logger.info({ userAddress }, 'KYC Approved on-chain. Marking as executed.')
await supabase.from('kyc_proposals').update({ executed: true }).eq('id', proposal.id)
}
}
})
}
} catch (error) {
logger.error({ err: error, userAddress }, 'Error in checkAndExecute')
}
}
// ========================================
// GETTERS
// ========================================
app.get('/api/kyc/pending', async (req, res) => {
try {
const { data, error } = await supabase
.from('kyc_proposals')
.select('user_address, proposer_address, created_at, votes ( voter_address, is_aye )')
.eq('executed', false)
if (error) throw error
res.json({ pending: data })
} catch (error) {
logger.error({ err: error }, 'Error fetching pending proposals')
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
}
})
// ========================================
// HEALTH CHECK
// ========================================
app.get('/health', async (req, res) => {
res.json({
status: 'ok',
blockchain: getApi() ? 'connected' : 'disconnected'
})
})
return app
}
+32 -124
View File
@@ -1,134 +1,42 @@
import express from 'express';
import cors from 'cors';
import { open } from 'sqlite';
import sqlite3 from 'sqlite3';
import { ApiPromise, WsProvider } from '@pezkuwi/api';
import dotenv from 'dotenv';
// Indexer service bootstrap.
//
// Thin wiring layer: connects to the live chain and feeds decoded blocks into
// the injectable core in ./indexer.js (which holds all the DB + HTTP logic and
// is unit-tested offline). Keeping the @pezkuwi/api connection isolated here
// means the testable core never touches the network.
dotenv.config();
import { ApiPromise, WsProvider } from '@pezkuwi/api'
import dotenv from 'dotenv'
import { initDb, indexBlock, createApp } from './indexer.js'
const app = express();
const port = process.env.PORT || 3001;
const WS_ENDPOINT = process.env.WS_ENDPOINT || 'wss://rpc.pezkuwichain.io';
dotenv.config()
app.use(cors());
app.use(express.json());
// Initialize Database
async function initDb() {
const db = await open({
filename: './transactions.db',
driver: sqlite3.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT UNIQUE,
sender TEXT,
receiver TEXT,
amount TEXT,
asset_id INTEGER DEFAULT NULL,
symbol TEXT,
block_number INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
return db;
}
const port = process.env.PORT || 3001
const WS_ENDPOINT = process.env.WS_ENDPOINT || 'wss://rpc.pezkuwichain.io'
// Start Indexing
async function startIndexer(db) {
console.log(`Connecting to Pezkuwi Node: ${WS_ENDPOINT}`);
const provider = new WsProvider(WS_ENDPOINT);
const api = await ApiPromise.create({ provider });
async function startIndexer (db) {
console.log(`Connecting to Pezkuwi Node: ${WS_ENDPOINT}`)
const provider = new WsProvider(WS_ENDPOINT)
const api = await ApiPromise.create({ provider })
console.log('Connected! Listening for new blocks...');
console.log('Connected! Listening for new blocks...')
api.rpc.chain.subscribeNewHeads(async (header) => {
const blockNumber = header.number.toNumber();
const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
const signedBlock = await api.rpc.chain.getBlock(blockHash);
signedBlock.block.extrinsics.forEach(async (ex) => {
const { method: { method, section }, signer } = ex;
// 1. Handle Native HEZ Transfers
if (section === 'balances' && (method === 'transfer' || method === 'transferKeepAlive')) {
const [dest, value] = ex.method.args;
await saveTransfer(db, {
hash: ex.hash.toHex(),
sender: signer.toString(),
receiver: dest.toString(),
amount: value.toString(),
asset_id: null,
symbol: 'HEZ',
block_number: blockNumber
});
}
// 2. Handle Asset Transfers (PEZ, USDT)
if (section === 'assets' && method === 'transfer') {
const [id, dest, value] = ex.method.args;
const assetId = id.toNumber();
const symbol = assetId === 1 ? 'PEZ' : assetId === 1000 ? 'USDT' : `ASSET-${assetId}`;
await saveTransfer(db, {
hash: ex.hash.toHex(),
sender: signer.toString(),
receiver: dest.toString(),
amount: value.toString(),
asset_id: assetId,
symbol: symbol,
block_number: blockNumber
});
}
});
});
}
async function saveTransfer(db, tx) {
try {
await db.run(
`INSERT OR IGNORE INTO transfers (hash, sender, receiver, amount, asset_id, symbol, block_number)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[tx.hash, tx.sender, tx.receiver, tx.amount, tx.asset_id, tx.symbol, tx.block_number]
);
console.log(`Indexed ${tx.symbol} Transfer: ${tx.hash.slice(0, 10)}...`);
} catch (err) {
console.error('DB Insert Error:', err);
}
}
// API Routes
async function startServer(db) {
app.get('/api/history/:address', async (req, res) => {
const { address } = req.params;
try {
const history = await db.all(
`SELECT * FROM transfers
WHERE sender = ? OR receiver = ?
ORDER BY block_number DESC LIMIT 50`,
[address, address]
);
res.json(history);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/stats', async (req, res) => {
const stats = await db.get('SELECT COUNT(*) as total FROM transfers');
res.json(stats);
});
app.listen(port, () => {
console.log(`Indexer API running at http://localhost:${port}`);
});
api.rpc.chain.subscribeNewHeads(async (header) => {
const blockNumber = header.number.toNumber()
const blockHash = await api.rpc.chain.getBlockHash(blockNumber)
const signedBlock = await api.rpc.chain.getBlock(blockHash)
await indexBlock(db, signedBlock, blockNumber)
})
}
// Launch
const db = await initDb();
startIndexer(db);
startServer(db);
// DB_PATH lets the deploy point the stateful sqlite file at a mounted volume
// (e.g. /data/transactions.db); defaults to the historical ./transactions.db.
const db = await initDb(process.env.DB_PATH || './transactions.db')
startIndexer(db)
const app = createApp(db)
app.listen(port, () => {
console.log(`Indexer API running at http://localhost:${port}`)
})
+135
View File
@@ -0,0 +1,135 @@
// Indexer core — pure, injectable, offline-testable.
//
// This module deliberately does NOT import @pezkuwi/api. It operates on
// already-decoded extrinsic objects and an injected sqlite handle, so the
// dedup / symbol-mapping / DB logic can be exercised in CI with no live chain
// and an in-memory (`:memory:`) database. src/index.js wires this to the live
// WsProvider/ApiPromise subscription.
import express from 'express'
import cors from 'cors'
import { open } from 'sqlite'
import sqlite3 from 'sqlite3'
// Open (or create) the transfers DB. Pass ':memory:' in tests.
export async function initDb (filename = './transactions.db') {
const db = await open({ filename, driver: sqlite3.Database })
await db.exec(`
CREATE TABLE IF NOT EXISTS transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT UNIQUE,
sender TEXT,
receiver TEXT,
amount TEXT,
asset_id INTEGER DEFAULT NULL,
symbol TEXT,
block_number INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
return db
}
// Persist a single transfer. INSERT OR IGNORE gives us hash-level dedup so the
// same extrinsic seen twice (re-org / replay / overlapping subscriptions) is a
// no-op instead of a duplicate row.
export async function saveTransfer (db, tx) {
try {
await db.run(
`INSERT OR IGNORE INTO transfers (hash, sender, receiver, amount, asset_id, symbol, block_number)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[tx.hash, tx.sender, tx.receiver, tx.amount, tx.asset_id, tx.symbol, tx.block_number]
)
console.log(`Indexed ${tx.symbol} Transfer: ${tx.hash.slice(0, 10)}...`)
} catch (err) {
console.error('DB Insert Error:', err)
}
}
// Decode a single (decoded) extrinsic into a transfer row, or null if it is not
// a transfer we index. Kept pure so it can be unit-tested with plain fixtures.
export function parseExtrinsic (ex, blockNumber) {
const { method: { method, section }, signer } = ex
// 1. Native HEZ transfers
if (section === 'balances' && (method === 'transfer' || method === 'transferKeepAlive')) {
const [dest, value] = ex.method.args
return {
hash: ex.hash.toHex(),
sender: signer.toString(),
receiver: dest.toString(),
amount: value.toString(),
asset_id: null,
symbol: 'HEZ',
block_number: blockNumber
}
}
// 2. Asset transfers (PEZ, USDT, …)
if (section === 'assets' && method === 'transfer') {
const [id, dest, value] = ex.method.args
const assetId = id.toNumber()
const symbol = assetId === 1 ? 'PEZ' : assetId === 1000 ? 'USDT' : `ASSET-${assetId}`
return {
hash: ex.hash.toHex(),
sender: signer.toString(),
receiver: dest.toString(),
amount: value.toString(),
asset_id: assetId,
symbol,
block_number: blockNumber
}
}
return null
}
// Index every relevant extrinsic in a block. Sequential await (vs the original
// fire-and-forget forEach) so inserts actually complete before the block is
// considered processed — same rows, just no lost/racing writes.
export async function indexBlock (db, signedBlock, blockNumber) {
for (const ex of signedBlock.block.extrinsics) {
const transfer = parseExtrinsic(ex, blockNumber)
if (transfer) await saveTransfer(db, transfer)
}
}
// Build the read-only indexer API over an injected db handle.
export function createApp (db) {
const app = express()
app.use(cors())
app.use(express.json())
// Liveness/readiness probe (used by the Docker HEALTHCHECK).
app.get('/health', (req, res) => {
res.json({ status: 'ok' })
})
app.get('/api/history/:address', async (req, res) => {
const { address } = req.params
try {
const history = await db.all(
`SELECT * FROM transfers
WHERE sender = ? OR receiver = ?
ORDER BY block_number DESC LIMIT 50`,
[address, address]
)
res.json(history)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
app.get('/api/stats', async (req, res) => {
try {
const stats = await db.get('SELECT COUNT(*) as total FROM transfers')
res.json(stats)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
return app
}
+19 -189
View File
@@ -1,11 +1,17 @@
import express from 'express'
import cors from 'cors'
// Council / KYC service bootstrap.
//
// Thin wiring layer: builds the real Supabase client + logger, connects to the
// live chain, and mounts the injectable route factory from ./council.js. All
// endpoint logic lives in council.js so it can be tested offline; this file only
// owns the live side effects (Supabase client, blockchain connection).
import dotenv from 'dotenv'
import pino from 'pino'
import pinoHttp from 'pino-http'
import { createClient } from '@supabase/supabase-js'
import { ApiPromise, WsProvider, Keyring } from '@pezkuwi/api'
import { cryptoWaitReady, signatureVerify } from '@pezkuwi/util-crypto'
import { cryptoWaitReady } from '@pezkuwi/util-crypto'
import { createApp } from './council.js'
dotenv.config()
@@ -25,7 +31,6 @@ const logger = pino({
// ========================================
// INITIALIZATION
// ========================================
const supabaseUrl = process.env.SUPABASE_URL
const supabaseKey = process.env.SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseKey) {
@@ -34,19 +39,21 @@ if (!supabaseUrl || !supabaseKey) {
}
const supabase = createClient(supabaseUrl, supabaseKey)
const app = express()
app.use(cors())
app.use(express.json())
app.use(pinoHttp({ logger }))
const THRESHOLD_PERCENT = 0.6
let sudoAccount = null
let api = null
// Mount routes with live deps injected via getters (so reassignment in
// initBlockchain is picked up by handlers).
const app = createApp({
supabase,
getApi: () => api,
getSudo: () => sudoAccount,
logger
})
// ========================================
// BLOCKCHAIN CONNECTION
// ========================================
async function initBlockchain () {
logger.info('🔗 Connecting to Blockchain...')
const wsProvider = new WsProvider(process.env.WS_ENDPOINT || 'ws://127.0.0.1:9944')
@@ -63,189 +70,12 @@ async function initBlockchain () {
}
}
// ========================================
// COUNCIL MANAGEMENT
// ========================================
app.post('/api/council/add-member', async (req, res) => {
const { newMemberAddress, signature, message } = req.body
const founderAddress = process.env.FOUNDER_ADDRESS
if (!founderAddress) {
logger.error('Founder address is not configured.')
return res.status(500).json({ error: { key: 'errors.server.founder_not_configured' } })
}
if (process.env.NODE_ENV !== 'test') {
const { isValid } = signatureVerify(message, signature, founderAddress)
if (!isValid) {
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
}
if (!message.includes(`addCouncilMember:${newMemberAddress}`)) {
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
}
}
if (!newMemberAddress || newMemberAddress.length < 47) {
return res.status(400).json({ error: { key: 'errors.request.invalid_address' } })
}
try {
const { error } = await supabase
.from('council_members')
.insert([{ address: newMemberAddress }])
if (error) {
if (error.code === '23505') { // Unique violation
return res.status(409).json({ error: { key: 'errors.council.member_exists' } })
}
throw error
}
res.status(200).json({ success: true })
} catch (error) {
logger.error({ err: error, newMemberAddress }, 'Error adding council member')
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
}
})
// ========================================
// KYC VOTING
// ========================================
app.post('/api/kyc/propose', async (req, res) => {
const { userAddress, proposerAddress, signature, message } = req.body
try {
if (process.env.NODE_ENV !== 'test') {
const { isValid } = signatureVerify(message, signature, proposerAddress)
if (!isValid) {
return res.status(401).json({ error: { key: 'errors.auth.invalid_signature' } })
}
if (!message.includes(`proposeKYC:${userAddress}`)) {
return res.status(400).json({ error: { key: 'errors.request.message_mismatch' } })
}
}
const { data: councilMember, error: memberError } = await supabase
.from('council_members').select('address').eq('address', proposerAddress).single()
if (memberError || !councilMember) {
return res.status(403).json({ error: { key: 'errors.auth.proposer_not_member' } })
}
const { error: proposalError } = await supabase
.from('kyc_proposals').insert({ user_address: userAddress, proposer_address: proposerAddress })
if (proposalError) {
if (proposalError.code === '23505') {
return res.status(409).json({ error: { key: 'errors.kyc.proposal_exists' } })
}
throw proposalError
}
const { data: proposal } = await supabase
.from('kyc_proposals').select('id').eq('user_address', userAddress).single()
await supabase.from('votes')
.insert({ proposal_id: proposal.id, voter_address: proposerAddress, is_aye: true })
await checkAndExecute(userAddress)
res.status(201).json({ success: true, proposalId: proposal.id })
} catch (error) {
logger.error({ err: error, ...req.body }, 'Error proposing KYC')
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
}
})
async function checkAndExecute (userAddress) {
try {
const { count: totalMembers, error: countError } = await supabase
.from('council_members').select('*', { count: 'exact', head: true })
if (countError) throw countError
if (totalMembers === 0) return
const { data: proposal, error: proposalError } = await supabase
.from('kyc_proposals').select('id, executed').eq('user_address', userAddress).single()
if (proposalError || !proposal || proposal.executed) return
const { count: ayesCount, error: ayesError } = await supabase
.from('votes').select('*', { count: 'exact', head: true })
.eq('proposal_id', proposal.id).eq('is_aye', true)
if (ayesError) throw ayesError
const requiredVotes = Math.ceil(totalMembers * THRESHOLD_PERCENT)
if (ayesCount >= requiredVotes) {
if (!sudoAccount || !api) {
logger.error({ userAddress }, 'Cannot execute: No sudo account or API connection')
return
}
logger.info({ userAddress }, `Threshold reached! Executing approveKyc...`)
const tx = api.tx.identityKyc.approveKyc(userAddress)
await tx.signAndSend(sudoAccount, async ({ status, dispatchError, events }) => {
if (status.isFinalized) {
if (dispatchError) {
const decoded = api.registry.findMetaError(dispatchError.asModule)
const errorMsg = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`
logger.error({ userAddress, error: errorMsg }, `Approval failed`)
return
}
const approvedEvent = events.find(({ event }) => api.events.identityKyc.KycApproved.is(event))
if (approvedEvent) {
logger.info({ userAddress }, 'KYC Approved on-chain. Marking as executed.')
await supabase.from('kyc_proposals').update({ executed: true }).eq('id', proposal.id)
}
}
})
}
} catch (error) {
logger.error({ err: error, userAddress }, `Error in checkAndExecute`)
}
}
// ========================================
// OTHER ENDPOINTS (GETTERS)
// ========================================
app.get('/api/kyc/pending', async (req, res) => {
try {
const { data, error } = await supabase
.from('kyc_proposals')
.select('user_address, proposer_address, created_at, votes ( voter_address, is_aye )')
.eq('executed', false)
if (error) throw error
res.json({ pending: data })
} catch (error) {
logger.error({ err: error }, 'Error fetching pending proposals')
res.status(500).json({ error: { key: 'errors.server.internal_error' } })
}
})
// ========================================
// HEALTH CHECK
// ========================================
app.get('/health', async (req, res) => {
res.json({
status: 'ok',
blockchain: api ? 'connected' : 'disconnected'
});
})
// ========================================
// START & EXPORT
// ========================================
initBlockchain().catch(error => {
logger.fatal({ err: error }, '❌ Failed to initialize blockchain')
process.exit(1)
})
export { app, supabase, api, logger }
export { app, supabase, logger }
+228
View File
@@ -0,0 +1,228 @@
// Offline council / KYC tests — real signature crypto, mocked chain + Supabase.
// Run: node --test
//
// NODE_ENV must NOT be 'test' here: the handlers skip signature verification
// when NODE_ENV === 'test', and we specifically want to exercise the real
// @pezkuwi/util-crypto signature gate (reject unsigned/invalid, accept valid).
process.env.NODE_ENV = 'ci-offline'
import { test } from 'node:test'
import assert from 'node:assert/strict'
import request from 'supertest'
import { Keyring } from '@pezkuwi/keyring'
import { cryptoWaitReady } from '@pezkuwi/util-crypto'
import { u8aToHex } from '@pezkuwi/util'
import { createApp } from '../src/council.js'
import { makeFakeSupabase } from './helpers/fake-supabase.js'
await cryptoWaitReady()
const keyring = new Keyring({ type: 'sr25519' })
const founder = keyring.addFromUri('//Founder')
const proposer = keyring.addFromUri('//Proposer')
const stranger = keyring.addFromUri('//Stranger')
const user = keyring.addFromUri('//KycUser')
const sign = (pair, message) => u8aToHex(pair.sign(message))
const silentLogger = { info () {}, warn () {}, error () {}, fatal () {}, debug () {} }
// Stubbed chain: approveKyc(...).signAndSend(sudo, cb) drives the callback with
// a finalized, successful event so the "mark executed" branch runs — no network.
function makeMockApi () {
const calls = []
const api = {
tx: {
identityKyc: {
approveKyc: (addr) => ({
async signAndSend (sudo, cb) {
calls.push(addr)
await cb({
status: { isFinalized: true },
dispatchError: undefined,
events: [{ event: { __kycApproved: true } }]
})
return () => {}
}
})
}
},
events: {
identityKyc: {
KycApproved: { is: (ev) => !!(ev && ev.__kycApproved) }
}
},
registry: { findMetaError: () => ({ section: 's', name: 'n', docs: [] }) }
}
return { api, calls }
}
// --- /api/council/add-member ------------------------------------------------
test('add-member: 500 when FOUNDER_ADDRESS not configured', async () => {
delete process.env.FOUNDER_ADDRESS
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
const res = await request(app).post('/api/council/add-member').send({
newMemberAddress: stranger.address,
signature: '0x00',
message: `addCouncilMember:${stranger.address}`
})
assert.equal(res.statusCode, 500)
assert.equal(res.body.error.key, 'errors.server.founder_not_configured')
})
test('add-member: 401 on invalid signature', async () => {
process.env.FOUNDER_ADDRESS = founder.address
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
const message = `addCouncilMember:${stranger.address}`
const res = await request(app).post('/api/council/add-member').send({
newMemberAddress: stranger.address,
signature: sign(stranger, message), // well-formed, but NOT the founder's key
message
})
assert.equal(res.statusCode, 401)
assert.equal(res.body.error.key, 'errors.auth.invalid_signature')
})
test('add-member: 400 when signed message does not match the action', async () => {
process.env.FOUNDER_ADDRESS = founder.address
const message = 'addCouncilMember:5SomeoneElse'
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
const res = await request(app).post('/api/council/add-member').send({
newMemberAddress: stranger.address,
signature: sign(founder, message),
message
})
assert.equal(res.statusCode, 400)
assert.equal(res.body.error.key, 'errors.request.message_mismatch')
})
test('add-member: 200 on valid founder signature', async () => {
process.env.FOUNDER_ADDRESS = founder.address
const message = `addCouncilMember:${stranger.address}`
const supabase = makeFakeSupabase()
const app = createApp({ supabase, logger: silentLogger })
const res = await request(app).post('/api/council/add-member').send({
newMemberAddress: stranger.address,
signature: sign(founder, message),
message
})
assert.equal(res.statusCode, 200)
assert.equal(res.body.success, true)
assert.equal(supabase._tables.council_members.length, 1)
})
test('add-member: 409 when member already exists', async () => {
process.env.FOUNDER_ADDRESS = founder.address
const message = `addCouncilMember:${stranger.address}`
const supabase = makeFakeSupabase({ council_members: [{ address: stranger.address }] })
const app = createApp({ supabase, logger: silentLogger })
const res = await request(app).post('/api/council/add-member').send({
newMemberAddress: stranger.address,
signature: sign(founder, message),
message
})
assert.equal(res.statusCode, 409)
assert.equal(res.body.error.key, 'errors.council.member_exists')
})
// --- /api/kyc/propose -------------------------------------------------------
test('propose: 401 on invalid proposer signature', async () => {
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
const message = `proposeKYC:${user.address}`
const res = await request(app).post('/api/kyc/propose').send({
userAddress: user.address,
proposerAddress: proposer.address,
signature: sign(stranger, message), // signed by a different key than proposerAddress
message
})
assert.equal(res.statusCode, 401)
})
test('propose: 403 when proposer is not a council member', async () => {
const message = `proposeKYC:${user.address}`
const app = createApp({ supabase: makeFakeSupabase(), logger: silentLogger })
const res = await request(app).post('/api/kyc/propose').send({
userAddress: user.address,
proposerAddress: proposer.address, // not seeded into council_members
signature: sign(proposer, message),
message
})
assert.equal(res.statusCode, 403)
assert.equal(res.body.error.key, 'errors.auth.proposer_not_member')
})
test('propose: 201 and auto-executes when threshold reached (chain mocked)', async () => {
const message = `proposeKYC:${user.address}`
const supabase = makeFakeSupabase({ council_members: [{ address: proposer.address }] })
const { api, calls } = makeMockApi()
const app = createApp({
supabase,
getApi: () => api,
getSudo: () => ({ address: founder.address }), // stub signer
logger: silentLogger
})
const res = await request(app).post('/api/kyc/propose').send({
userAddress: user.address,
proposerAddress: proposer.address,
signature: sign(proposer, message),
message
})
assert.equal(res.statusCode, 201)
assert.equal(res.body.success, true)
// Single-member council → 60% threshold (ceil(1*0.6)=1) met by the auto-aye,
// so approveKyc was signed+sent for the user and the proposal marked executed.
assert.deepEqual(calls, [user.address])
const proposal = supabase._tables.kyc_proposals[0]
assert.equal(proposal.executed, true)
})
test('propose: does NOT execute below threshold (multi-member council)', async () => {
const message = `proposeKYC:${user.address}`
const supabase = makeFakeSupabase({
council_members: [
{ address: proposer.address },
{ address: founder.address },
{ address: stranger.address }
]
})
const { api, calls } = makeMockApi()
const app = createApp({
supabase,
getApi: () => api,
getSudo: () => ({ address: founder.address }),
logger: silentLogger
})
const res = await request(app).post('/api/kyc/propose').send({
userAddress: user.address,
proposerAddress: proposer.address,
signature: sign(proposer, message),
message
})
assert.equal(res.statusCode, 201)
// 3 members → required = ceil(3*0.6)=2, only 1 aye → no on-chain execution.
assert.deepEqual(calls, [])
assert.equal(supabase._tables.kyc_proposals[0].executed, false)
})
// --- /api/kyc/pending -------------------------------------------------------
test('GET /api/kyc/pending lists non-executed proposals', async () => {
const supabase = makeFakeSupabase({
kyc_proposals: [
{ user_address: user.address, proposer_address: proposer.address, executed: false },
{ user_address: stranger.address, proposer_address: proposer.address, executed: true }
]
})
const app = createApp({ supabase, logger: silentLogger })
const res = await request(app).get('/api/kyc/pending')
assert.equal(res.statusCode, 200)
assert.equal(res.body.pending.length, 1)
assert.equal(res.body.pending[0].user_address, user.address)
})
+105
View File
@@ -0,0 +1,105 @@
// Minimal in-memory Supabase stand-in.
//
// Implements exactly the query-builder surface the council/KYC handlers use:
// .from(t).insert(v)
// .from(t).select(cols).eq(c,v).single()
// .from(t).select('*', { count:'exact', head:true }).eq(...).eq(...)
// .from(t).update(v).eq(c,v)
// .from(t).select(cols).eq(c,v) (list)
// Unique-violation (23505) is simulated on council_members.address and
// kyc_proposals.user_address so the 409 paths are covered. Everything lives in
// plain arrays — no network, no real DB.
export function makeFakeSupabase (initial = {}) {
const tables = {
council_members: [],
kyc_proposals: [],
votes: [],
...structuredCloneSafe(initial)
}
const UNIQUE = {
council_members: 'address',
kyc_proposals: 'user_address'
}
function from (name) {
const rows = tables[name] || (tables[name] = [])
const builder = {
_filters: [],
_count: null,
_head: false,
_update: null,
insert (vals) {
const arr = Array.isArray(vals) ? vals : [vals]
for (const v of arr) {
const uniqueCol = UNIQUE[name]
if (uniqueCol && rows.some(r => r[uniqueCol] === v[uniqueCol])) {
return Promise.resolve({ error: { code: '23505' }, data: null })
}
rows.push({ id: rows.length + 1, executed: false, created_at: new Date().toISOString(), ...v })
}
return Promise.resolve({ error: null, data: null })
},
select (cols, opts) {
if (opts) {
this._count = opts.count || null
this._head = !!opts.head
}
return this
},
eq (col, val) {
this._filters.push([col, val])
return this
},
neq () { return this },
update (vals) {
this._update = vals
return this
},
_apply () {
return rows.filter(r => this._filters.every(([c, v]) => r[c] === v))
},
single () {
const found = this._apply()
if (found.length === 0) {
return Promise.resolve({ data: null, error: { code: 'PGRST116', message: 'no rows' } })
}
return Promise.resolve({ data: found[0], error: null })
},
// Thenable terminal for chains awaited without .single()
then (resolve, reject) {
try {
const found = this._apply()
if (this._update) {
found.forEach(r => Object.assign(r, this._update))
return resolve({ error: null, data: found })
}
if (this._count) {
return resolve({ count: found.length, error: null, data: this._head ? null : found })
}
return resolve({ data: found, error: null })
} catch (err) {
return reject(err)
}
}
}
return builder
}
return { from, _tables: tables }
}
function structuredCloneSafe (obj) {
const out = {}
for (const [k, v] of Object.entries(obj)) out[k] = v.map(r => ({ ...r }))
return out
}
+152
View File
@@ -0,0 +1,152 @@
// Offline indexer tests — in-memory sqlite, no live chain.
// Run: node --test
import { test } from 'node:test'
import assert from 'node:assert/strict'
import request from 'supertest'
import { initDb, saveTransfer, parseExtrinsic, indexBlock, createApp } from '../src/indexer.js'
// --- fixtures ---------------------------------------------------------------
// Build a decoded-extrinsic look-alike matching the shape parseExtrinsic reads.
function mkEx ({ section, method, args, hash, signer }) {
return {
method: { section, method, args },
signer: { toString: () => signer },
hash: { toHex: () => hash }
}
}
const scalar = (s) => ({ toString: () => s })
const numeric = (n) => ({ toNumber: () => n })
// --- parseExtrinsic ---------------------------------------------------------
test('parseExtrinsic maps native balances.transfer to HEZ', () => {
const ex = mkEx({
section: 'balances',
method: 'transfer',
args: [scalar('5Receiver'), scalar('1000')],
hash: '0xhez1',
signer: '5Sender'
})
const t = parseExtrinsic(ex, 42)
assert.deepEqual(t, {
hash: '0xhez1',
sender: '5Sender',
receiver: '5Receiver',
amount: '1000',
asset_id: null,
symbol: 'HEZ',
block_number: 42
})
})
test('parseExtrinsic maps balances.transferKeepAlive to HEZ', () => {
const ex = mkEx({
section: 'balances',
method: 'transferKeepAlive',
args: [scalar('5R'), scalar('7')],
hash: '0xhez2',
signer: '5S'
})
assert.equal(parseExtrinsic(ex, 1).symbol, 'HEZ')
})
test('parseExtrinsic maps asset ids to PEZ/USDT/ASSET-n', () => {
const mk = (id) => mkEx({
section: 'assets',
method: 'transfer',
args: [numeric(id), scalar('5R'), scalar('9')],
hash: `0xa${id}`,
signer: '5S'
})
assert.equal(parseExtrinsic(mk(1), 1).symbol, 'PEZ')
assert.equal(parseExtrinsic(mk(1000), 1).symbol, 'USDT')
assert.equal(parseExtrinsic(mk(5), 1).symbol, 'ASSET-5')
assert.equal(parseExtrinsic(mk(5), 1).asset_id, 5)
})
test('parseExtrinsic ignores non-transfer extrinsics', () => {
const ex = mkEx({
section: 'system',
method: 'remark',
args: [scalar('hi')],
hash: '0xnope',
signer: '5S'
})
assert.equal(parseExtrinsic(ex, 1), null)
})
// --- dedup + persistence ----------------------------------------------------
test('saveTransfer dedups on hash (INSERT OR IGNORE)', async () => {
const db = await initDb(':memory:')
const tx = {
hash: '0xdup',
sender: '5S',
receiver: '5R',
amount: '100',
asset_id: null,
symbol: 'HEZ',
block_number: 10
}
await saveTransfer(db, tx)
await saveTransfer(db, tx) // same hash again — must be a no-op
const { total } = await db.get('SELECT COUNT(*) as total FROM transfers')
assert.equal(total, 1)
await db.close()
})
test('indexBlock persists only transfer extrinsics from a block', async () => {
const db = await initDb(':memory:')
const signedBlock = {
block: {
extrinsics: [
mkEx({ section: 'balances', method: 'transfer', args: [scalar('5R'), scalar('1')], hash: '0x1', signer: '5A' }),
mkEx({ section: 'system', method: 'remark', args: [scalar('x')], hash: '0x2', signer: '5A' }),
mkEx({ section: 'assets', method: 'transfer', args: [numeric(1000), scalar('5R'), scalar('2')], hash: '0x3', signer: '5B' })
]
}
}
await indexBlock(db, signedBlock, 99)
const rows = await db.all('SELECT hash, symbol FROM transfers ORDER BY hash')
assert.equal(rows.length, 2)
assert.deepEqual(rows.map(r => r.symbol).sort(), ['HEZ', 'USDT'])
await db.close()
})
// --- HTTP surface -----------------------------------------------------------
test('GET /health returns 200 ok', async () => {
const db = await initDb(':memory:')
const app = createApp(db)
const res = await request(app).get('/health')
assert.equal(res.statusCode, 200)
assert.equal(res.body.status, 'ok')
await db.close()
})
test('GET /api/stats reflects indexed count', async () => {
const db = await initDb(':memory:')
await saveTransfer(db, { hash: '0xs1', sender: 'a', receiver: 'b', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 1 })
const app = createApp(db)
const res = await request(app).get('/api/stats')
assert.equal(res.statusCode, 200)
assert.equal(res.body.total, 1)
await db.close()
})
test('GET /api/history/:address filters by sender or receiver', async () => {
const db = await initDb(':memory:')
await saveTransfer(db, { hash: '0xh1', sender: 'alice', receiver: 'bob', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 2 })
await saveTransfer(db, { hash: '0xh2', sender: 'carol', receiver: 'dave', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 3 })
await saveTransfer(db, { hash: '0xh3', sender: 'eve', receiver: 'alice', amount: '1', asset_id: null, symbol: 'HEZ', block_number: 4 })
const app = createApp(db)
const res = await request(app).get('/api/history/alice')
assert.equal(res.statusCode, 200)
assert.equal(res.body.length, 2) // sent one, received one
const hashes = res.body.map(r => r.hash).sort()
assert.deepEqual(hashes, ['0xh1', '0xh3'])
await db.close()
})
+3 -3
View File
@@ -6,16 +6,16 @@
"scripts": {
"build": "npm run build:web && npm run build:sdk-ui && npm run build:mobile",
"build:web": "cd web && npm run build",
"build:sdk-ui": "cd /home/mamostehp/pezkuwi-sdk-ui && yarn build",
"build:sdk-ui": "cd \"${SDK_UI_DIR:-../pezkuwi-sdk-ui}\" 2>/dev/null && yarn build || echo 'sdk-ui: skipped (set SDK_UI_DIR to the pezkuwi-sdk-ui checkout to enable)'",
"build:mobile": "cd mobile && npx expo export --platform web",
"build:parallel": "concurrently \"npm run build:web\" \"npm run build:sdk-ui\"",
"dev": "concurrently \"npm run dev:web\" \"npm run dev:mobile\"",
"dev:web": "cd web && npm run dev",
"dev:sdk-ui": "cd /home/mamostehp/pezkuwi-sdk-ui && yarn start",
"dev:sdk-ui": "cd \"${SDK_UI_DIR:-../pezkuwi-sdk-ui}\" && yarn start",
"dev:mobile": "cd mobile && npm run dev",
"install:all": "npm run install:web && npm run install:sdk-ui && npm run install:mobile && npm run install:backend",
"install:web": "cd web && npm install",
"install:sdk-ui": "cd /home/mamostehp/pezkuwi-sdk-ui && yarn install",
"install:sdk-ui": "cd \"${SDK_UI_DIR:-../pezkuwi-sdk-ui}\" 2>/dev/null && yarn install || echo 'sdk-ui: skipped (set SDK_UI_DIR to the pezkuwi-sdk-ui checkout to enable)'",
"install:mobile": "cd mobile && npm install",
"install:backend": "cd backend && npm install",
"lint": "npm run lint:web && npm run lint:mobile",
+198 -111
View File
@@ -119,6 +119,8 @@ export interface P2PReputation {
export interface CreateOfferParams {
userId: string;
/** Citizen/visa identity id (needed to authorize the escrow lock) */
identityId: string;
sellerWallet: string;
token: CryptoToken;
amountCrypto: number;
@@ -130,6 +132,8 @@ export interface CreateOfferParams {
minOrderAmount?: number;
maxOrderAmount?: number;
adType?: 'buy' | 'sell';
/** Signs a message with the wallet that owns `identityId` (signRaw) */
signMessage: (message: string) => Promise<string>;
}
export interface AcceptOfferParams {
@@ -375,9 +379,54 @@ async function decryptPaymentDetails(encrypted: string): Promise<Record<string,
*
* NOTE: Blockchain transactions only occur at deposit/withdraw
*/
/**
* Canonical lock-escrow challenge. MUST be byte-identical to the server
* buildLockEscrowChallenge (web/supabase/functions/_shared/identity-auth.ts).
*/
export function buildLockEscrowChallenge(p: {
identityId: string;
token: string;
amount: number;
signerAddress: string;
timestamp: number;
nonce: string;
}): string {
return [
'Pezkuwi P2P Lock Escrow',
`identity:${p.identityId}`,
`token:${p.token}`,
`amount:${p.amount}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n');
}
/**
* Canonical confirm-payment challenge. MUST be byte-identical to the server
* buildConfirmPaymentChallenge (web/supabase/functions/_shared/identity-auth.ts).
*/
export function buildConfirmPaymentChallenge(p: {
tradeId: string;
sellerIdentity: string;
signerAddress: string;
timestamp: number;
nonce: string;
}): string {
return [
'Pezkuwi P2P Confirm Payment',
`trade:${p.tradeId}`,
`identity:${p.sellerIdentity}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n');
}
export async function createFiatOffer(params: CreateOfferParams): Promise<string> {
const {
userId,
identityId,
sellerWallet,
token,
amountCrypto,
@@ -388,19 +437,42 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
timeLimitMinutes = DEFAULT_PAYMENT_DEADLINE_MINUTES,
minOrderAmount,
maxOrderAmount,
adType = 'sell'
adType = 'sell',
signMessage
} = params;
try {
if (!userId) throw new Error('Identity required for P2P trading');
if (!identityId) throw new Error('Identity required for P2P trading');
if (!sellerWallet) throw new Error('Connect a wallet to create an offer');
toast.info('Locking crypto from your balance...');
// 1. Lock crypto from internal balance via the authorized edge function.
// lock_escrow_internal is no longer callable with the anon key; the
// seller must sign a challenge proving they own the identity being locked.
const lockTimestamp = Date.now();
const lockNonce = `lk-${lockTimestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
const lockChallenge = buildLockEscrowChallenge({
identityId,
token,
amount: amountCrypto,
signerAddress: sellerWallet,
timestamp: lockTimestamp,
nonce: lockNonce,
});
// 1. Lock crypto from internal balance (NO blockchain tx!)
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
p_user_id: userId,
p_token: token,
p_amount: amountCrypto
toast.info('Sign to lock crypto from your balance...');
const lockSignature = await signMessage(lockChallenge);
const { data: lockResult, error: lockError } = await supabase.functions.invoke('lock-escrow', {
body: {
identityId,
token,
amount: amountCrypto,
signerAddress: sellerWallet,
signature: lockSignature,
timestamp: lockTimestamp,
nonce: lockNonce,
},
});
if (lockError) throw lockError;
@@ -408,8 +480,8 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
// Parse result
const lockResponse = typeof lockResult === 'string' ? JSON.parse(lockResult) : lockResult;
if (!lockResponse.success) {
throw new Error(lockResponse.error || 'Failed to lock balance');
if (!lockResponse?.success) {
throw new Error(lockResponse?.error || 'Failed to lock balance');
}
toast.success('Balance locked successfully');
@@ -443,18 +515,9 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
if (offerError) throw offerError;
// 4. Update the lock with offer reference
try {
await supabase.rpc('lock_escrow_internal', {
p_user_id: userId,
p_token: token,
p_amount: 0, // Just updating reference, not locking more
p_reference_type: 'offer',
p_reference_id: offer.id
});
} catch {
// Non-critical, just for tracking
}
// (Removed) The previous amount=0 lock_escrow_internal "reference update" call
// was a no-op that only logged a zero-amount ledger row. lock_escrow_internal
// is now service-role only, so this cosmetic call is dropped entirely.
// 5. Audit log
await logAction('offer', offer.id, 'create_offer', {
@@ -632,83 +695,54 @@ export async function markPaymentSent(
*
* Buyer can later withdraw to external wallet if needed (separate blockchain tx).
*/
export async function confirmPaymentReceived(tradeId: string, sellerId: string): Promise<void> {
export async function confirmPaymentReceived(
tradeId: string,
sellerIdentity: string,
signerAddress: string,
signMessage: (message: string) => Promise<string>
): Promise<void> {
try {
if (!sellerId) throw new Error('Identity required for P2P trading');
if (!sellerIdentity) throw new Error('Identity required for P2P trading');
if (!signerAddress) throw new Error('Connect a wallet to release escrow');
// 2. Get trade details
const { data: trade, error: tradeError } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
if (tradeError) throw tradeError;
if (!trade) throw new Error('Trade not found');
// Verify caller is the seller
if (trade.seller_id !== sellerId) {
throw new Error('Only seller can confirm payment');
}
if (trade.status !== 'payment_sent') {
throw new Error('Payment has not been marked as sent');
}
// 3. Get offer to get token type
const { data: offer } = await supabase
.from('p2p_fiat_offers')
.select('token')
.eq('id', trade.offer_id)
.single();
if (!offer) throw new Error('Offer not found');
toast.info('Releasing crypto to buyer...');
// 4. Release escrow internally (NO blockchain tx!)
// This transfers from seller's locked_balance to buyer's available_balance
const { data: releaseResult, error: releaseError } = await supabase.rpc('release_escrow_internal', {
p_from_user_id: trade.seller_id,
p_to_user_id: trade.buyer_id,
p_token: offer.token,
p_amount: trade.crypto_amount,
p_reference_type: 'trade',
p_reference_id: tradeId
// Release is a money-OUT action: it is authorized ONLY via the confirm-payment
// edge function, which verifies the seller's wallet signature + identity
// ownership before calling release_escrow_internal with the service role.
// release_escrow_internal is no longer callable with the anon key.
const timestamp = Date.now();
const nonce = `cp-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
const challenge = buildConfirmPaymentChallenge({
tradeId,
sellerIdentity,
signerAddress,
timestamp,
nonce,
});
if (releaseError) throw releaseError;
toast.info('Sign to release crypto to the buyer...');
const signature = await signMessage(challenge);
// Parse result
const releaseResponse = typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
const { data, error } = await supabase.functions.invoke('confirm-payment', {
body: { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce },
});
if (!releaseResponse.success) {
throw new Error(releaseResponse.error || 'Failed to release escrow');
if (error) throw error;
if (!data?.success) {
throw new Error(data?.error || 'Failed to release escrow');
}
// 5. Update trade status
const { error: updateError } = await supabase
.from('p2p_fiat_trades')
.update({
seller_confirmed_at: new Date().toISOString(),
escrow_released_at: new Date().toISOString(),
status: 'completed',
completed_at: new Date().toISOString()
// NOTE: No escrow_release_tx_hash - internal ledger doesn't use blockchain during trades
})
.eq('id', tradeId);
if (updateError) throw updateError;
// 6. Update reputations
await updateReputations(trade.seller_id, trade.buyer_id, tradeId);
// 7. Audit log
await logAction('trade', tradeId, 'confirm_payment', {
released_amount: trade.crypto_amount,
token: offer.token,
escrow_type: 'internal_ledger'
}, sellerId);
// Non-financial follow-ups (best effort). The escrow move + trade status
// change already happened atomically server-side.
try {
await updateReputations(data.sellerId, data.buyerId, tradeId);
await logAction('trade', tradeId, 'confirm_payment', {
released_amount: data.amount,
token: data.token,
escrow_type: 'internal_ledger'
}, data.sellerId);
} catch (followupErr) {
console.error('Post-release follow-up failed (non-critical):', followupErr);
}
toast.success('Payment confirmed! Crypto released to buyer\'s balance.');
} catch (error: unknown) {
@@ -1023,18 +1057,53 @@ export async function getInternalBalance(userId: string, token: CryptoToken): Pr
}
/**
* Request a withdrawal from internal balance to external wallet
* Calls the process-withdraw edge function which handles the full flow:
* limit check → lock balance → blockchain TX → complete withdrawal
* Canonical withdrawal challenge string. MUST be byte-identical to the server's
* buildWithdrawChallenge (web/supabase/functions/_shared/identity-auth.ts).
*/
export function buildWithdrawChallenge(p: {
identityId: string;
token: string;
amount: number;
destination: string;
signerAddress: string;
timestamp: number;
nonce: string;
}): string {
return [
'Pezkuwi P2P Withdrawal',
`identity:${p.identityId}`,
`token:${p.token}`,
`amount:${p.amount}`,
`destination:${p.destination}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n');
}
/**
* Request a withdrawal from internal balance to external wallet.
*
* AUTHORIZATION: the caller must prove ownership of the P2P identity by signing
* a challenge with the wallet that owns it (citizen NFT / visa). The edge
* function derives the user_id server-side from `identityId` after verifying the
* signature and identity ownership — a client-supplied user_id is never trusted.
*
* @param identityId Citizen number (#42-<item>-<6d>) or visa number (V-XXXXXX)
* @param signerAddress The connected wallet (must own the identity)
* @param signMessage Signs a message with the connected wallet (signRaw)
*/
export async function requestWithdraw(
userId: string,
identityId: string,
token: CryptoToken,
amount: number,
walletAddress: string
walletAddress: string,
signerAddress: string,
signMessage: (message: string) => Promise<string>
): Promise<string> {
try {
if (!userId) throw new Error('Identity required for P2P trading');
if (!identityId) throw new Error('Identity required for P2P trading');
if (!signerAddress) throw new Error('Connect a wallet to withdraw');
// Validate amount
if (amount <= 0) throw new Error('Amount must be greater than 0');
@@ -1044,10 +1113,26 @@ export async function requestWithdraw(
throw new Error('Invalid wallet address');
}
// Build + sign the authorization challenge binding the full withdrawal intent.
const timestamp = Date.now();
const nonce = `wd-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
const challenge = buildWithdrawChallenge({
identityId,
token,
amount,
destination: walletAddress,
signerAddress,
timestamp,
nonce,
});
toast.info('Sign the request in your wallet to authorize the withdrawal...');
const signature = await signMessage(challenge);
toast.info('Processing withdrawal...');
const { data, error, response } = await supabase.functions.invoke('process-withdraw', {
body: { userId, token, amount, walletAddress }
body: { identityId, token, amount, walletAddress, signerAddress, signature, timestamp, nonce }
});
// Supabase client wraps non-2xx as generic FunctionsHttpError (data=null).
@@ -1085,15 +1170,14 @@ export async function getDepositWithdrawHistory(userId: string): Promise<Deposit
try {
if (!userId) throw new Error('Identity required for P2P trading');
const { data, error } = await supabase
.from('p2p_deposit_withdraw_requests')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Scoped SECURITY DEFINER RPC (direct table SELECT is no longer world-open).
const { data, error } = await supabase.rpc('get_user_deposit_withdraw_requests', {
p_user_id: userId,
p_limit: 50,
});
if (error) throw error;
return data || [];
return (data as DepositWithdrawRequest[]) || [];
} catch (error) {
console.error('Get deposit/withdraw history error:', error);
return [];
@@ -1103,16 +1187,19 @@ export async function getDepositWithdrawHistory(userId: string): Promise<Deposit
/**
* Get user's balance transaction history
*/
export async function getBalanceTransactionHistory(limit: number = 50): Promise<BalanceTransaction[]> {
export async function getBalanceTransactionHistory(userId: string, limit: number = 50): Promise<BalanceTransaction[]> {
try {
const { data, error } = await supabase
.from('p2p_balance_transactions')
.select('*')
.order('created_at', { ascending: false })
.limit(limit);
if (!userId) throw new Error('Identity required for P2P trading');
// Scoped SECURITY DEFINER RPC — returns only this user's ledger rows
// (the previous unfiltered SELECT * exposed every user's transactions).
const { data, error } = await supabase.rpc('get_user_balance_transactions', {
p_user_id: userId,
p_limit: limit,
});
if (error) throw error;
return data || [];
return (data as BalanceTransaction[]) || [];
} catch (error) {
console.error('Get balance transaction history error:', error);
return [];
+12
View File
@@ -445,6 +445,10 @@ export async function contribute(
if (status.isInBlock) {
onStatus?.('Transaction in block...');
}
if (status.isDropped || status.isInvalid || status.isUsurped || status.isRetracted) {
resolve({ success: false, error: 'Transaction was dropped or invalidated by the network. Please try again.' });
return;
}
if (status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Transaction failed';
@@ -489,6 +493,10 @@ export async function refund(
if (status.isInBlock) {
onStatus?.('Refund in block...');
}
if (status.isDropped || status.isInvalid || status.isUsurped || status.isRetracted) {
resolve({ success: false, error: 'Refund was dropped or invalidated by the network. Please try again.' });
return;
}
if (status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Refund failed';
@@ -533,6 +541,10 @@ export async function claimVested(
if (status.isInBlock) {
onStatus?.('Claim in block...');
}
if (status.isDropped || status.isInvalid || status.isUsurped || status.isRetracted) {
resolve({ success: false, error: 'Claim was dropped or invalidated by the network. Please try again.' });
return;
}
if (status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Claim failed';
+1 -1
View File
@@ -49,5 +49,5 @@ WORKDIR /dist
COPY --from=builder /build/web/dist /dist
LABEL org.opencontainers.image.source="https://github.com/pezkuwichain/pwap"
LABEL org.opencontainers.image.description="pwap/web static SPA — Pezkuwi wallet/exchange frontend"
LABEL org.opencontainers.image.licenses="proprietary"
LABEL org.opencontainers.image.licenses="MIT"
CMD ["sh", "-c", "echo 'pwap-web image — extract /dist via: docker create + docker cp'; sleep infinity"]
+2
View File
@@ -8,6 +8,8 @@ export default tseslint.config(
{
ignores: [
"dist/**",
"supabase/functions/**",
"supabase/migrations/**",
"node_modules/**",
"eslint.config.js",
"postcss.config.js",
+17 -2
View File
@@ -17,8 +17,23 @@ const rustdocDestPath = path.join(publicPath, 'sdk_docs'); // Destination for BU
const structureOutputPath = path.join(publicPath, 'docs-structure.json');
const rustdocBuildOutputPath = path.join(pezkuwiSdkRoot, 'target', 'doc'); // Output of cargo doc
// Absolute path to rustup (used to build rustdoc)
const rustupPath = '/home/mamostehp/.cargo/bin/rustup';
// Path to rustup (used to build rustdoc). Resolve dynamically instead of
// hardcoding a per-machine absolute path: prefer $RUSTUP_PATH, then whatever
// is on PATH, then the conventional ~/.cargo/bin/rustup, then bare 'rustup'.
function resolveRustupPath() {
if (process.env.RUSTUP_PATH) return process.env.RUSTUP_PATH;
try {
const which = spawnSync('bash', ['-lc', 'command -v rustup'], { encoding: 'utf8' });
if (which.status === 0 && which.stdout && which.stdout.trim()) {
return which.stdout.trim();
}
} catch { /* fall through */ }
const home = process.env.HOME || require('os').homedir();
const cargoRustup = path.join(home, '.cargo', 'bin', 'rustup');
if (fs.existsSync(cargoRustup)) return cargoRustup;
return 'rustup';
}
const rustupPath = resolveRustupPath();
// Path to the rebranding script (now .cjs)
const rebrandScriptPath = path.join(__dirname, 'rebrand-rustdoc.cjs');
-36907
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -58,7 +58,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.3.1",
"dompurify": "^3.4.12",
"embla-carousel-react": "^8.3.0",
"highlight.js": "^11.9.0",
"i18next": "^23.7.6",
@@ -103,7 +103,7 @@
"globals": "^15.15.0",
"husky": "^9.1.7",
"jsdom": "^27.2.0",
"postcss": "^8.4.47",
"postcss": "^8.5.23",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
@@ -7651,9 +7651,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -10054,9 +10054,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -10800,9 +10800,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
@@ -10819,7 +10819,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+3 -2
View File
@@ -8,6 +8,7 @@
"dev": "cp .env.development .env && vite",
"prebuild": "node generate-docs-structure.cjs",
"build": "vite build",
"typecheck": "tsc --noEmit -p tsconfig.app.json",
"build:telegram": "vite build --config vite.config.telegram.ts",
"build:dev": "cp .env.development .env && vite build",
"build:alfa": "cp .env.alfa .env && vite build",
@@ -70,7 +71,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"dompurify": "^3.3.1",
"dompurify": "^3.4.12",
"embla-carousel-react": "^8.3.0",
"highlight.js": "^11.9.0",
"i18next": "^23.7.6",
@@ -142,7 +143,7 @@
"globals": "^15.15.0",
"husky": "^9.1.7",
"jsdom": "^27.2.0",
"postcss": "^8.4.47",
"postcss": "^8.5.23",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
+4 -4
View File
@@ -659,9 +659,9 @@ const AppLayout: React.FC = () => {
<div className="lp-foot-col">
<h5>{t('landing.footer.network')}</h5>
<ul>
<li><a href="/network">{t('landing.footer.explorer')}</a></li>
<li><a href="/explorer">{t('landing.footer.explorer')}</a></li>
<li><a href="/telemetry">{t('landing.footer.telemetry')}</a></li>
<li><a href="/network">{t('landing.footer.validators')}</a></li>
<li><a href="/explorer">{t('landing.footer.validators')}</a></li>
<li><a href="/faucet">{t('landing.footer.faucet')}</a></li>
</ul>
</div>
@@ -670,7 +670,7 @@ const AppLayout: React.FC = () => {
<ul>
<li><a href="/wallet">{t('landing.footer.wallet')}</a></li>
<li><a href="/p2p">{t('landing.footer.trade')}</a></li>
<li><a href="/">{t('landing.footer.vote')}</a></li>
<li><a href="/elections">{t('landing.footer.vote')}</a></li>
<li><a href="/grants">{t('landing.footer.grants')}</a></li>
</ul>
</div>
@@ -689,7 +689,7 @@ const AppLayout: React.FC = () => {
<li><a href="/forum">{t('landing.footer.forum')}</a></li>
<li><a href="https://discord.gg/pezkuwichain" target="_blank" rel="noopener noreferrer">{t('landing.footer.discord')}</a></li>
<li><a href="https://t.me/PezkuwiApp" target="_blank" rel="noopener noreferrer">{t('landing.footer.telegram')}</a></li>
<li><a href="https://x.com/PezkuwiChain" target="_blank" rel="noopener noreferrer">{t('landing.footer.twitter')}</a></li>
<li><a href="https://x.com/bizinikiwi" target="_blank" rel="noopener noreferrer">{t('landing.footer.twitter')}</a></li>
</ul>
</div>
</div>
+6
View File
@@ -151,6 +151,12 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
return <Navigate to="/login" replace />;
}
// NOTE: `isAdmin` here is a COSMETIC UX gate only (derived from a localStorage
// wallet and forgeable in DevTools). It prevents casual access to the admin UI
// but is NOT a security boundary. All privileged operations behind this route
// (e.g. dispute resolution) independently verify admin authority server-side
// via a wallet signature (resolve-dispute edge function). Never rely on this
// check alone to protect funds or state.
if (requireAdmin && !isAdmin) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
+32 -27
View File
@@ -20,6 +20,8 @@ import {
} from '@/components/ui/select';
import { ArrowRight, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { MINTABLE_ASSETS } from './dex/mintableAssets';
import { parseTokenInput } from '@pezkuwi/utils/dex';
interface TokenBalance {
assetId: number;
@@ -36,7 +38,7 @@ interface TransferModalProps {
selectedAsset?: TokenBalance | null;
}
type TokenType = 'HEZ' | 'PEZ' | 'USDT' | 'BTC' | 'ETH' | 'DOT';
type TokenType = 'HEZ' | 'PEZ' | 'wUSDT' | 'wDOT' | 'wETH' | 'wBTC';
interface Token {
symbol: TokenType;
@@ -51,19 +53,26 @@ const TOKEN_LOGOS: Record<string, string> = {
HEZ: '/tokens/HEZ.png',
PEZ: '/tokens/PEZ.png',
USDT: '/tokens/USDT.png',
wUSDT: '/tokens/USDT.png',
wDOT: '/tokens/DOT.png',
wETH: '/tokens/ETH.png',
wBTC: '/tokens/BTC.png',
BTC: '/tokens/BTC.png',
ETH: '/tokens/ETH.png',
DOT: '/tokens/DOT.png',
BNB: '/tokens/BNB.png',
};
// Wrapped assets (id + decimals) are sourced from the single source of truth
// (MINTABLE_ASSETS in dex/mintableAssets.ts): wUSDT=1000, wDOT=1001, wETH=1002, wBTC=1003.
// All of these live on Asset Hub (assets pallet). Only native HEZ is on the relay chain.
const TOKENS: Token[] = [
{ symbol: 'HEZ', name: 'Hez Token', decimals: 12, color: 'from-green-600 to-yellow-400' },
{ symbol: 'PEZ', name: 'Pez Token', assetId: 1, decimals: 12, color: 'from-blue-600 to-purple-400' },
{ symbol: 'USDT', name: 'Tether USD', assetId: 1000, decimals: 6, color: 'from-green-500 to-green-600' },
{ symbol: 'BTC', name: 'Bitcoin', assetId: 3, decimals: 8, color: 'from-orange-500 to-yellow-500' },
{ symbol: 'ETH', name: 'Ethereum', assetId: 4, decimals: 18, color: 'from-purple-500 to-blue-500' },
{ symbol: 'DOT', name: 'Polkadot', assetId: 5, decimals: 10, color: 'from-pink-500 to-red-500' },
{ symbol: 'wUSDT', name: MINTABLE_ASSETS.wUSDT.name, assetId: MINTABLE_ASSETS.wUSDT.id, decimals: MINTABLE_ASSETS.wUSDT.decimals, color: 'from-green-500 to-green-600' },
{ symbol: 'wDOT', name: MINTABLE_ASSETS.wDOT.name, assetId: MINTABLE_ASSETS.wDOT.id, decimals: MINTABLE_ASSETS.wDOT.decimals, color: 'from-pink-500 to-red-500' },
{ symbol: 'wETH', name: MINTABLE_ASSETS.wETH.name, assetId: MINTABLE_ASSETS.wETH.id, decimals: MINTABLE_ASSETS.wETH.decimals, color: 'from-purple-500 to-blue-500' },
{ symbol: 'wBTC', name: MINTABLE_ASSETS.wBTC.name, assetId: MINTABLE_ASSETS.wBTC.id, decimals: MINTABLE_ASSETS.wBTC.decimals, color: 'from-orange-500 to-yellow-500' },
];
export const TransferModal: React.FC<TransferModalProps> = ({ isOpen, onClose, selectedAsset }) => {
@@ -99,14 +108,11 @@ export const TransferModal: React.FC<TransferModalProps> = ({ isOpen, onClose, s
return;
}
// Check if Asset Hub transfer (PEZ, wUSDT, wHEZ are on Asset Hub)
const isAssetHubTransfer = currentToken.symbol === 'PEZ' ||
currentToken.symbol === 'USDT' ||
currentToken.symbol === 'wUSDT' ||
currentToken.symbol === 'wHEZ' ||
currentToken.assetId === 1 || // PEZ
currentToken.assetId === 2 || // wHEZ
currentToken.assetId === 1000; // wUSDT
// Native HEZ (no assetId, or assetId 0) transfers on the relay chain.
// Every other token is an Asset Hub assets-pallet asset (PEZ, wHEZ, wUSDT,
// wDOT, wETH, wBTC and any custom token), so it must route through Asset Hub.
const isNativeHez = currentToken.assetId === undefined || currentToken.assetId === 0;
const isAssetHubTransfer = !isNativeHez;
if (isAssetHubTransfer && (!assetHubApi || !isAssetHubReady)) {
toast({
title: t('transfer.error'),
@@ -133,25 +139,24 @@ export const TransferModal: React.FC<TransferModalProps> = ({ isOpen, onClose, s
const { getSigner } = await import('@/lib/get-signer');
const injector = await getSigner(selectedAccount.address, walletSource, isAssetHubTransfer ? assetHubApi : api);
// Convert amount to smallest unit
const amountInSmallestUnit = BigInt(parseFloat(amount) * Math.pow(10, currentToken.decimals));
// Convert amount to smallest unit using string-based BigInt parsing.
// (BigInt(parseFloat(amount) * 10**decimals) throws a RangeError on
// fractional amounts because the intermediate double is non-integer.)
const amountInSmallestUnit = parseTokenInput(amount, currentToken.decimals);
let transfer;
let targetApi = api; // Default to main chain API
// Create appropriate transfer transaction based on token type
// HEZ uses native token transfer (balances pallet on main chain)
// PEZ, wHEZ, wUSDT use assets pallet on Asset Hub
if (currentToken.assetId === undefined || (selectedToken === 'HEZ' && !selectedAsset)) {
// Native HEZ token transfer on main chain
transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit.toString());
} else if (isAssetHubTransfer) {
// Asset Hub transfer (PEZ, wHEZ, wUSDT)
targetApi = assetHubApi!;
transfer = assetHubApi!.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString());
// Create appropriate transfer transaction based on token type.
// HEZ uses native token transfer (balances pallet on the relay chain).
// Every asset (PEZ, wHEZ, wUSDT, wDOT, wETH, wBTC, custom) is on Asset Hub.
if (isNativeHez) {
// Native HEZ token transfer on the relay chain
transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit);
} else {
// Other asset token transfers on main chain
transfer = api.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString());
// Asset Hub transfer (assets pallet)
targetApi = assetHubApi!;
transfer = assetHubApi!.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit);
}
setTxStatus('pending');
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { supabase } from '@/lib/supabase';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useWallet } from '@/contexts/WalletContext';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -111,8 +113,35 @@ const CATEGORY_KEYS: Record<string, string> = {
other: 'dispute.categoryOther'
};
/**
* Canonical admin-action challenge. MUST be byte-identical to the server
* buildAdminChallenge (web/supabase/functions/_shared/identity-auth.ts).
*/
function buildAdminChallenge(p: {
action: string;
disputeId: string;
tradeId: string;
decision: string;
adminAddress: string;
timestamp: number;
nonce: string;
}): string {
return [
'Pezkuwi P2P Admin Action',
`action:${p.action}`,
`dispute:${p.disputeId}`,
`trade:${p.tradeId}`,
`decision:${p.decision}`,
`admin:${p.adminAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n');
}
export function DisputeResolutionPanel() {
const { t } = useTranslation();
const { selectedAccount } = usePezkuwi();
const { signMessage } = useWallet();
const [disputes, setDisputes] = useState<Dispute[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
@@ -202,28 +231,46 @@ export function DisputeResolutionPanel() {
return true;
});
// Claim dispute for review
const claimDispute = async (disputeId: string) => {
// Sign an admin-action challenge with the connected wallet. The server
// (resolve-dispute edge function) verifies the signature AND that the wallet
// is in the admin set — this is the real authorization, not the client flag.
const signAdminAction = async (
action: 'claim' | 'resolve',
disputeId: string,
tradeId: string,
decision: string
) => {
if (!selectedAccount?.address) throw new Error('Connect an admin wallet');
const timestamp = Date.now();
const nonce = `adm-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
const challenge = buildAdminChallenge({
action,
disputeId,
tradeId,
decision,
adminAddress: selectedAccount.address,
timestamp,
nonce,
});
const signature = await signMessage(challenge);
return { adminAddress: selectedAccount.address, signature, timestamp, nonce };
};
// Claim dispute for review (server-verified admin action)
const claimDispute = async (disputeId: string, tradeId: string) => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { error } = await supabase
.from('p2p_fiat_disputes')
.update({
status: 'under_review',
assigned_moderator_id: user.id,
assigned_at: new Date().toISOString()
})
.eq('id', disputeId);
if (error) throw error;
toast.info(t('dispute.claimSignPrompt', 'Sign to claim this dispute...'));
const auth = await signAdminAction('claim', disputeId, tradeId, '');
const { data, error } = await supabase.functions.invoke('resolve-dispute', {
body: { action: 'claim', disputeId, tradeId, ...auth },
});
if (error || !data?.success) throw new Error(data?.error || error?.message || 'Claim failed');
toast.success(t('dispute.claimedToast'));
fetchDisputes();
} catch (error) {
console.error('Error claiming dispute:', error);
toast.error(t('dispute.claimFailed'));
toast.error(error instanceof Error ? error.message : t('dispute.claimFailed'));
}
};
@@ -236,52 +283,26 @@ export function DisputeResolutionPanel() {
setSubmitting(true);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Server-verified admin action. The edge function verifies the admin
// signature, then atomically MOVES ESCROW (release/refund/split) and
// updates trade + dispute status in one transaction. The client no longer
// relabels rows directly (which previously moved no funds).
toast.info(t('dispute.resolveSignPrompt', 'Sign to authorize the resolution...'));
const auth = await signAdminAction('resolve', selectedDispute.id, selectedDispute.trade_id, decision);
// Update dispute
const { error: disputeError } = await supabase
.from('p2p_fiat_disputes')
.update({
status: decision === 'escalate' ? 'escalated' : 'resolved',
const { data, error } = await supabase.functions.invoke('resolve-dispute', {
body: {
action: 'resolve',
disputeId: selectedDispute.id,
tradeId: selectedDispute.trade_id,
decision,
decision_reasoning: reasoning,
resolved_at: new Date().toISOString()
})
.eq('id', selectedDispute.id);
reasoning,
...auth,
},
});
if (disputeError) throw disputeError;
// Update trade status based on decision
if (decision !== 'escalate' && selectedDispute.trade) {
const tradeStatus = decision === 'release_to_buyer' ? 'completed' : 'refunded';
await supabase
.from('p2p_fiat_trades')
.update({ status: tradeStatus })
.eq('id', selectedDispute.trade_id);
}
// Create notifications for both parties
if (selectedDispute.trade) {
const notificationPromises = [
supabase.rpc('create_p2p_notification', {
p_user_id: selectedDispute.trade.seller_id,
p_type: 'dispute_resolved',
p_title: 'Dispute Resolved',
p_message: `The dispute has been resolved: ${t(DECISION_OPTION_KEYS.find(o => o.value === decision)?.labelKey || '')}`,
p_reference_type: 'dispute',
p_reference_id: selectedDispute.id
}),
supabase.rpc('create_p2p_notification', {
p_user_id: selectedDispute.trade.buyer_id,
p_type: 'dispute_resolved',
p_title: 'Dispute Resolved',
p_message: `The dispute has been resolved: ${t(DECISION_OPTION_KEYS.find(o => o.value === decision)?.labelKey || '')}`,
p_reference_type: 'dispute',
p_reference_id: selectedDispute.id
})
];
await Promise.all(notificationPromises);
if (error || !data?.success) {
throw new Error(data?.error || error?.message || 'Resolution failed');
}
toast.success(t('dispute.resolvedToast'));
@@ -292,7 +313,7 @@ export function DisputeResolutionPanel() {
fetchDisputes();
} catch (error) {
console.error('Error resolving dispute:', error);
toast.error(t('dispute.resolveFailed'));
toast.error(error instanceof Error ? error.message : t('dispute.resolveFailed'));
} finally {
setSubmitting(false);
}
@@ -475,7 +496,7 @@ export function DisputeResolutionPanel() {
{dispute.status === 'open' && (
<Button
size="sm"
onClick={() => claimDispute(dispute.id)}
onClick={() => claimDispute(dispute.id, dispute.trade_id)}
>
{t('dispute.claim')}
</Button>
@@ -10,6 +10,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { parseTokenInput } from '@pezkuwi/utils/dex';
interface InitializeHezPoolModalProps {
isOpen: boolean;
@@ -131,7 +132,7 @@ export const InitializeHezPoolModal: React.FC<InitializeHezPoolModalProps> = ({
return;
}
const amountRaw = BigInt(parseFloat(amount) * 10 ** 12);
const amountRaw = BigInt(parseTokenInput(amount, 12));
if (amountRaw <= BigInt(0)) {
setErrorMessage(t('common.amountGtZero'));
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { parseTokenInput } from '@pezkuwi/utils/dex';
interface InitializeUsdtModalProps {
isOpen: boolean;
@@ -86,7 +87,7 @@ export const InitializeUsdtModal: React.FC<InitializeUsdtModalProps> = ({
return;
}
const usdtAmountRaw = BigInt(parseFloat(usdtAmount) * 10 ** USDT_DECIMALS);
const usdtAmountRaw = BigInt(parseTokenInput(usdtAmount, USDT_DECIMALS));
if (usdtAmountRaw <= BigInt(0)) {
setErrorMessage(t('common.amountGtZero'));
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { parseTokenInput } from '@pezkuwi/utils/dex';
import {
checkBridgeStatus,
fetchAssetHubUsdtInfo,
@@ -160,8 +161,8 @@ export const XCMBridgeSetupModal: React.FC<XCMBridgeSetupModalProps> = ({
try {
// Convert amounts to raw values (6 decimals for wUSDT, 12 for HEZ)
const wusdtRaw = BigInt(parseFloat(wusdtAmount) * 10 ** 6).toString();
const hezRaw = BigInt(parseFloat(hezAmount) * 10 ** 12).toString();
const wusdtRaw = parseTokenInput(wusdtAmount, 6);
const hezRaw = parseTokenInput(hezAmount, 12);
await createWUsdtHezPool(
assetHubApi,
+17 -1
View File
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useWebSocket } from '@/contexts/WebSocketContext';
import { useToast } from '@/hooks/use-toast';
import DOMPurify from 'dompurify';
interface Comment {
id: string;
@@ -329,8 +330,18 @@ export function DiscussionThread({ proposalId }: { proposalId: string }) {
</div>
);
// SECURITY: user-supplied comment content. Escape all HTML FIRST so raw markup
// (e.g. <img src=x onerror=...>) becomes inert text, THEN apply the markdown
// transforms, THEN run the result through DOMPurify restricted to a safe tag/attr
// allow-list with http(s)-only hrefs. This blocks stored XSS (script/onerror/
// javascript: URLs) while keeping basic markdown rendering.
const parseMarkdown = (text: string): string => {
return text
const escaped = (text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const html = escaped
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
@@ -339,6 +350,11 @@ export function DiscussionThread({ proposalId }: { proposalId: string }) {
.replace(/\[([^\]]+)\]\(([^)]+)\)/gim, '<a href="$2" class="text-blue-600 hover:underline">$1</a>')
.replace(/^> (.*$)/gim, '<blockquote class="border-l-4 border-gray-300 pl-4 italic">$1</blockquote>')
.replace(/\n/gim, '<br>');
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'strong', 'em', 'a', 'blockquote', 'br', 'p'],
ALLOWED_ATTR: ['href', 'class'],
ALLOWED_URI_REGEXP: /^https?:\/\//i,
});
};
return (
@@ -1148,9 +1148,9 @@ const LandingPageDesktop: React.FC = () => {
<div className="lp-foot-col">
<h5>{t('landing.footer.network')}</h5>
<ul>
<li><a href="/network">{t('landing.footer.explorer')}</a></li>
<li><a href="/explorer">{t('landing.footer.explorer')}</a></li>
<li><a href="/telemetry">{t('landing.footer.telemetry')}</a></li>
<li><a href="/network">{t('landing.footer.validators')}</a></li>
<li><a href="/explorer">{t('landing.footer.validators')}</a></li>
<li><a href="/faucet">{t('landing.footer.faucet')}</a></li>
</ul>
</div>
@@ -1159,7 +1159,7 @@ const LandingPageDesktop: React.FC = () => {
<ul>
<li><a href="/wallet">{t('landing.footer.wallet')}</a></li>
<li><a href="/p2p">{t('landing.footer.trade')}</a></li>
<li><a href="/">{t('landing.footer.vote')}</a></li>
<li><a href="/elections">{t('landing.footer.vote')}</a></li>
<li><a href="/grants">{t('landing.footer.grants')}</a></li>
</ul>
</div>
@@ -1178,7 +1178,7 @@ const LandingPageDesktop: React.FC = () => {
<li><a href="/forum">{t('landing.footer.forum')}</a></li>
<li><a href="https://discord.gg/pezkuwichain" target="_blank" rel="noopener noreferrer">{t('landing.footer.discord')}</a></li>
<li><a href="https://t.me/PezkuwiApp" target="_blank" rel="noopener noreferrer">{t('landing.footer.telegram')}</a></li>
<li><a href="https://x.com/PezkuwiChain" target="_blank" rel="noopener noreferrer">{t('landing.footer.twitter')}</a></li>
<li><a href="https://x.com/bizinikiwi" target="_blank" rel="noopener noreferrer">{t('landing.footer.twitter')}</a></li>
</ul>
</div>
</div>
@@ -29,11 +29,14 @@ export default function NotificationBell() {
const [open, setOpen] = useState(false);
useEffect(() => {
if (user) {
loadNotifications();
if (!user) return;
subscribeToNotifications();
}
loadNotifications();
// Return the unsubscribe callback so the realtime channel is torn down
// on unmount / when the user changes — otherwise channels leak and
// setState fires after unmount.
const unsubscribe = subscribeToNotifications();
return unsubscribe;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
@@ -55,7 +58,7 @@ export default function NotificationBell() {
const subscribeToNotifications = () => {
const channel = supabase
.channel('notifications')
.channel(`notifications-${user?.id}`)
.on(
'postgres_changes',
{
+6 -2
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useWallet } from '@/contexts/WalletContext';
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -25,7 +26,8 @@ interface CreateAdProps {
export function CreateAd({ onAdCreated }: CreateAdProps) {
const { t } = useTranslation();
const { selectedAccount } = usePezkuwi();
const { userId } = useP2PIdentity();
const { signMessage } = useWallet();
const { userId, identityId } = useP2PIdentity();
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(null);
@@ -77,7 +79,7 @@ export function CreateAd({ onAdCreated }: CreateAdProps) {
};
const handleCreateAd = async () => {
if (!selectedAccount || !userId) {
if (!selectedAccount || !userId || !identityId) {
toast.error(t('p2p.connectWalletAndLogin'));
return;
}
@@ -128,6 +130,8 @@ export function CreateAd({ onAdCreated }: CreateAdProps) {
try {
await createFiatOffer({
userId,
identityId: identityId!,
signMessage,
sellerWallet: selectedAccount.address,
token,
amountCrypto: cryptoAmt,
+15 -3
View File
@@ -30,6 +30,7 @@ import {
Info
} from 'lucide-react';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useWallet } from '@/contexts/WalletContext';
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
import { toast } from 'sonner';
import {
@@ -52,7 +53,8 @@ type WithdrawStep = 'form' | 'confirm' | 'success';
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
const { t } = useTranslation();
const { selectedAccount } = usePezkuwi();
const { userId } = useP2PIdentity();
const { signMessage } = useWallet();
const { userId, identityId } = useP2PIdentity();
const [step, setStep] = useState<WithdrawStep>('form');
const [token, setToken] = useState<CryptoToken>('HEZ');
@@ -177,8 +179,18 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps
try {
const withdrawAmount = parseFloat(amount);
if (!userId) throw new Error('Identity required');
const id = await requestWithdraw(userId, token, withdrawAmount, walletAddress);
if (!identityId) throw new Error('Identity required');
if (!selectedAccount?.address) throw new Error('Connect a wallet to withdraw');
// Authorization: sign a challenge with the wallet that owns the identity.
// The edge function derives user_id from identityId after verifying this.
const id = await requestWithdraw(
identityId,
token,
withdrawAmount,
walletAddress,
selectedAccount.address,
signMessage
);
setRequestId(id);
setStep('success');
onSuccess?.();
+11
View File
@@ -115,6 +115,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const checkAdminStatus = useCallback(async () => {
// ============================================================
// COSMETIC ONLY — NOT an authorization boundary.
// ------------------------------------------------------------
// This flag is derived from a localStorage wallet value and can be trivially
// forged in DevTools. It ONLY controls whether admin UI is shown. Every
// privileged operation MUST be verified server-side:
// - Dispute claim/resolve -> `resolve-dispute` edge function verifies a
// wallet SIGNATURE against the server-side admin wallet set before doing
// anything (see supabase/functions/resolve-dispute + _shared/identity-auth).
// Do NOT add fund-moving or state-changing logic gated solely on isAdmin.
// ============================================================
// Admin wallet whitelist (blockchain-based auth)
const ADMIN_WALLETS = [
'5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF', // Founder
+4
View File
@@ -26,6 +26,10 @@ if (typeof window !== 'undefined' && import.meta.env.PROD) {
};
}
// Augment @pezkuwi/api types so api.query/tx/rpc return typed values
// instead of the generic `Codec`. Must be imported before any api usage.
import '@pezkuwi/api-augment';
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'
+1 -1
View File
@@ -860,7 +860,7 @@ const Explorer: React.FC = () => {
{t('explorer.telemetry')}
</a>
<a
href="/governance"
href="/governance/assembly"
className="flex items-center gap-2 p-3 rounded-lg bg-gray-800 hover:bg-gray-750 text-gray-300 hover:text-white transition-colors"
>
<Users className="w-4 h-4 text-blue-400" />
+1 -1
View File
@@ -429,7 +429,7 @@ const Forum: React.FC = () => {
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<a href="/governance" className="block text-gray-400 hover:text-green-400 text-sm transition-colors">
<a href="/governance/assembly" className="block text-gray-400 hover:text-green-400 text-sm transition-colors">
{t('forum.govDashboard')}
</a>
<a href="/docs" className="block text-gray-400 hover:text-green-400 text-sm transition-colors">
+5 -3
View File
@@ -34,6 +34,7 @@ import {
Star,
} from 'lucide-react';
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
import { useWallet } from '@/contexts/WalletContext';
import { toast } from 'sonner';
import { supabase } from '@/lib/supabase';
import {
@@ -74,7 +75,8 @@ export default function P2PTrade() {
const { tradeId } = useParams<{ tradeId: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const { userId } = useP2PIdentity();
const { userId, identityId, walletAddress } = useP2PIdentity();
const { signMessage } = useWallet();
const [trade, setTrade] = useState<TradeWithDetails | null>(null);
const [loading, setLoading] = useState(true);
@@ -295,14 +297,14 @@ export default function P2PTrade() {
// Handle release crypto
const handleReleaseCrypto = async () => {
if (!trade || !userId) {
if (!trade || !userId || !identityId || !walletAddress) {
toast.error(t('p2p.connectWalletAndLogin'));
return;
}
setActionLoading(true);
try {
await confirmPaymentReceived(trade.id, userId);
await confirmPaymentReceived(trade.id, identityId, walletAddress, signMessage);
toast.success(t('p2pTrade.cryptoReleasedToast'));
fetchTrade();
} catch (error) {
+6 -2
View File
@@ -14,7 +14,7 @@ import { toast } from 'sonner';
export default function Presale() {
const { t } = useTranslation();
const navigate = useNavigate();
const { api, selectedAccount, isApiReady } = usePezkuwi();
const { api, selectedAccount, isApiReady, walletSource } = usePezkuwi();
const { balances } = useWallet();
const [wusdtAmount, setWusdtAmount] = useState('');
@@ -107,9 +107,13 @@ export default function Presale() {
try {
const amountWithDecimals = Math.floor(amount * 1_000_000); // 6 decimals
// Get signer (extension or WalletConnect) — no global api.setSigner exists
const { getSigner } = await import('@/lib/get-signer');
const injector = await getSigner(selectedAccount.address, walletSource, api);
const tx = api.tx.presale.contribute(amountWithDecimals);
await tx.signAndSend(selectedAccount.address, ({ status, events }) => {
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, events }) => {
if (status.isInBlock) {
if (import.meta.env.DEV) {
console.log(`Transaction included in block: ${status.asInBlock}`);
+2 -2
View File
@@ -398,8 +398,8 @@ export default function ProfileSettings() {
<SelectItem value="en">English</SelectItem>
<SelectItem value="tr">Türkçe</SelectItem>
<SelectItem value="ar">العربية</SelectItem>
<SelectItem value="kmr">Kurdî</SelectItem>
<SelectItem value="ckb">کوردی</SelectItem>
<SelectItem value="ku-kurmanji">Kurdî (Kurmancî)</SelectItem>
<SelectItem value="ku-sorani">کوردی (سۆرانی)</SelectItem>
<SelectItem value="fa">فارسی</SelectItem>
</SelectContent>
</Select>
+69
View File
@@ -0,0 +1,69 @@
# Security Fixes — Deploy Runbook (2026-07-25)
Deploys are gated and applied by the owner. This runbook lists exactly what must
ship for the P2P custodial-fund security fixes. **Apply migrations first, then
edge functions, then set secrets, then ship the frontend.** All escrow/withdraw
paths fail closed, so partial deploys can block legitimate withdrawals/trades.
## 1. Migrations (apply in this order)
Round 1 (BOLA / RLS / dispute escrow):
1. `20260725000000_withdraw_authz_hardening.sql``p2p_challenge_nonces` table; locks `request_withdraw` to service role.
2. `20260725010000_harden_financial_rls.sql` — removes world-open reads on balances/ledger/deposit-withdraw/payment-PII; scoped read RPCs; locks `p2p_fiat_disputes` UPDATE to service role.
3. `20260725020000_admin_dispute_resolution.sql``admin_resolve_dispute` (atomic release/refund/split, service role only).
Round 2 (escrow-RPC drain):
4. `20260725030000_lock_escrow_internal_rpcs.sql` — REVOKE PUBLIC/anon/authenticated EXECUTE on `lock_/release_/refund_escrow_internal`; GRANT to `service_role`.
> Migration 4 MUST be accompanied by the round-2 edge functions (below) and the
> updated frontend, or offer-creation and payment-release will break (the client
> can no longer call the escrow RPCs directly).
## 2. Edge functions to deploy
Round 1:
- `process-withdraw` (rewritten — signed-challenge authz, derives user_id server-side)
- `process-withdrawal` (batch — now requires the service-role key)
- `verify-deposit` (adds identity-ownership binding)
- `resolve-dispute` (new — admin-signature-gated dispute claim/resolve)
Round 2:
- `confirm-payment` (new — seller-signed escrow release)
- `lock-escrow` (new — owner-signed escrow lock)
Shared code: `functions/_shared/identity-auth.ts` (imported by the above; not a
deployable function itself — ensure it deploys alongside).
## 3. Secrets (supabase functions secrets)
- `PEOPLE_RPC_ENDPOINT` — People Chain WS (default `wss://people-rpc.pezkuwichain.io`).
Required by `process-withdraw`, `verify-deposit`, `confirm-payment`, `lock-escrow`
for citizen-NFT ownership verification.
- `ADMIN_WALLETS` — optional, comma-separated SS58 admin set for `resolve-dispute`
(defaults to the 3 historical admin wallets baked into the function).
- Existing: `PLATFORM_PRIVATE_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_URL`, RPC endpoints.
## 4. Frontend
Ship the updated client (new signing flows in withdraw/offer-create/payment-release
and the admin dispute panel). Users now sign an authorization challenge in their
wallet for: withdrawals, offer creation (lock), payment release, and admin dispute
actions.
## 5. Post-deploy smoke checks
- Withdraw a small amount (signs, succeeds; a replayed nonce is rejected).
- Create a sell offer (signs the lock; balance moves available→locked).
- Complete a trade release (seller signs; buyer credited).
- Resolve a test dispute as admin (escrow actually moves).
- Confirm anon `rpc('release_escrow_internal', …)` now returns permission denied.
## Known residual (NOT closed — tracked)
- Scoped read RPCs (`get_user_balance_transactions`, `get_user_deposit_withdraw_requests`)
are keyed by a non-secret `user_id` (UUID from citizen/visa number). They stop
full-table dumps but are not yet bound to a signed session. Deferred to avoid a
sign-prompt on every passive read; reads leak history only (no fund movement).
- `p2p_fiat_offers` / `p2p_fiat_trades` / `p2p_messages` retain `USING(true)` for
direct client read/write (no server session to bind to). Marking a trade status
moves no funds; all fund movement now flows through service-role edge functions.
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# apply-migrations.sh — idempotent, tracked migration applier for the self-hosted
# Supabase Postgres. Runs ON the Supabase host (invoked by the deploy-supabase CI
# job over SSH). Applies every versioned migration in $MIGRATIONS_DIR that is not
# yet recorded in supabase_migrations.schema_migrations, in ascending version
# order, each inside a single transaction together with its own tracking-row
# insert — so a failed migration rolls back cleanly and is never half-recorded.
#
# Safe to re-run: already-recorded versions are skipped. Non-versioned files
# (e.g. COMBINED_*.sql consolidated snapshots) are ignored.
#
# Usage: apply-migrations.sh <migrations_dir> [db_container]
set -euo pipefail
MIGRATIONS_DIR="${1:?migrations dir required}"
DB_CONTAINER="${2:-supabase-db}"
PSQL=(docker exec -i "$DB_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 --single-transaction -q)
PSQL_Q=(docker exec -i "$DB_CONTAINER" psql -U postgres -tAq)
echo "▶ apply-migrations: dir=$MIGRATIONS_DIR container=$DB_CONTAINER"
# Ensure the tracking schema/table exists (matches the Supabase CLI layout).
"${PSQL[@]}" >/dev/null <<'SQL'
CREATE SCHEMA IF NOT EXISTS supabase_migrations;
CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (
version text PRIMARY KEY,
statements text[],
name text
);
SQL
# Snapshot the already-applied versions once.
applied="$("${PSQL_Q[@]}" -c "SELECT version FROM supabase_migrations.schema_migrations;")"
is_applied() { grep -qxF "$1" <<<"$applied"; }
pending=0 done=0
# Sort by filename so version order == chronological order (14-digit timestamps
# and zero-padded legacy 0NN prefixes both sort correctly).
for f in $(ls "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort); do
base="$(basename "$f")"
# Versioned migrations only: <digits>_<name>.sql. Skips COMBINED_*, README, etc.
if [[ ! "$base" =~ ^([0-9]+)_(.+)\.sql$ ]]; then
echo " ↷ skip (not a versioned migration): $base"
continue
fi
version="${BASH_REMATCH[1]}"
name="${BASH_REMATCH[2]}"
if is_applied "$version"; then
continue
fi
pending=$((pending+1))
if [[ "${DRY_RUN:-}" == "1" ]]; then
echo " → [dry-run] WOULD apply $version ($name)"
continue
fi
echo " → applying $version ($name)"
# Migration body + its tracking insert in ONE transaction (--single-transaction):
# if the body errors, ON_ERROR_STOP aborts and the whole tx (including the insert)
# rolls back, so the version is never recorded for a failed apply.
# version is always digits and name always [a-z0-9_] (captured from the filename
# regex above), so direct single-quoting is safe — no injection surface.
{
cat "$f"
printf "\nINSERT INTO supabase_migrations.schema_migrations(version,name) VALUES ('%s','%s');\n" \
"$version" "$name"
} | "${PSQL[@]}"
echo " ✓ applied and recorded $version"
done=$((done+1))
done
if [[ $pending -eq 0 ]]; then
echo "✔ up to date — no pending migrations"
else
echo "✔ applied $done migration(s)"
fi
@@ -0,0 +1,275 @@
// Handler-flow authorization tests.
//
// Run: deno test -A web/supabase/functions/__tests__/authorization-flow.test.ts
//
// The edge-function index.ts files call serve() at import time (binding a port
// and constructing a live Supabase client from env), so they cannot be imported
// directly in a unit test. These tests instead re-run each handler's EXACT guard
// sequence against the REAL _shared gate functions + a stateful in-memory Supabase
// mock, proving the composed money-move gates hold: bad signature rejected, a
// verified signer acting on ANOTHER user's id rejected, replayed nonce rejected,
// wrong trade state rejected, the fund DESTINATION is read from the server-side
// trade row, and the confirm-payment compare-and-swap prevents double-release.
import { assertEquals, assert } from 'https://deno.land/std@0.224.0/assert/mod.ts'
import { Keyring } from 'npm:@pezkuwi/keyring@14.0.25'
import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25'
import { stringToU8a, u8aToHex, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25'
import {
identityToUUID,
verifyWalletSignature,
buildWithdrawChallenge,
buildConfirmPaymentChallenge,
assertIdentityOwnedByWallet,
consumeNonce,
isFreshTimestamp,
} from '../_shared/identity-auth.ts'
await cryptoWaitReady()
const kr = new Keyring({ type: 'sr25519' })
const seller = kr.addFromUri('//Seller')
const attacker = kr.addFromUri('//Attacker')
// ---------------------------------------------------------------------------
// Stateful in-memory Supabase mock (trades + offers + visa + nonces + rpc)
// ---------------------------------------------------------------------------
interface Trade {
id: string; seller_id: string; buyer_id: string; offer_id: string
crypto_amount: number; status: string
}
interface Offer { id: string; token: string }
function makeDb(init: { trades?: Trade[]; offers?: Offer[]; visa?: { visa_number: string; wallet_address: string; status: string }[] }) {
const trades = new Map<string, Trade>((init.trades ?? []).map(t => [t.id, { ...t }]))
const offers = new Map<string, Offer>((init.offers ?? []).map(o => [o.id, o]))
const visa = init.visa ?? []
const nonces = new Set<string>()
const releaseCalls: any[] = []
const client: any = {
releaseCalls,
tradesState: trades,
from(table: string) {
if (table === 'p2p_visa') {
const f: Record<string, string> = {}
const b: any = {
select() { return b }, eq(c: string, v: string) { f[c] = v; return b },
async maybeSingle() {
const row = visa.find(r => r.visa_number === f['visa_number'] && r.wallet_address === f['wallet_address'] && r.status === f['status'])
return { data: row ?? null, error: null }
},
}
return b
}
if (table === 'p2p_challenge_nonces') {
return { async insert(row: { nonce: string }) { if (nonces.has(row.nonce)) return { error: { code: '23505' } }; nonces.add(row.nonce); return { error: null } } }
}
if (table === 'p2p_fiat_trades') {
const f: Record<string, string> = {}
let updateVals: Record<string, unknown> | null = null
const b: any = {
select() { return b },
update(vals: Record<string, unknown>) { updateVals = vals; return b },
eq(c: string, v: string) { f[c] = v; return b },
async single() { const t = trades.get(f['id']); return { data: t ?? null, error: t ? null : { message: 'not found' } } },
// Terminal for the CAS update: .update().eq('id').eq('status').select('id')
then(resolve: (r: any) => void) {
// Only reached for the update+select CAS chain.
const t = trades.get(f['id'])
if (!t || !updateVals) { resolve({ data: [], error: null }); return }
// compare-and-swap: status filter must still match current row.
if (f['status'] !== undefined && t.status !== f['status']) { resolve({ data: [], error: null }); return }
Object.assign(t, updateVals)
resolve({ data: [{ id: t.id }], error: null })
},
}
return b
}
if (table === 'p2p_fiat_offers') {
const f: Record<string, string> = {}
const b: any = { select() { return b }, eq(c: string, v: string) { f[c] = v; return b }, async single() { const o = offers.get(f['id']); return { data: o ?? null, error: o ? null : { message: 'nf' } } } }
return b
}
throw new Error('unexpected table ' + table)
},
async rpc(fn: string, args: any) {
if (fn === 'release_escrow_internal') {
releaseCalls.push(args)
return { data: { success: true }, error: null }
}
throw new Error('unexpected rpc ' + fn)
},
}
return client
}
const getPeopleApiStub = async () => ({ query: { tiki: { citizenNft: async () => ({ isEmpty: true }) } } }) as any
// ---------------------------------------------------------------------------
// Faithful re-implementation of confirm-payment/index.ts guard sequence
// (kept in lockstep with the edge function, including the CAS fix).
// ---------------------------------------------------------------------------
async function runConfirmPayment(db: any, body: any): Promise<{ status: number; body: any }> {
const J = (status: number, obj: any) => ({ status, body: obj })
const { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce } = body
if (!tradeId || !sellerIdentity || !signerAddress || !signature || !nonce || typeof timestamp !== 'number')
return J(401, { success: false, error: 'Missing authorization fields' })
if (!isFreshTimestamp(timestamp)) return J(401, { success: false, error: 'expired' })
const challenge = buildConfirmPaymentChallenge({ tradeId, sellerIdentity, signerAddress, timestamp, nonce })
if (!(await verifyWalletSignature(challenge, signature, signerAddress))) return J(401, { success: false, error: 'Invalid signature' })
const ownership = await assertIdentityOwnedByWallet(db, sellerIdentity, signerAddress, getPeopleApiStub)
if (!ownership.ok) return J(403, { success: false, error: ownership.error })
const sellerUserId = await identityToUUID(sellerIdentity)
const { data: trade, error: tradeErr } = await db.from('p2p_fiat_trades').select('*').eq('id', tradeId).single()
if (tradeErr || !trade) return J(404, { success: false, error: 'Trade not found' })
if (trade.seller_id !== sellerUserId) return J(403, { success: false, error: 'Only the trade seller can release this escrow' })
if (trade.status !== 'payment_sent') return J(400, { success: false, error: 'not awaiting confirmation' })
const { data: offer } = await db.from('p2p_fiat_offers').select('token').eq('id', trade.offer_id).single()
if (!offer?.token) return J(400, { success: false, error: 'Offer/token not found' })
if (!(await consumeNonce(db, nonce, 'confirm_payment'))) return J(401, { success: false, error: 'replay' })
const nowIso = new Date().toISOString()
const { data: claimed } = await db.from('p2p_fiat_trades')
.update({ status: 'completed', seller_confirmed_at: nowIso, escrow_released_at: nowIso, completed_at: nowIso })
.eq('id', tradeId).eq('status', 'payment_sent').select('id')
if (!claimed || claimed.length === 0) return J(409, { success: false, error: 'already released' })
const { data: releaseRes } = await db.rpc('release_escrow_internal', {
p_from_user_id: trade.seller_id, p_to_user_id: trade.buyer_id, p_token: offer.token,
p_amount: trade.crypto_amount, p_reference_type: 'trade', p_reference_id: tradeId,
})
if (!releaseRes?.success) return J(400, { success: false, error: 'Release failed' })
return J(200, { success: true, sellerId: trade.seller_id, buyerId: trade.buyer_id, amount: trade.crypto_amount })
}
async function setupTrade() {
const sellerId = await identityToUUID('V-100001')
const buyerId = await identityToUUID('V-200002')
const db = makeDb({
trades: [{ id: 'T1', seller_id: sellerId, buyer_id: buyerId, offer_id: 'O1', crypto_amount: 50, status: 'payment_sent' }],
offers: [{ id: 'O1', token: 'HEZ' }],
visa: [{ visa_number: 'V-100001', wallet_address: seller.address, status: 'active' }],
})
return { db, sellerId, buyerId }
}
function sign(pair: any, msg: string) { return u8aToHex(pair.sign(u8aWrapBytes(msg))) }
// ---------------------------------------------------------------------------
// confirm-payment (escrow RELEASE) tests
// ---------------------------------------------------------------------------
Deno.test('confirm-payment: missing signature -> 401', async () => {
const { db } = await setupTrade()
const r = await runConfirmPayment(db, { tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, timestamp: Date.now(), nonce: 'cp-11112222' })
assertEquals(r.status, 401)
})
Deno.test('confirm-payment: invalid signature -> 401 (no release)', async () => {
const { db } = await setupTrade()
const r = await runConfirmPayment(db, { tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, signature: '0xdeadbeef', timestamp: Date.now(), nonce: 'cp-11112222' })
assertEquals(r.status, 401)
assertEquals(db.releaseCalls.length, 0)
})
Deno.test('confirm-payment: valid signer who does NOT own the seller identity -> 403 (no release)', async () => {
const { db } = await setupTrade()
const ts = Date.now(), nonce = 'cp-attacker1'
// Attacker signs a well-formed challenge for the seller identity, with a valid
// signature over it — but the attacker wallet does not own visa V-100001.
const challenge = buildConfirmPaymentChallenge({ tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: attacker.address, timestamp: ts, nonce })
const r = await runConfirmPayment(db, { tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: attacker.address, signature: sign(attacker, challenge), timestamp: ts, nonce })
assertEquals(r.status, 403)
assertEquals(db.releaseCalls.length, 0)
})
Deno.test('confirm-payment: verified seller BUT trade not in payment_sent -> 400 (no release)', async () => {
const { db } = await setupTrade()
db.tradesState.get('T1').status = 'pending'
const ts = Date.now(), nonce = 'cp-state001'
const challenge = buildConfirmPaymentChallenge({ tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, timestamp: ts, nonce })
const r = await runConfirmPayment(db, { tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, signature: sign(seller, challenge), timestamp: ts, nonce })
assertEquals(r.status, 400)
assertEquals(db.releaseCalls.length, 0)
})
Deno.test('confirm-payment: happy path releases to the trade-row buyer_id (destination from server row)', async () => {
const { db, sellerId, buyerId } = await setupTrade()
const ts = Date.now(), nonce = 'cp-happy001'
const challenge = buildConfirmPaymentChallenge({ tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, timestamp: ts, nonce })
const r = await runConfirmPayment(db, { tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, signature: sign(seller, challenge), timestamp: ts, nonce })
assertEquals(r.status, 200)
assertEquals(db.releaseCalls.length, 1)
assertEquals(db.releaseCalls[0].p_from_user_id, sellerId)
assertEquals(db.releaseCalls[0].p_to_user_id, buyerId) // funds go to the row's buyer_id
assertEquals(db.releaseCalls[0].p_amount, 50)
})
Deno.test('confirm-payment: replayed nonce after success -> 401 (no second release)', async () => {
const { db } = await setupTrade()
const ts = Date.now(), nonce = 'cp-replay01'
const challenge = buildConfirmPaymentChallenge({ tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, timestamp: ts, nonce })
const body = { tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, signature: sign(seller, challenge), timestamp: ts, nonce }
const r1 = await runConfirmPayment(db, body)
assertEquals(r1.status, 200)
// Reset status so ONLY the nonce guard can stop the replay (proving replay defense).
db.tradesState.get('T1').status = 'payment_sent'
const r2 = await runConfirmPayment(db, body)
assertEquals(r2.status, 401)
assertEquals(db.releaseCalls.length, 1)
})
Deno.test('confirm-payment: concurrent double-release (two nonces) -> only ONE release via CAS', async () => {
const { db } = await setupTrade()
const ts = Date.now()
const mk = (nonce: string) => {
const c = buildConfirmPaymentChallenge({ tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, timestamp: ts, nonce })
return { tradeId: 'T1', sellerIdentity: 'V-100001', signerAddress: seller.address, signature: sign(seller, c), timestamp: ts, nonce }
}
const [a, b] = await Promise.all([runConfirmPayment(db, mk('cp-conc0001')), runConfirmPayment(db, mk('cp-conc0002'))])
const oks = [a, b].filter(x => x.status === 200)
assertEquals(oks.length, 1) // exactly one succeeds
assertEquals(db.releaseCalls.length, 1) // escrow moved exactly once
})
// ---------------------------------------------------------------------------
// process-withdraw auth-gate mirror (server derives user_id; client value ignored)
// ---------------------------------------------------------------------------
async function runWithdrawAuth(db: any, body: any): Promise<{ status: number; userId?: string }> {
const { identityId, signerAddress, signature, timestamp, nonce, token, amount, walletAddress } = body
if (!identityId || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') return { status: 401 }
if (!isFreshTimestamp(timestamp)) return { status: 401 }
const challenge = buildWithdrawChallenge({ identityId, token: String(token ?? ''), amount: Number(amount ?? 0), destination: String(walletAddress ?? ''), signerAddress, timestamp, nonce })
if (!(await verifyWalletSignature(challenge, signature, signerAddress))) return { status: 401 }
const ownership = await assertIdentityOwnedByWallet(db, identityId, signerAddress, getPeopleApiStub)
if (!ownership.ok) return { status: 403 }
if (!(await consumeNonce(db, nonce, 'withdraw'))) return { status: 401 }
const userId = await identityToUUID(identityId) // server-derived; body.user_id ignored
return { status: 200, userId }
}
Deno.test('process-withdraw: signer acting on ANOTHER identity is rejected (ownership) ', async () => {
const db = makeDb({ visa: [{ visa_number: 'V-100001', wallet_address: seller.address, status: 'active' }] })
const ts = Date.now(), nonce = 'wd-otheruser'
// Attacker owns nothing; claims victim identity V-100001.
const challenge = buildWithdrawChallenge({ identityId: 'V-100001', token: 'HEZ', amount: 5, destination: '5EVIL', signerAddress: attacker.address, timestamp: ts, nonce })
const r = await runWithdrawAuth(db, { identityId: 'V-100001', token: 'HEZ', amount: 5, walletAddress: '5EVIL', signerAddress: attacker.address, signature: u8aToHex(attacker.sign(u8aWrapBytes(challenge))), timestamp: ts, nonce })
assertEquals(r.status, 403)
})
Deno.test('process-withdraw: valid owner passes and user_id is derived from identity (client user_id irrelevant)', async () => {
const db = makeDb({ visa: [{ visa_number: 'V-100001', wallet_address: seller.address, status: 'active' }] })
const ts = Date.now(), nonce = 'wd-gooduser'
const challenge = buildWithdrawChallenge({ identityId: 'V-100001', token: 'HEZ', amount: 5, destination: '5DEST', signerAddress: seller.address, timestamp: ts, nonce })
const r = await runWithdrawAuth(db, { identityId: 'V-100001', token: 'HEZ', amount: 5, walletAddress: '5DEST', signerAddress: seller.address, signature: u8aToHex(seller.sign(u8aWrapBytes(challenge))), timestamp: ts, nonce, user_id: 'client-supplied-garbage' })
assertEquals(r.status, 200)
assertEquals(r.userId, await identityToUUID('V-100001'))
})
Deno.test('process-withdraw: signature bound to amount+destination — tampering either invalidates it', async () => {
const db = makeDb({ visa: [{ visa_number: 'V-100001', wallet_address: seller.address, status: 'active' }] })
const ts = Date.now()
const signed = buildWithdrawChallenge({ identityId: 'V-100001', token: 'HEZ', amount: 5, destination: '5DEST', signerAddress: seller.address, timestamp: ts, nonce: 'wd-tamper01' })
const sig = u8aToHex(seller.sign(u8aWrapBytes(signed)))
// Attacker keeps the signature but bumps amount + redirects destination.
const r = await runWithdrawAuth(db, { identityId: 'V-100001', token: 'HEZ', amount: 999999, walletAddress: '5EVIL', signerAddress: seller.address, signature: sig, timestamp: ts, nonce: 'wd-tamper01' })
assertEquals(r.status, 401)
})
@@ -0,0 +1,298 @@
// Unit tests for the fund-custody authorization boundary (_shared/identity-auth.ts).
//
// Run: deno test -A web/supabase/functions/_shared/__tests__/identity-auth.test.ts
//
// These exercise the REAL module (real sr25519 sign/verify via @pezkuwi/*), with
// the Supabase client and People-Chain API mocked. They prove the gates that the
// edge functions compose: signature verification, canonical money-bound
// challenges, identity-ownership, single-use nonces and timestamp freshness.
import {
assertEquals,
assert,
assertNotEquals,
} from 'https://deno.land/std@0.224.0/assert/mod.ts'
import { Keyring } from 'npm:@pezkuwi/keyring@14.0.25'
import { cryptoWaitReady, encodeAddress } from 'npm:@pezkuwi/util-crypto@14.0.25'
import { stringToU8a, u8aToHex, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25'
import {
verifyWalletSignature,
buildWithdrawChallenge,
buildConfirmPaymentChallenge,
buildLockEscrowChallenge,
buildDepositChallenge,
buildAdminChallenge,
assertIdentityOwnedByWallet,
consumeNonce,
isFreshTimestamp,
generateCitizenNumber,
identityToUUID,
CHALLENGE_MAX_AGE_MS,
} from '../identity-auth.ts'
// ---------------------------------------------------------------------------
// Test key material
// ---------------------------------------------------------------------------
await cryptoWaitReady()
const kr = new Keyring({ type: 'sr25519' })
const alice = kr.addFromUri('//Alice')
const bob = kr.addFromUri('//Bob')
// ---------------------------------------------------------------------------
// verifyWalletSignature
// ---------------------------------------------------------------------------
Deno.test('verifyWalletSignature: valid raw signature passes', async () => {
const msg = 'Pezkuwi P2P Withdrawal\ntoken:HEZ'
const sig = u8aToHex(alice.sign(stringToU8a(msg)))
assert(await verifyWalletSignature(msg, sig, alice.address))
})
Deno.test('verifyWalletSignature: <Bytes>-wrapped signature passes (extension signRaw form)', async () => {
const msg = 'Pezkuwi P2P Confirm Payment\ntrade:abc'
const sig = u8aToHex(alice.sign(u8aWrapBytes(msg)))
assert(await verifyWalletSignature(msg, sig, alice.address))
})
Deno.test('verifyWalletSignature: wrong signer is rejected', async () => {
const msg = 'authorize me'
const sig = u8aToHex(alice.sign(stringToU8a(msg))) // signed by Alice
assertEquals(await verifyWalletSignature(msg, sig, bob.address), false)
})
Deno.test('verifyWalletSignature: signature over a DIFFERENT message is rejected (no malleability/reuse)', async () => {
const sig = u8aToHex(alice.sign(stringToU8a('message-A')))
assertEquals(await verifyWalletSignature('message-B', sig, alice.address), false)
})
Deno.test('verifyWalletSignature: empty / malformed signatures return false (no throw)', async () => {
assertEquals(await verifyWalletSignature('m', '', alice.address), false)
assertEquals(await verifyWalletSignature('m', '0x', alice.address), false)
assertEquals(await verifyWalletSignature('m', 'not-hex', alice.address), false)
assertEquals(await verifyWalletSignature('m', '0xdeadbeef', alice.address), false)
})
// ---------------------------------------------------------------------------
// Canonical challenge builders — money-param binding
// ---------------------------------------------------------------------------
Deno.test('buildWithdrawChallenge: canonical shape + binds token/amount/destination/nonce', () => {
const base = { identityId: 'V-100001', token: 'HEZ', amount: 5, destination: '5DEST', signerAddress: '5SIGN', timestamp: 111, nonce: 'n-1234abcd' }
const c = buildWithdrawChallenge(base)
assertEquals(c.split('\n')[0], 'Pezkuwi P2P Withdrawal')
assert(c.includes('token:HEZ'))
assert(c.includes('amount:5'))
assert(c.includes('destination:5DEST'))
// Any change to a money field changes the signed bytes:
assertNotEquals(c, buildWithdrawChallenge({ ...base, token: 'PEZ' }))
assertNotEquals(c, buildWithdrawChallenge({ ...base, amount: 6 }))
assertNotEquals(c, buildWithdrawChallenge({ ...base, destination: '5EVIL' }))
assertNotEquals(c, buildWithdrawChallenge({ ...base, nonce: 'n-otherabcd' }))
})
Deno.test('buildConfirmPaymentChallenge: binds tradeId + seller identity', () => {
const base = { tradeId: 'T1', sellerIdentity: 'V-1', signerAddress: '5S', timestamp: 1, nonce: 'nnnnnnnn' }
const c = buildConfirmPaymentChallenge(base)
assertEquals(c.split('\n')[0], 'Pezkuwi P2P Confirm Payment')
assertNotEquals(c, buildConfirmPaymentChallenge({ ...base, tradeId: 'T2' }))
assertNotEquals(c, buildConfirmPaymentChallenge({ ...base, sellerIdentity: 'V-2' }))
})
Deno.test('buildLockEscrowChallenge: binds token + amount', () => {
const base = { identityId: 'V-1', token: 'HEZ', amount: 10, signerAddress: '5S', timestamp: 1, nonce: 'nnnnnnnn' }
const c = buildLockEscrowChallenge(base)
assertEquals(c.split('\n')[0], 'Pezkuwi P2P Lock Escrow')
assertNotEquals(c, buildLockEscrowChallenge({ ...base, amount: 11 }))
assertNotEquals(c, buildLockEscrowChallenge({ ...base, token: 'PEZ' }))
})
Deno.test('challenge prefixes are distinct — a signature for one action cannot satisfy another', () => {
const t = 1, n = 'nnnnnnnn', s = '5S'
const prefixes = new Set([
buildWithdrawChallenge({ identityId: 'i', token: 'HEZ', amount: 1, destination: 'd', signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
buildConfirmPaymentChallenge({ tradeId: 'x', sellerIdentity: 'i', signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
buildLockEscrowChallenge({ identityId: 'i', token: 'HEZ', amount: 1, signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
buildDepositChallenge({ identityId: 'i', token: 'HEZ', txHash: '0x', signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
buildAdminChallenge({ action: 'resolve', disputeId: 'd', tradeId: 'x', decision: 'split', adminAddress: s, timestamp: t, nonce: n }).split('\n')[0],
])
assertEquals(prefixes.size, 5) // all unique
})
Deno.test('end-to-end: a withdraw signature does NOT verify against a tampered amount challenge', async () => {
const p = { identityId: 'V-1', token: 'HEZ', amount: 5, destination: '5DEST', signerAddress: alice.address, timestamp: Date.now(), nonce: 'wd-abcdefgh' }
const sig = u8aToHex(alice.sign(u8aWrapBytes(buildWithdrawChallenge(p))))
// Attacker replays the signature but bumps the amount -> server rebuilds a
// different challenge -> verification fails.
const tampered = buildWithdrawChallenge({ ...p, amount: 5000 })
assertEquals(await verifyWalletSignature(tampered, sig, alice.address), false)
// sanity: the untampered challenge does verify
assert(await verifyWalletSignature(buildWithdrawChallenge(p), sig, alice.address))
})
// ---------------------------------------------------------------------------
// identityToUUID / generateCitizenNumber
// ---------------------------------------------------------------------------
Deno.test('identityToUUID: deterministic + distinct per identity', async () => {
const a = await identityToUUID('V-100001')
const b = await identityToUUID('V-100001')
const c = await identityToUUID('V-100002')
assertEquals(a, b)
assertNotEquals(a, c)
assert(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(a), 'valid v5 uuid shape')
})
Deno.test('generateCitizenNumber: deterministic 6-digit tied to (address, collection, item)', () => {
const n = generateCitizenNumber(alice.address, 42, 7)
assertEquals(n, generateCitizenNumber(alice.address, 42, 7))
assertEquals(n.length, 6)
// Different owner or item generally yields a different number.
assertNotEquals(generateCitizenNumber(alice.address, 42, 7), generateCitizenNumber(bob.address, 42, 7))
})
// ---------------------------------------------------------------------------
// Mock Supabase client
// ---------------------------------------------------------------------------
type VisaRow = { visa_number: string; wallet_address: string; status: string }
function makeSupabaseMock(opts: { visaRows?: VisaRow[]; nonceStore?: Set<string> } = {}) {
const visaRows = opts.visaRows ?? []
const nonceStore = opts.nonceStore ?? new Set<string>()
return {
nonceStore,
from(table: string) {
if (table === 'p2p_visa') {
const filters: Record<string, string> = {}
const builder: any = {
select() { return builder },
eq(col: string, val: string) { filters[col] = val; return builder },
async maybeSingle() {
const row = visaRows.find(r =>
r.visa_number === filters['visa_number'] &&
r.wallet_address === filters['wallet_address'] &&
r.status === filters['status'])
return { data: row ?? null, error: null }
},
}
return builder
}
if (table === 'p2p_challenge_nonces') {
return {
async insert(row: { nonce: string; purpose: string }) {
if (nonceStore.has(row.nonce)) {
return { error: { code: '23505', message: 'duplicate key value violates unique constraint' } }
}
nonceStore.add(row.nonce)
return { error: null }
},
}
}
throw new Error(`unexpected table ${table}`)
},
} as any
}
// People-chain API mock. `ownedItem === null` => wallet owns no citizen NFT.
function makePeopleApi(ownedItem: number | null) {
const res = ownedItem === null
? { isEmpty: true }
: { isEmpty: false, isSome: true, unwrap: () => ({ toNumber: () => ownedItem }) }
return async () => ({ query: { tiki: { citizenNft: async (_addr: string) => res } } }) as any
}
// ---------------------------------------------------------------------------
// assertIdentityOwnedByWallet — visa
// ---------------------------------------------------------------------------
Deno.test('ownership(visa): active visa bound to signer -> ok', async () => {
const sb = makeSupabaseMock({ visaRows: [{ visa_number: 'V-100001', wallet_address: alice.address, status: 'active' }] })
const r = await assertIdentityOwnedByWallet(sb, 'V-100001', alice.address, makePeopleApi(null))
assertEquals(r.ok, true)
assertEquals(r.kind, 'visa')
})
Deno.test('ownership(visa): signer B cannot act on identity A (visa bound to A) -> rejected', async () => {
const sb = makeSupabaseMock({ visaRows: [{ visa_number: 'V-100001', wallet_address: alice.address, status: 'active' }] })
const r = await assertIdentityOwnedByWallet(sb, 'V-100001', bob.address, makePeopleApi(null))
assertEquals(r.ok, false)
})
Deno.test('ownership(visa): inactive visa -> rejected', async () => {
const sb = makeSupabaseMock({ visaRows: [{ visa_number: 'V-100001', wallet_address: alice.address, status: 'revoked' }] })
const r = await assertIdentityOwnedByWallet(sb, 'V-100001', alice.address, makePeopleApi(null))
assertEquals(r.ok, false)
})
// ---------------------------------------------------------------------------
// assertIdentityOwnedByWallet — citizen
// ---------------------------------------------------------------------------
Deno.test('ownership(citizen): chain owns item + derivation matches -> ok', async () => {
const item = 7
const six = generateCitizenNumber(alice.address, 42, item)
const sb = makeSupabaseMock()
const r = await assertIdentityOwnedByWallet(sb, `#42-${item}-${six}`, alice.address, makePeopleApi(item))
assertEquals(r.ok, true)
assertEquals(r.kind, 'citizen')
})
Deno.test('ownership(citizen): claimed item != chain-owned item -> rejected (no NFT theft)', async () => {
const claimedItem = 7
const six = generateCitizenNumber(alice.address, 42, claimedItem)
const sb = makeSupabaseMock()
// Chain says alice actually owns item 9, not the claimed 7.
const r = await assertIdentityOwnedByWallet(sb, `#42-${claimedItem}-${six}`, alice.address, makePeopleApi(9))
assertEquals(r.ok, false)
})
Deno.test('ownership(citizen): correct item but forged 6-digit -> rejected', async () => {
const item = 7
const sb = makeSupabaseMock()
const r = await assertIdentityOwnedByWallet(sb, `#42-${item}-000000`, alice.address, makePeopleApi(item))
assertEquals(r.ok, false)
})
Deno.test('ownership(citizen): wallet owns no NFT -> rejected', async () => {
const item = 7
const six = generateCitizenNumber(alice.address, 42, item)
const sb = makeSupabaseMock()
const r = await assertIdentityOwnedByWallet(sb, `#42-${item}-${six}`, alice.address, makePeopleApi(null))
assertEquals(r.ok, false)
})
Deno.test('ownership: malformed identity / wrong collection -> rejected', async () => {
const sb = makeSupabaseMock()
assertEquals((await assertIdentityOwnedByWallet(sb, 'garbage', alice.address, makePeopleApi(1))).ok, false)
assertEquals((await assertIdentityOwnedByWallet(sb, '#1-2-345678', alice.address, makePeopleApi(2))).ok, false) // collection != 42
assertEquals((await assertIdentityOwnedByWallet(sb, '', alice.address, makePeopleApi(1))).ok, false)
})
// ---------------------------------------------------------------------------
// consumeNonce — single use / replay
// ---------------------------------------------------------------------------
Deno.test('consumeNonce: first use ok, second use (replay) rejected', async () => {
const sb = makeSupabaseMock()
assertEquals(await consumeNonce(sb, 'wd-11112222', 'withdraw'), true)
assertEquals(await consumeNonce(sb, 'wd-11112222', 'withdraw'), false) // replay
})
Deno.test('consumeNonce: concurrent double-spend of one nonce yields exactly one success', async () => {
const sb = makeSupabaseMock()
const [a, b] = await Promise.all([
consumeNonce(sb, 'wd-race0001', 'withdraw'),
consumeNonce(sb, 'wd-race0001', 'withdraw'),
])
assertEquals([a, b].filter(Boolean).length, 1)
})
Deno.test('consumeNonce: too-short nonce rejected', async () => {
const sb = makeSupabaseMock()
assertEquals(await consumeNonce(sb, 'short', 'withdraw'), false)
})
// ---------------------------------------------------------------------------
// isFreshTimestamp
// ---------------------------------------------------------------------------
Deno.test('isFreshTimestamp: now is fresh; expired and non-finite rejected', () => {
assert(isFreshTimestamp(Date.now()))
assertEquals(isFreshTimestamp(Date.now() - CHALLENGE_MAX_AGE_MS - 5000), false)
assertEquals(isFreshTimestamp(Date.now() + 120_000), false) // beyond 60s skew
assertEquals(isFreshTimestamp(NaN), false)
assertEquals(isFreshTimestamp(Infinity), false)
})
@@ -0,0 +1,327 @@
// _shared/identity-auth.ts
//
// Server-side identity + wallet-signature authorization for fund-moving edge
// functions (withdrawals, deposits). This is the security boundary that binds
// an incoming request to a real, cryptographically-proven principal:
//
// 1. The caller signs a canonical challenge with their Substrate wallet.
// 2. We verify that signature server-side (sr25519/ed25519) -> proves control
// of `signerAddress`.
// 3. We prove `signerAddress` OWNS the claimed identity (citizen number / visa):
// - citizen -> People Chain `tiki.citizenNft(signerAddress)` + the
// deterministic 6-digit derivation must match the claimed number.
// - visa -> `p2p_visa` row (wallet_address == signerAddress, active).
// 4. Only then do we derive `userId = identityToUUID(identityId)` SERVER-SIDE.
//
// The client-supplied `userId` is NEVER trusted for authorization. Every
// caller can only ever act on the balance of the identity their wallet owns.
import { signatureVerify, cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25'
import { stringToU8a, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25'
import type { ApiPromise } from 'npm:@pezkuwi/api@16.5.36'
import type { SupabaseClient } from 'npm:@supabase/supabase-js@2'
// Max age of a signed challenge (replay window). Nonce dedup (see caller)
// provides exact-once semantics; this bounds how long a captured signature
// could even be presented.
export const CHALLENGE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes
// UUID v5 namespace (RFC 4122 DNS namespace) — MUST match shared/lib/identity.ts
const UUID_V5_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
/**
* Deterministic UUID v5 from a citizen/visa number. Identical algorithm to
* shared/lib/identity.ts (client) and verify-deposit — the value MUST match so
* balances resolve to the same row everywhere.
*/
export async function identityToUUID(identityId: string): Promise<string> {
const namespaceHex = UUID_V5_NAMESPACE.replace(/-/g, '')
const namespaceBytes = new Uint8Array(16)
for (let i = 0; i < 16; i++) {
namespaceBytes[i] = parseInt(namespaceHex.substr(i * 2, 2), 16)
}
const nameBytes = new TextEncoder().encode(identityId)
const combined = new Uint8Array(namespaceBytes.length + nameBytes.length)
combined.set(namespaceBytes)
combined.set(nameBytes, namespaceBytes.length)
const hashBuffer = await crypto.subtle.digest('SHA-1', combined)
const h = new Uint8Array(hashBuffer)
h[6] = (h[6] & 0x0f) | 0x50
h[8] = (h[8] & 0x3f) | 0x80
const hex = Array.from(h.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/**
* Deterministic 6-digit citizen number derivation. Identical algorithm to
* shared/lib/tiki.ts generateCitizenNumber — used to bind a citizen number to
* the wallet that owns the NFT.
*/
export function generateCitizenNumber(ownerAddress: string, collectionId: number, itemId: number): string {
let hash = 0
for (let i = 0; i < ownerAddress.length; i++) {
hash = ((hash << 5) - hash) + ownerAddress.charCodeAt(i)
hash = hash & hash
}
hash += collectionId * 1000 + itemId
hash = Math.abs(hash)
return (hash % 1000000).toString().padStart(6, '0')
}
/**
* Verify a wallet signature over `message`. Handles both the raw-bytes form and
* the `<Bytes>...</Bytes>` wrapped form that Polkadot-style extensions apply to
* signRaw payloads, so extension, WalletConnect and native signers all verify.
*/
export async function verifyWalletSignature(
message: string,
signature: string,
signerAddress: string
): Promise<boolean> {
try {
await cryptoWaitReady()
const raw = stringToU8a(message)
const wrapped = u8aWrapBytes(message)
for (const candidate of [raw, wrapped]) {
const res = signatureVerify(candidate, signature, signerAddress)
if (res.isValid) return true
}
return false
} catch (_e) {
return false
}
}
/**
* Canonical challenge string for a withdrawal. MUST be byte-identical to what
* the client builds and signs (see shared/lib/p2p-fiat.ts buildWithdrawChallenge).
*/
export function buildWithdrawChallenge(p: {
identityId: string
token: string
amount: number
destination: string
signerAddress: string
timestamp: number
nonce: string
}): string {
return [
'Pezkuwi P2P Withdrawal',
`identity:${p.identityId}`,
`token:${p.token}`,
`amount:${p.amount}`,
`destination:${p.destination}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n')
}
/**
* Canonical challenge string for a deposit-credit. Binds the on-chain tx and the
* identity being credited to the wallet that signs.
*/
export function buildDepositChallenge(p: {
identityId: string
token: string
txHash: string
signerAddress: string
timestamp: number
nonce: string
}): string {
return [
'Pezkuwi P2P Deposit',
`identity:${p.identityId}`,
`token:${p.token}`,
`tx:${p.txHash}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n')
}
/**
* Canonical challenge for a seller confirming payment / releasing escrow.
* MUST be byte-identical to the client (shared/lib/p2p-fiat.ts).
*/
export function buildConfirmPaymentChallenge(p: {
tradeId: string
sellerIdentity: string
signerAddress: string
timestamp: number
nonce: string
}): string {
return [
'Pezkuwi P2P Confirm Payment',
`trade:${p.tradeId}`,
`identity:${p.sellerIdentity}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n')
}
/**
* Canonical challenge for locking one's OWN balance into escrow (offer create).
* MUST be byte-identical to the client (shared/lib/p2p-fiat.ts).
*/
export function buildLockEscrowChallenge(p: {
identityId: string
token: string
amount: number
signerAddress: string
timestamp: number
nonce: string
}): string {
return [
'Pezkuwi P2P Lock Escrow',
`identity:${p.identityId}`,
`token:${p.token}`,
`amount:${p.amount}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n')
}
/**
* Canonical challenge string for a privileged admin action (dispute claim /
* resolve). MUST be byte-identical to the client (DisputeResolutionPanel).
*/
export function buildAdminChallenge(p: {
action: string
disputeId: string
tradeId: string
decision: string
adminAddress: string
timestamp: number
nonce: string
}): string {
return [
'Pezkuwi P2P Admin Action',
`action:${p.action}`,
`dispute:${p.disputeId}`,
`trade:${p.tradeId}`,
`decision:${p.decision}`,
`admin:${p.adminAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n')
}
export interface OwnershipResult {
ok: boolean
error?: string
/** 'citizen' | 'visa' */
kind?: 'citizen' | 'visa'
}
/**
* Prove that `signerAddress` owns `identityId`.
*
* - Visa numbers ("V-XXXXXX"): must have an active p2p_visa row bound to the
* signer's wallet_address.
* - Citizen numbers ("#42-<item>-<6digit>"): the People Chain must report that
* signerAddress owns the citizen NFT with that item id, and the deterministic
* 6-digit derivation for (signerAddress, 42, itemId) must equal the claimed
* 6-digit — i.e. the number is cryptographically tied to the wallet.
*
* `peopleApi` may be lazily created by the caller; it is only required for
* citizen identities.
*/
export async function assertIdentityOwnedByWallet(
serviceClient: SupabaseClient,
identityId: string,
signerAddress: string,
getPeopleApi: () => Promise<ApiPromise>
): Promise<OwnershipResult> {
if (!identityId || !signerAddress) {
return { ok: false, error: 'Missing identity or signer' }
}
// ---- Visa identity ----
if (identityId.startsWith('V-')) {
const { data: visa, error } = await serviceClient
.from('p2p_visa')
.select('visa_number, wallet_address, status')
.eq('visa_number', identityId)
.eq('wallet_address', signerAddress)
.eq('status', 'active')
.maybeSingle()
if (error) return { ok: false, error: 'Visa lookup failed' }
if (!visa) return { ok: false, error: 'Signing wallet does not own this visa identity' }
return { ok: true, kind: 'visa' }
}
// ---- Citizen identity: "#<collection>-<item>-<6digit>" ----
const clean = identityId.trim().replace('#', '')
const parts = clean.split('-')
if (parts.length !== 3) {
return { ok: false, error: 'Invalid citizen identity format' }
}
const collectionId = parseInt(parts[0], 10)
const itemId = parseInt(parts[1], 10)
const providedSixDigit = parts[2]
if (isNaN(collectionId) || isNaN(itemId) || providedSixDigit.length !== 6) {
return { ok: false, error: 'Invalid citizen identity format' }
}
if (collectionId !== 42) {
return { ok: false, error: 'Invalid citizen collection' }
}
let peopleApi: ApiPromise
try {
peopleApi = await getPeopleApi()
} catch (_e) {
return { ok: false, error: 'People Chain unavailable for identity verification' }
}
try {
if (!peopleApi.query?.tiki?.citizenNft) {
return { ok: false, error: 'Citizen NFT pallet unavailable' }
}
const res: any = await peopleApi.query.tiki.citizenNft(signerAddress)
if (res.isEmpty || (res.isSome === false && typeof res.isSome === 'boolean')) {
return { ok: false, error: 'Signing wallet owns no citizen NFT' }
}
const actualItemId = res.isSome ? res.unwrap().toNumber() : (typeof res.toNumber === 'function' ? res.toNumber() : null)
if (actualItemId === null || actualItemId !== itemId) {
return { ok: false, error: 'Citizen NFT does not match signing wallet' }
}
const expected = generateCitizenNumber(signerAddress, collectionId, itemId)
if (expected !== providedSixDigit) {
return { ok: false, error: 'Citizen number does not match signing wallet' }
}
return { ok: true, kind: 'citizen' }
} catch (_e) {
return { ok: false, error: 'Citizen identity verification failed' }
}
}
/**
* Atomically record a used challenge nonce for replay protection. Returns false
* if the nonce was already used (replay) — caller MUST reject in that case.
* Requires table public.p2p_challenge_nonces(nonce text primary key, ...).
*/
export async function consumeNonce(
serviceClient: SupabaseClient,
nonce: string,
purpose: string
): Promise<boolean> {
if (!nonce || nonce.length < 8) return false
const { error } = await serviceClient
.from('p2p_challenge_nonces')
.insert({ nonce, purpose })
// Unique violation => already used => replay.
if (error) return false
return true
}
/** Basic timestamp freshness check for a signed challenge. */
export function isFreshTimestamp(timestamp: number): boolean {
if (!Number.isFinite(timestamp)) return false
const age = Date.now() - timestamp
return age >= -60_000 && age <= CHALLENGE_MAX_AGE_MS // allow 60s clock skew
}
@@ -0,0 +1,176 @@
// confirm-payment Edge Function
//
// Seller-authorized escrow RELEASE (the money-OUT path that was previously
// callable by anyone holding the public anon key via rpc('release_escrow_internal')).
//
// Authorization chain:
// 1. Seller signs a canonical challenge with their wallet.
// 2. verifyWalletSignature (raw + <Bytes>) proves control of `signerAddress`.
// 3. assertIdentityOwnedByWallet proves the wallet owns the claimed seller
// identity (citizen NFT / active visa).
// 4. The derived seller user_id MUST equal the trade's seller_id, and the
// trade MUST be in 'payment_sent'.
// 5. Single-use nonce (replay protection).
// 6. Only then release_escrow_internal(seller -> buyer) runs with the service
// role, atomically with the trade status update.
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'npm:@supabase/supabase-js@2'
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36'
import {
identityToUUID,
verifyWalletSignature,
buildConfirmPaymentChallenge,
assertIdentityOwnedByWallet,
consumeNonce,
isFreshTimestamp,
} from '../_shared/identity-auth.ts'
const ALLOWED_ORIGINS = [
'https://app.pezkuwichain.io',
'https://www.pezkuwichain.io',
'https://pezkuwichain.io',
]
function getCorsHeaders(origin: string | null) {
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
return {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Credentials': 'true',
}
}
const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io'
let peopleApiInstance: ApiPromise | null = null
async function getPeopleApi(): Promise<ApiPromise> {
if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance
peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) })
return peopleApiInstance
}
interface Body {
tradeId?: string
sellerIdentity?: string
signerAddress?: string
signature?: string
timestamp?: number
nonce?: string
}
serve(async (req) => {
const corsHeaders = getCorsHeaders(req.headers.get('Origin'))
const json = (status: number, obj: unknown) =>
new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
try {
const serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
const { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce }: Body = await req.json()
if (!tradeId || !sellerIdentity || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') {
return json(401, { success: false, error: 'Missing authorization fields' })
}
if (!isFreshTimestamp(timestamp)) {
return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' })
}
// 1) Verify signature
const challenge = buildConfirmPaymentChallenge({ tradeId, sellerIdentity, signerAddress, timestamp, nonce })
if (!(await verifyWalletSignature(challenge, signature, signerAddress))) {
return json(401, { success: false, error: 'Invalid signature' })
}
// 2) Prove the signer owns the claimed seller identity
const ownership = await assertIdentityOwnedByWallet(serviceClient, sellerIdentity, signerAddress, getPeopleApi)
if (!ownership.ok) {
return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' })
}
const sellerUserId = await identityToUUID(sellerIdentity)
// 3) Load trade and enforce it belongs to this seller and is releasable
const { data: trade, error: tradeErr } = await serviceClient
.from('p2p_fiat_trades')
.select('id, seller_id, buyer_id, offer_id, crypto_amount, status')
.eq('id', tradeId)
.single()
if (tradeErr || !trade) return json(404, { success: false, error: 'Trade not found' })
if (trade.seller_id !== sellerUserId) {
return json(403, { success: false, error: 'Only the trade seller can release this escrow' })
}
if (trade.status !== 'payment_sent') {
return json(400, { success: false, error: `Trade is not awaiting confirmation (status: ${trade.status})` })
}
// 4) Resolve token from offer
const { data: offer } = await serviceClient
.from('p2p_fiat_offers')
.select('token')
.eq('id', trade.offer_id)
.single()
if (!offer?.token) return json(400, { success: false, error: 'Offer/token not found' })
// 5) Consume nonce (single use)
if (!(await consumeNonce(serviceClient, nonce, 'confirm_payment'))) {
return json(401, { success: false, error: 'Authorization already used (replay detected)' })
}
// 6) Atomically CLAIM the payment_sent -> completed transition BEFORE moving
// any funds. This is the concurrency lock: only one caller can flip the
// row out of 'payment_sent', so two concurrent (differently-nonced) release
// requests for the same trade cannot both reach release_escrow_internal and
// double-spend escrow that was locked for other trades on the same offer.
const nowIso = new Date().toISOString()
const { data: claimed, error: claimErr } = await serviceClient
.from('p2p_fiat_trades')
.update({
seller_confirmed_at: nowIso,
escrow_released_at: nowIso,
status: 'completed',
completed_at: nowIso,
})
.eq('id', tradeId)
.eq('status', 'payment_sent') // compare-and-swap guard
.select('id')
if (claimErr) return json(500, { success: false, error: 'Failed to claim trade for release' })
if (!claimed || claimed.length === 0) {
// Someone (or a concurrent request) already moved it out of payment_sent.
return json(409, { success: false, error: 'Trade is no longer awaiting confirmation (already released?)' })
}
// 7) Release escrow (service role). If it fails, revert the claim so the trade
// can be retried / disputed rather than being stuck "completed" with no move.
const { data: releaseRes, error: releaseErr } = await serviceClient.rpc('release_escrow_internal', {
p_from_user_id: trade.seller_id,
p_to_user_id: trade.buyer_id,
p_token: offer.token,
p_amount: trade.crypto_amount,
p_reference_type: 'trade',
p_reference_id: tradeId,
})
const parsed = typeof releaseRes === 'string' ? JSON.parse(releaseRes) : releaseRes
if (releaseErr || !parsed?.success) {
await serviceClient
.from('p2p_fiat_trades')
.update({ status: 'payment_sent', seller_confirmed_at: null, escrow_released_at: null, completed_at: null })
.eq('id', tradeId)
return json(releaseErr ? 500 : 400, {
success: false,
error: releaseErr?.message || parsed?.error || 'Release failed',
})
}
return json(200, {
success: true,
tradeId,
sellerId: trade.seller_id,
buyerId: trade.buyer_id,
token: offer.token,
amount: trade.crypto_amount,
})
} catch (error) {
console.error('confirm-payment error:', error)
return json(500, { success: false, error: 'Internal server error' })
}
})
+115
View File
@@ -0,0 +1,115 @@
// lock-escrow Edge Function
//
// Locks a caller's OWN available balance into escrow (used by offer creation).
// lock_escrow_internal only ever moves the caller's own funds available->locked,
// so it is not a theft vector — but an anon caller could previously lock a
// VICTIM's balance (griefing/DoS on their funds). This gates it so a caller can
// only ever lock the balance of the identity their wallet owns.
//
// Authorization: wallet signature -> identity ownership -> derived user_id ->
// lock_escrow_internal with the service role.
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'npm:@supabase/supabase-js@2'
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36'
import {
identityToUUID,
verifyWalletSignature,
buildLockEscrowChallenge,
assertIdentityOwnedByWallet,
consumeNonce,
isFreshTimestamp,
} from '../_shared/identity-auth.ts'
const ALLOWED_ORIGINS = [
'https://app.pezkuwichain.io',
'https://www.pezkuwichain.io',
'https://pezkuwichain.io',
]
function getCorsHeaders(origin: string | null) {
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
return {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Credentials': 'true',
}
}
const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io'
let peopleApiInstance: ApiPromise | null = null
async function getPeopleApi(): Promise<ApiPromise> {
if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance
peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) })
return peopleApiInstance
}
interface Body {
identityId?: string
token?: 'HEZ' | 'PEZ'
amount?: number
signerAddress?: string
signature?: string
timestamp?: number
nonce?: string
}
serve(async (req) => {
const corsHeaders = getCorsHeaders(req.headers.get('Origin'))
const json = (status: number, obj: unknown) =>
new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
try {
const serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
const { identityId, token, amount, signerAddress, signature, timestamp, nonce }: Body = await req.json()
if (!identityId || !token || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') {
return json(401, { success: false, error: 'Missing authorization fields' })
}
if (!['HEZ', 'PEZ'].includes(token)) {
return json(400, { success: false, error: 'Invalid token' })
}
const amt = Number(amount)
if (!Number.isFinite(amt) || amt <= 0) {
return json(400, { success: false, error: 'Amount must be greater than 0' })
}
if (!isFreshTimestamp(timestamp)) {
return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' })
}
// 1) Verify signature over the exact lock intent
const challenge = buildLockEscrowChallenge({ identityId, token, amount: amt, signerAddress, timestamp, nonce })
if (!(await verifyWalletSignature(challenge, signature, signerAddress))) {
return json(401, { success: false, error: 'Invalid signature' })
}
// 2) Prove the signer owns the identity whose balance is being locked
const ownership = await assertIdentityOwnedByWallet(serviceClient, identityId, signerAddress, getPeopleApi)
if (!ownership.ok) {
return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' })
}
const userId = await identityToUUID(identityId)
// 3) Replay protection
if (!(await consumeNonce(serviceClient, nonce, 'lock_escrow'))) {
return json(401, { success: false, error: 'Authorization already used (replay detected)' })
}
// 4) Lock the caller's OWN balance (service role)
const { data: lockRes, error: lockErr } = await serviceClient.rpc('lock_escrow_internal', {
p_user_id: userId,
p_token: token,
p_amount: amt,
})
if (lockErr) return json(500, { success: false, error: lockErr.message || 'Lock failed' })
const parsed = typeof lockRes === 'string' ? JSON.parse(lockRes) : lockRes
if (!parsed?.success) return json(400, { success: false, error: parsed?.error || 'Lock failed' })
return json(200, { success: true, userId, token, amount: amt, ...parsed })
} catch (error) {
console.error('lock-escrow error:', error)
return json(500, { success: false, error: 'Internal server error' })
}
})
@@ -6,6 +6,14 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'npm:@supabase/supabase-js@2'
import { ApiPromise, WsProvider, Keyring } from 'npm:@pezkuwi/api@16.5.36'
import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25'
import {
identityToUUID,
verifyWalletSignature,
buildWithdrawChallenge,
assertIdentityOwnedByWallet,
consumeNonce,
isFreshTimestamp,
} from '../_shared/identity-auth.ts'
// Allowed origins for CORS
const ALLOWED_ORIGINS = [
@@ -35,6 +43,9 @@ const DECIMALS = 12
// PEZ asset ID
const PEZ_ASSET_ID = 1
// People Chain endpoint — required to verify citizen NFT ownership server-side
const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io'
// Minimum withdrawal amounts
const MIN_WITHDRAW = {
HEZ: 1,
@@ -49,10 +60,19 @@ const WITHDRAW_FEE = {
interface WithdrawRequest {
requestId?: string // If processing specific request
userId: string // Identity-based UUID (from citizen/visa number)
// ---- Authorization (all required) ----
// The caller proves control of a wallet that OWNS `identityId`. The user_id
// is derived server-side from identityId; a client-supplied user_id is IGNORED.
identityId?: string // Citizen number (#42-<item>-<6d>) or visa (V-XXXXXX)
signerAddress?: string // Wallet that signed the challenge
signature?: string // Signature over the canonical withdraw challenge
timestamp?: number // ms epoch, must be fresh
nonce?: string // single-use, replay-protected
token?: 'HEZ' | 'PEZ'
amount?: number
walletAddress?: string
walletAddress?: string // Withdrawal destination (bound into the signature)
}
// Cache API connection
@@ -68,6 +88,18 @@ async function getApi(): Promise<ApiPromise> {
return apiInstance
}
// Cache People Chain connection (citizen NFT ownership verification)
let peopleApiInstance: ApiPromise | null = null
async function getPeopleApi(): Promise<ApiPromise> {
if (peopleApiInstance && peopleApiInstance.isConnected) {
return peopleApiInstance
}
const provider = new WsProvider(PEOPLE_RPC_ENDPOINT)
peopleApiInstance = await ApiPromise.create({ provider })
return peopleApiInstance
}
// Send tokens from hot wallet
async function sendTokens(
api: ApiPromise,
@@ -190,7 +222,8 @@ serve(async (req) => {
}
try {
// Get authorization header
// Get authorization header (transport-level only — NOT the authz boundary).
// Real authorization is the wallet signature + identity-ownership proof below.
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response(
@@ -218,16 +251,74 @@ serve(async (req) => {
// Parse request body
const body: WithdrawRequest = await req.json()
const { userId } = body
const { identityId, signerAddress, signature, timestamp, nonce } = body
let { requestId, token, amount, walletAddress } = body
if (!userId) {
// =====================================================
// OBJECT-LEVEL AUTHORIZATION (the security boundary)
// =====================================================
// 1) Require a complete signed challenge.
if (!identityId || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') {
return new Response(
JSON.stringify({ success: false, error: 'Missing required field: userId' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
JSON.stringify({ success: false, error: 'Missing authorization (identityId, signerAddress, signature, timestamp, nonce required)' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 2) The signature covers the full withdrawal intent, incl. destination and
// amount. Determine the values that will be bound into the challenge.
// - Mode 1 (requestId): destination/amount/token come from the stored
// request; the client still signs them (fetched before submit).
// - Mode 2: destination/amount/token come from the (signed) body.
if (!isFreshTimestamp(timestamp)) {
return new Response(
JSON.stringify({ success: false, error: 'Authorization challenge expired. Please retry.' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 3) Verify the wallet signature over the canonical challenge. For Mode 1 the
// signed destination/amount/token are supplied alongside requestId and are
// re-checked against the stored request after lookup.
const challenge = buildWithdrawChallenge({
identityId,
token: String(token ?? ''),
amount: Number(amount ?? 0),
destination: String(walletAddress ?? ''),
signerAddress,
timestamp,
nonce,
})
const sigOk = await verifyWalletSignature(challenge, signature, signerAddress)
if (!sigOk) {
return new Response(
JSON.stringify({ success: false, error: 'Invalid signature' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 4) Prove the signing wallet OWNS the claimed identity (citizen NFT / visa).
const ownership = await assertIdentityOwnedByWallet(serviceClient, identityId, signerAddress, getPeopleApi)
if (!ownership.ok) {
return new Response(
JSON.stringify({ success: false, error: ownership.error || 'Identity ownership verification failed' }),
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 5) Consume the nonce (single-use) to block replay of a captured signature.
const nonceOk = await consumeNonce(serviceClient, nonce, 'withdraw')
if (!nonceOk) {
return new Response(
JSON.stringify({ success: false, error: 'Authorization already used (replay detected)' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 6) Derive user_id SERVER-SIDE from the verified identity. Any client-supplied
// user_id is intentionally ignored.
const userId = await identityToUUID(identityId)
// Mode 1: Process existing request by ID
if (requestId) {
const { data: request, error: reqError } = await serviceClient
@@ -246,8 +337,23 @@ serve(async (req) => {
)
}
// The signed challenge (built from body values) must match the stored
// request, so the signature authorizes exactly this withdrawal.
const storedAmount = parseFloat(request.amount)
const bodyAmount = Number(amount ?? 0)
if (
String(token ?? '') !== String(request.token) ||
String(walletAddress ?? '') !== String(request.wallet_address) ||
Math.abs(bodyAmount - storedAmount) > 1e-9
) {
return new Response(
JSON.stringify({ success: false, error: 'Signed withdrawal does not match the stored request' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
token = request.token as 'HEZ' | 'PEZ'
amount = parseFloat(request.amount)
amount = storedAmount
walletAddress = request.wallet_address
}
// Mode 2: Create new withdrawal request
@@ -199,11 +199,16 @@ serve(async (req: Request) => {
}
try {
// Verify authorization (should be called with service role key or admin JWT)
// Authorization: this batch processor drains ALL pending withdrawal requests
// to their destination wallets, so it MUST be restricted to the backend
// service role (cron / admin tooling). Merely having *any* bearer token
// (e.g. the public anon key) is NOT sufficient — previously that let anyone
// trigger mass processing.
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
const bearer = authHeader?.replace(/^Bearer\s+/i, "").trim();
if (!bearer || bearer !== SUPABASE_SERVICE_ROLE_KEY) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
JSON.stringify({ error: "Unauthorized: service role required" }),
{ status: 401, headers }
);
}
@@ -0,0 +1,151 @@
// resolve-dispute Edge Function
//
// Server-side authorization for privileged P2P dispute actions (claim / resolve).
// The admin authorization is NOT the client isAdmin flag (which is cosmetic and
// bypassable). Here we:
// 1. Verify a wallet signature over a canonical admin-action challenge.
// 2. Check the signing wallet is in the server-side admin wallet set.
// 3. Enforce freshness + single-use nonce (replay protection).
// 4. For 'resolve', invoke admin_resolve_dispute() with the service role so the
// escrow movement + status changes happen atomically server-side.
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'npm:@supabase/supabase-js@2'
import {
verifyWalletSignature,
buildAdminChallenge,
consumeNonce,
isFreshTimestamp,
} from '../_shared/identity-auth.ts'
const ALLOWED_ORIGINS = [
'https://app.pezkuwichain.io',
'https://www.pezkuwichain.io',
'https://pezkuwichain.io',
]
function getCorsHeaders(origin: string | null) {
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
return {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Credentials': 'true',
}
}
// Authoritative admin wallet set (server-side). Overridable via ADMIN_WALLETS
// (comma-separated SS58). Defaults mirror the historical client whitelist.
function getAdminWallets(): string[] {
const env = Deno.env.get('ADMIN_WALLETS')
if (env) return env.split(',').map(s => s.trim()).filter(Boolean)
return [
'5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF', // Founder
'5EhCpn82QtdU53MF6PoNFrKHgSrsfcAxFTMwrn3JYf9dioQw', // Treasury admin
'5ELgySrX5ZyK7EWXjj6bAedyTCcTNWDANbiiipsT5gnpoCEp', // Admin
]
}
interface Body {
action?: 'claim' | 'resolve'
disputeId?: string
tradeId?: string
decision?: string
reasoning?: string
adminAddress?: string
signature?: string
timestamp?: number
nonce?: string
}
serve(async (req) => {
const corsHeaders = getCorsHeaders(req.headers.get('Origin'))
const json = (status: number, obj: unknown) =>
new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
const serviceClient = createClient(supabaseUrl, supabaseServiceKey)
const body: Body = await req.json()
const { action, disputeId, tradeId, decision, reasoning, adminAddress, signature, timestamp, nonce } = body
if (!action || !['claim', 'resolve'].includes(action)) {
return json(400, { success: false, error: 'Invalid action' })
}
if (!disputeId || !tradeId || !adminAddress || !signature || !nonce || typeof timestamp !== 'number') {
return json(401, { success: false, error: 'Missing authorization fields' })
}
if (action === 'resolve' && (!decision || !reasoning)) {
return json(400, { success: false, error: 'Decision and reasoning are required' })
}
// 1) Freshness
if (!isFreshTimestamp(timestamp)) {
return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' })
}
// 2) Verify signature over the canonical admin challenge
const challenge = buildAdminChallenge({
action,
disputeId,
tradeId,
decision: String(decision ?? ''),
adminAddress,
timestamp,
nonce,
})
const sigOk = await verifyWalletSignature(challenge, signature, adminAddress)
if (!sigOk) {
return json(401, { success: false, error: 'Invalid signature' })
}
// 3) Server-side admin authorization
if (!getAdminWallets().includes(adminAddress)) {
return json(403, { success: false, error: 'Wallet is not authorized for admin actions' })
}
// 4) Replay protection
const nonceOk = await consumeNonce(serviceClient, nonce, `admin_${action}`)
if (!nonceOk) {
return json(401, { success: false, error: 'Authorization already used (replay detected)' })
}
// ---- CLAIM ----
if (action === 'claim') {
const { error } = await serviceClient
.from('p2p_fiat_disputes')
.update({ status: 'under_review', assigned_at: new Date().toISOString() })
.eq('id', disputeId)
if (error) return json(500, { success: false, error: 'Failed to claim dispute' })
return json(200, { success: true, action: 'claim' })
}
// ---- RESOLVE (atomic escrow movement + status) ----
const { data, error } = await serviceClient.rpc('admin_resolve_dispute', {
p_dispute_id: disputeId,
p_trade_id: tradeId,
p_decision: decision,
p_reasoning: reasoning,
p_admin_ref: adminAddress,
})
if (error) {
console.error('admin_resolve_dispute error:', error)
return json(500, { success: false, error: error.message || 'Resolution failed' })
}
const result = typeof data === 'string' ? JSON.parse(data) : data
if (!result?.success) {
return json(400, { success: false, error: result?.error || 'Resolution failed' })
}
return json(200, { success: true, ...result })
} catch (error) {
console.error('resolve-dispute error:', error)
return json(500, { success: false, error: 'Internal server error' })
}
})
@@ -8,6 +8,7 @@ import { createClient } from 'npm:@supabase/supabase-js@2'
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36'
import { blake2b } from 'npm:@noble/hashes@1.7.1/blake2b'
import { base58 } from 'npm:@scure/base@1.2.4'
import { assertIdentityOwnedByWallet } from '../_shared/identity-auth.ts'
// Allowed origins for CORS
const ALLOWED_ORIGINS = [
@@ -61,6 +62,17 @@ async function identityToUUID(identityId: string): Promise<string> {
// PEZ asset ID
const PEZ_ASSET_ID = 1
// People Chain endpoint — required to verify citizen NFT ownership server-side
const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io'
let peopleApiInstance: ApiPromise | null = null
async function getPeopleApi(): Promise<ApiPromise> {
if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance
const provider = new WsProvider(PEOPLE_RPC_ENDPOINT)
peopleApiInstance = await ApiPromise.create({ provider })
return peopleApiInstance
}
interface DepositRequest {
txHash: string
token: 'HEZ' | 'PEZ'
@@ -501,6 +513,30 @@ serve(async (req) => {
)
}
// Bind the crediting identity to the depositing wallet. The on-chain sender
// has just been proven to equal `walletAddress`; requiring that wallet to
// OWN `identityId` prevents crediting a balance the depositor does not own
// (citizen NFT / active visa binding).
const depositOwnership = await assertIdentityOwnedByWallet(serviceClient, identityId, walletAddress, getPeopleApi)
if (!depositOwnership.ok) {
await serviceClient
.from('p2p_deposit_withdraw_requests')
.update({
status: 'failed',
error_message: `Identity ownership check failed: ${depositOwnership.error}`,
processed_at: new Date().toISOString()
})
.eq('id', depositRequest.id)
return new Response(
JSON.stringify({
success: false,
error: depositOwnership.error || 'Depositing wallet does not own this identity'
}),
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// Process deposit
const { data: processResult, error: processError } = await serviceClient
.rpc('process_deposit', {
@@ -0,0 +1,118 @@
-- =====================================================
-- Migration: Withdrawal authorization hardening
-- Date: 2026-07-25
-- =====================================================
--
-- Part of the fix for the CRITICAL broken-object-level-auth finding on the
-- withdrawal path. Two defenses:
--
-- 1. Replay protection for the signed withdrawal challenge (nonce store).
-- 2. Defense-in-depth: request_withdraw() may ONLY be executed by the backend
-- service role. Previously it was executable by anon/authenticated, so an
-- attacker with the public anon key could call it directly with a victim's
-- user_id to create a pending withdrawal to an attacker-controlled wallet,
-- which the batch processor would then pay out.
--
-- =====================================================
-- 1. CHALLENGE NONCE STORE (single-use signed challenges)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_challenge_nonces (
nonce TEXT PRIMARY KEY,
purpose TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_p2p_challenge_nonces_created
ON public.p2p_challenge_nonces(created_at);
ALTER TABLE public.p2p_challenge_nonces ENABLE ROW LEVEL SECURITY;
-- Only the service role (edge functions) may read/write nonces.
DROP POLICY IF EXISTS "challenge_nonces_service_only" ON public.p2p_challenge_nonces;
CREATE POLICY "challenge_nonces_service_only" ON public.p2p_challenge_nonces
FOR ALL USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
REVOKE ALL ON public.p2p_challenge_nonces FROM anon, authenticated, PUBLIC;
-- =====================================================
-- 2. LOCK request_withdraw() TO SERVICE ROLE
-- =====================================================
-- Recreate with an explicit service-role guard (mirrors process_deposit()).
CREATE OR REPLACE FUNCTION request_withdraw(
p_user_id UUID,
p_token TEXT,
p_amount DECIMAL(20, 12),
p_wallet_address TEXT
) RETURNS JSON AS $$
DECLARE
v_balance RECORD;
v_request_id UUID;
BEGIN
-- SECURITY: backend service role only. Object-level authorization (proving the
-- caller owns p_user_id) is enforced by the process-withdraw edge function via
-- a verified wallet signature before this is ever invoked.
IF current_setting('role', true) <> 'service_role'
AND current_setting('request.jwt.claim.role', true) <> 'service_role' THEN
RETURN json_build_object(
'success', false,
'error', 'UNAUTHORIZED: withdrawals must go through the backend service'
);
END IF;
-- Lock user's balance
SELECT * INTO v_balance
FROM user_internal_balances
WHERE user_id = p_user_id AND token = p_token
FOR UPDATE;
-- Check sufficient available balance
IF v_balance IS NULL OR v_balance.available_balance < p_amount THEN
RETURN json_build_object(
'success', false,
'error', 'Insufficient available balance. Available: ' || COALESCE(v_balance.available_balance, 0)
);
END IF;
-- Lock the amount (move to locked_balance)
UPDATE user_internal_balances
SET
available_balance = available_balance - p_amount,
locked_balance = locked_balance + p_amount,
updated_at = NOW()
WHERE user_id = p_user_id AND token = p_token;
-- Create withdrawal request
INSERT INTO p2p_deposit_withdraw_requests (
user_id, request_type, token, amount, wallet_address, status
) VALUES (
p_user_id, 'withdraw', p_token, p_amount, p_wallet_address, 'pending'
) RETURNING id INTO v_request_id;
-- Log the transaction
INSERT INTO p2p_balance_transactions (
user_id, token, transaction_type, amount,
balance_before, balance_after, reference_type, reference_id,
description
) VALUES (
p_user_id, p_token, 'withdraw', -p_amount,
v_balance.available_balance, v_balance.available_balance - p_amount, 'withdraw_request', v_request_id,
'Withdrawal request to ' || p_wallet_address
);
RETURN json_build_object(
'success', true,
'request_id', v_request_id,
'amount', p_amount,
'wallet_address', p_wallet_address,
'status', 'pending'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
REVOKE EXECUTE ON FUNCTION request_withdraw(UUID, TEXT, DECIMAL, TEXT) FROM PUBLIC, anon, authenticated;
COMMENT ON FUNCTION request_withdraw IS
'Create withdrawal request and lock balance. SERVICE ROLE ONLY — called by the
process-withdraw edge function after it verifies the caller owns the identity via
a wallet signature. Never call directly from the client.';
@@ -0,0 +1,122 @@
-- =====================================================
-- Migration: Harden RLS on financial + PII tables
-- Date: 2026-07-25
-- =====================================================
--
-- Forward migration (does NOT rewrite history) that removes the blanket
-- `USING (true)` read policies added in 20260223160000_fix_rls_for_wallet_auth
-- on the crown-jewel financial/PII tables, so the public anon key can no longer
-- enumerate every user's balances, ledger history, deposit/withdraw requests, or
-- payment-method PII.
--
-- Reads the app legitimately needs are moved behind SECURITY DEFINER RPCs that
-- return only the requested user's rows (instead of the whole table). Writes to
-- these tables already go through service_role edge functions / SECURITY DEFINER
-- RPCs, so no client write path is affected.
--
-- NOTE (residual, tracked): user_id is a UUID v5 derived from a citizen/visa
-- number, which is not a secret. These RPCs remove full-table exfiltration but do
-- not yet cryptographically bind the reader to the row owner. Fully closing that
-- requires a signed-session read path (same mechanism as withdrawals) applied to
-- these reads — see the security report. The high-severity leak (dump ALL rows)
-- is closed here.
-- =====================================================
-- 1. user_internal_balances — no direct client reads (uses
-- get_user_internal_balance RPC which is SECURITY DEFINER and bypasses RLS)
-- =====================================================
DROP POLICY IF EXISTS "balances_anon_select" ON public.user_internal_balances;
-- No anon policy remains -> anon/authenticated cannot read the table directly.
-- SECURITY DEFINER RPCs (get_user_internal_balance) continue to work.
-- =====================================================
-- 2. p2p_balance_transactions — replace open SELECT with a scoped RPC
-- =====================================================
DROP POLICY IF EXISTS "balance_tx_anon_select" ON public.p2p_balance_transactions;
CREATE OR REPLACE FUNCTION public.get_user_balance_transactions(
p_user_id UUID,
p_limit INT DEFAULT 50
) RETURNS SETOF public.p2p_balance_transactions AS $$
SELECT *
FROM public.p2p_balance_transactions
WHERE user_id = p_user_id
ORDER BY created_at DESC
LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.get_user_balance_transactions(UUID, INT) TO anon, authenticated;
COMMENT ON FUNCTION public.get_user_balance_transactions IS
'Returns balance-transaction history for a single user_id only (no full-table
dump). Replaces the removed USING(true) SELECT policy.';
-- =====================================================
-- 3. p2p_deposit_withdraw_requests — replace open SELECT with a scoped RPC
-- (service_role write policy from 20260223160000 is retained)
-- =====================================================
DROP POLICY IF EXISTS "deposit_requests_anon_select" ON public.p2p_deposit_withdraw_requests;
CREATE OR REPLACE FUNCTION public.get_user_deposit_withdraw_requests(
p_user_id UUID,
p_limit INT DEFAULT 50
) RETURNS SETOF public.p2p_deposit_withdraw_requests AS $$
SELECT *
FROM public.p2p_deposit_withdraw_requests
WHERE user_id = p_user_id
ORDER BY created_at DESC
LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.get_user_deposit_withdraw_requests(UUID, INT) TO anon, authenticated;
COMMENT ON FUNCTION public.get_user_deposit_withdraw_requests IS
'Returns deposit/withdraw requests for a single user_id only. Replaces the
removed USING(true) SELECT policy.';
-- =====================================================
-- 4. p2p_user_payment_methods (IBAN / payment PII) — lock to service role
-- (no direct client access exists; offers embed encrypted payment details).
-- =====================================================
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'p2p_user_payment_methods') THEN
EXECUTE 'ALTER TABLE public.p2p_user_payment_methods ENABLE ROW LEVEL SECURITY';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_select" ON public.p2p_user_payment_methods';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_insert" ON public.p2p_user_payment_methods';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_update" ON public.p2p_user_payment_methods';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_service_only" ON public.p2p_user_payment_methods';
EXECUTE 'CREATE POLICY "user_pm_service_only" ON public.p2p_user_payment_methods
FOR ALL USING (auth.role() = ''service_role'') WITH CHECK (auth.role() = ''service_role'')';
EXECUTE 'REVOKE ALL ON public.p2p_user_payment_methods FROM anon, authenticated, PUBLIC';
END IF;
END $$;
-- =====================================================
-- 5. p2p_fiat_disputes — remove world-open UPDATE.
-- Dispute state transitions (claim/resolve) now go through the service-role
-- `resolve-dispute` edge function (which verifies the admin wallet signature).
-- Regular users still INSERT disputes (disputes_anon_insert retained).
-- =====================================================
DROP POLICY IF EXISTS "disputes_anon_update" ON public.p2p_fiat_disputes;
DROP POLICY IF EXISTS "disputes_service_update" ON public.p2p_fiat_disputes;
CREATE POLICY "disputes_service_update" ON public.p2p_fiat_disputes
FOR UPDATE USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- =====================================================
-- Done. Balances, ledger, deposit/withdraw requests and payment PII are no longer
-- world-readable via the anon key; dispute resolution is no longer world-writable.
--
-- RESIDUAL (documented in the security report, NOT closed here to avoid breaking
-- the live app without the signed-session refactor):
-- * p2p_fiat_offers / p2p_fiat_trades / p2p_messages still carry USING(true)
-- policies because the client reads/writes them directly with the anon key
-- and there is no server session to bind to. Marking a trade status does not
-- itself move funds.
-- * The internal-ledger escrow RPCs (lock_/release_/refund_escrow_internal)
-- are still EXECUTE-able by anon (confirmPaymentReceived / createFiatOffer
-- call them directly). This is a SEPARATE critical hole that needs the same
-- signed-challenge mechanism applied to trade actions (an authenticated
-- confirm-payment / create-offer edge function). See report.
-- =====================================================
@@ -0,0 +1,158 @@
-- =====================================================
-- Migration: Atomic admin dispute resolution (moves escrow)
-- Date: 2026-07-25
-- =====================================================
--
-- Fix for the CRITICAL fund-logic finding: the admin Dispute Resolution panel
-- only relabelled p2p_fiat_trades.status and NEVER moved escrow, so "buyer wins"
-- paid nothing, "refund seller" left funds locked, and "split" was unhandled.
--
-- This SECURITY DEFINER function performs the escrow movement AND all status
-- changes in a single transaction. It is service-role only; caller identity
-- (admin) is verified by the resolve-dispute edge function via a wallet
-- signature before this is invoked.
--
-- ESCROW ACCOUNTING (verified against createFiatOffer + accept_p2p_offer):
-- * createFiatOffer locks the FULL offer amount (available -> locked).
-- * accept_p2p_offer does NOT lock again; it only decrements
-- offer.remaining_amount. So: seller.locked == remaining_amount + SUM(active
-- trade amounts) for the offer.
-- Therefore, per trade of amount X:
-- - release_to_buyer : locked(seller) -= X, available(buyer) += X. (offer untouched)
-- - refund_to_seller : locked(seller) -= X, available(seller) += X. (offer untouched)
-- - split : release X/2 to buyer + refund X/2 to seller. (offer untouched)
-- remaining_amount is intentionally NOT restored (that would double-count the
-- funds — a latent bug in the older resolve_p2p_dispute()).
CREATE OR REPLACE FUNCTION public.admin_resolve_dispute(
p_dispute_id UUID,
p_trade_id UUID,
p_decision TEXT, -- 'release_to_buyer' | 'refund_to_seller' | 'split' | 'escalate'
p_reasoning TEXT,
p_admin_ref TEXT -- admin wallet address (for audit only)
) RETURNS JSON AS $$
DECLARE
v_trade RECORD;
v_token TEXT;
v_half DECIMAL(20, 12);
v_res JSON;
v_trade_status TEXT;
BEGIN
-- SECURITY: backend service role only. Admin identity is verified upstream by
-- the resolve-dispute edge function (wallet signature vs. admin wallet set).
IF current_setting('role', true) <> 'service_role'
AND current_setting('request.jwt.claim.role', true) <> 'service_role' THEN
RETURN json_build_object('success', false, 'error', 'UNAUTHORIZED: service role required');
END IF;
IF p_reasoning IS NULL OR length(trim(p_reasoning)) = 0 THEN
RETURN json_build_object('success', false, 'error', 'Reasoning is required');
END IF;
IF p_decision NOT IN ('release_to_buyer', 'refund_to_seller', 'split', 'escalate') THEN
RETURN json_build_object('success', false, 'error', 'Invalid decision');
END IF;
-- Lock the trade row
SELECT * INTO v_trade FROM public.p2p_fiat_trades WHERE id = p_trade_id FOR UPDATE;
IF NOT FOUND THEN
RETURN json_build_object('success', false, 'error', 'Trade not found');
END IF;
-- ----- ESCALATE: no fund movement, just mark dispute escalated -----
IF p_decision = 'escalate' THEN
UPDATE public.p2p_fiat_disputes
SET status = 'escalated', decision = 'escalate', decision_reasoning = p_reasoning, updated_at = NOW()
WHERE id = p_dispute_id;
INSERT INTO public.p2p_audit_log (user_id, action, entity_type, entity_id, details)
VALUES (NULL, 'dispute_escalated', 'trade', p_trade_id,
jsonb_build_object('dispute_id', p_dispute_id, 'admin_ref', p_admin_ref, 'reasoning', p_reasoning));
RETURN json_build_object('success', true, 'decision', 'escalate');
END IF;
-- For fund-moving decisions the trade must be in dispute.
IF v_trade.status <> 'disputed' THEN
RETURN json_build_object('success', false, 'error', 'Trade is not in disputed status (current: ' || v_trade.status || ')');
END IF;
-- Resolve token from the offer
SELECT token INTO v_token FROM public.p2p_fiat_offers WHERE id = v_trade.offer_id;
IF v_token IS NULL THEN
RETURN json_build_object('success', false, 'error', 'Offer/token not found for trade');
END IF;
IF p_decision = 'release_to_buyer' THEN
v_res := public.release_escrow_internal(
v_trade.seller_id, v_trade.buyer_id, v_token, v_trade.crypto_amount, 'dispute_resolution', p_trade_id);
IF (v_res->>'success')::boolean IS NOT TRUE THEN
RAISE EXCEPTION 'release_escrow_internal failed: %', COALESCE(v_res->>'error', 'unknown');
END IF;
v_trade_status := 'completed';
ELSIF p_decision = 'refund_to_seller' THEN
v_res := public.refund_escrow_internal(
v_trade.seller_id, v_token, v_trade.crypto_amount, 'dispute_resolution', p_trade_id);
IF (v_res->>'success')::boolean IS NOT TRUE THEN
RAISE EXCEPTION 'refund_escrow_internal failed: %', COALESCE(v_res->>'error', 'unknown');
END IF;
v_trade_status := 'refunded';
ELSIF p_decision = 'split' THEN
v_half := ROUND(v_trade.crypto_amount / 2, 12);
-- Half to buyer
v_res := public.release_escrow_internal(
v_trade.seller_id, v_trade.buyer_id, v_token, v_half, 'dispute_resolution_split', p_trade_id);
IF (v_res->>'success')::boolean IS NOT TRUE THEN
RAISE EXCEPTION 'split release failed: %', COALESCE(v_res->>'error', 'unknown');
END IF;
-- Remainder back to seller (handles odd cents deterministically)
v_res := public.refund_escrow_internal(
v_trade.seller_id, v_token, v_trade.crypto_amount - v_half, 'dispute_resolution_split', p_trade_id);
IF (v_res->>'success')::boolean IS NOT TRUE THEN
RAISE EXCEPTION 'split refund failed: %', COALESCE(v_res->>'error', 'unknown');
END IF;
v_trade_status := 'completed';
END IF;
-- Update trade
UPDATE public.p2p_fiat_trades
SET status = v_trade_status,
completed_at = CASE WHEN v_trade_status = 'completed' THEN NOW() ELSE completed_at END,
escrow_released_at = NOW(),
dispute_resolved_at = NOW(),
dispute_resolution = p_decision || ': ' || COALESCE(p_reasoning, ''),
updated_at = NOW()
WHERE id = p_trade_id;
-- Update dispute
UPDATE public.p2p_fiat_disputes
SET status = 'resolved', decision = p_decision, decision_reasoning = p_reasoning,
resolved_at = NOW(), updated_at = NOW()
WHERE id = p_dispute_id;
-- Notify both parties (best-effort, same txn)
INSERT INTO public.p2p_notifications (user_id, type, title, message, reference_type, reference_id)
VALUES
(v_trade.seller_id, 'dispute_resolved', 'Dispute Resolved', 'Your dispute was resolved: ' || p_decision, 'dispute', p_dispute_id),
(v_trade.buyer_id, 'dispute_resolved', 'Dispute Resolved', 'Your dispute was resolved: ' || p_decision, 'dispute', p_dispute_id);
-- Audit
INSERT INTO public.p2p_audit_log (user_id, action, entity_type, entity_id, details)
VALUES (NULL, 'dispute_resolved', 'trade', p_trade_id,
jsonb_build_object(
'dispute_id', p_dispute_id, 'decision', p_decision, 'admin_ref', p_admin_ref,
'reasoning', p_reasoning, 'seller_id', v_trade.seller_id, 'buyer_id', v_trade.buyer_id,
'amount', v_trade.crypto_amount, 'token', v_token));
RETURN json_build_object('success', true, 'decision', p_decision, 'trade_id', p_trade_id, 'token', v_token, 'amount', v_trade.crypto_amount);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
REVOKE EXECUTE ON FUNCTION public.admin_resolve_dispute(UUID, UUID, TEXT, TEXT, TEXT) FROM PUBLIC, anon, authenticated;
COMMENT ON FUNCTION public.admin_resolve_dispute IS
'Atomically resolves a P2P dispute: moves escrow (release/refund/split) AND
updates trade + dispute status + notifications in one transaction. SERVICE ROLE
ONLY — admin identity is verified by the resolve-dispute edge function.';
@@ -0,0 +1,52 @@
-- =====================================================
-- Migration: Lock down internal-ledger escrow RPCs
-- Date: 2026-07-25
-- =====================================================
--
-- Fix for the CRITICAL drain: lock_/release_/refund_escrow_internal (migration
-- 014) had default PUBLIC EXECUTE, and the client called release_escrow_internal
-- directly with the public anon key. An anon caller could therefore run
-- release_escrow_internal(victim, attacker, token, amount)
-- and drain any victim's LOCKED balance.
--
-- These functions now execute ONLY as the service role. Every legitimate caller
-- goes through a service-role edge function that first verifies a wallet
-- signature + identity ownership:
-- * release -> confirm-payment edge function (seller-authorized)
-- * lock -> lock-escrow edge function (owner-authorized)
-- * refund -> process-withdraw (on failed payout) + admin_resolve_dispute
-- (dispute refunds/splits). No direct client caller.
--
-- SECURITY DEFINER functions that call these internally (admin_resolve_dispute,
-- cancel_expired_trades, process_deposit) are unaffected: EXECUTE for an inner
-- call is checked against the function OWNER, not the session role.
-- release_escrow_internal(p_from_user_id, p_to_user_id, p_token, p_amount, p_reference_type, p_reference_id)
REVOKE EXECUTE ON FUNCTION release_escrow_internal(UUID, UUID, TEXT, DECIMAL, TEXT, UUID)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION release_escrow_internal(UUID, UUID, TEXT, DECIMAL, TEXT, UUID)
TO service_role;
-- refund_escrow_internal(p_user_id, p_token, p_amount, p_reference_type, p_reference_id)
REVOKE EXECUTE ON FUNCTION refund_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION refund_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
TO service_role;
-- lock_escrow_internal(p_user_id, p_token, p_amount, p_reference_type, p_reference_id)
REVOKE EXECUTE ON FUNCTION lock_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION lock_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
TO service_role;
COMMENT ON FUNCTION release_escrow_internal IS
'Release escrow seller->buyer. SERVICE ROLE ONLY — invoked by the confirm-payment
edge function after verifying the seller''s wallet signature + identity ownership.';
COMMENT ON FUNCTION refund_escrow_internal IS
'Refund locked escrow to its owner. SERVICE ROLE ONLY — invoked by process-withdraw
(failed payout) and admin_resolve_dispute (dispute refund/split).';
COMMENT ON FUNCTION lock_escrow_internal IS
'Lock a user''s own available balance into escrow. SERVICE ROLE ONLY — invoked by
the lock-escrow edge function after verifying the caller owns that identity.';
@@ -0,0 +1,109 @@
-- =====================================================
-- Migration: Freeze fund-routing columns on trades/offers
-- Date: 2026-07-25
-- =====================================================
--
-- Fix for a CONFIRMED custody-critical escrow-redirection bypass.
--
-- p2p_fiat_trades still carries a world-open UPDATE policy
-- (trades_anon_update USING(true), migration 20260223160000) because the client
-- legitimately flips status / uploads payment proof with the anon key, and there
-- is no server session to bind row ownership to. The 20260725010000 hardening
-- migration knowingly left this, on the assumption that "marking a trade status
-- does not itself move funds".
--
-- That assumption is INCOMPLETE. The seller-authorized release path
-- (confirm-payment edge function) and admin_resolve_dispute() read the fund
-- DESTINATION and AMOUNT from the trade row at release time:
-- release_escrow_internal(trade.seller_id, trade.buyer_id, token, trade.crypto_amount)
-- Because buyer_id / crypto_amount / offer_id are attacker-writable via the anon
-- key, an external attacker can do
-- UPDATE p2p_fiat_trades SET buyer_id = <attacker> WHERE id = <victim_trade>
-- and, when the honest seller signs the (correctly authenticated) release, the
-- escrow is paid to the attacker instead of the buyer who sent fiat. The
-- signature gate is intact but authorizes a row whose routing was tampered.
--
-- Minimal fix (no app-breaking change, no signed-session refactor required):
-- make the fund-routing / amount columns IMMUTABLE after insert via a BEFORE
-- UPDATE trigger. No legitimate code path ever mutates these columns after the
-- trade/offer is created (accept_p2p_offer INSERTs them; every UPDATE — status,
-- proof, timestamps, dispute fields, remaining_amount, price edits — leaves them
-- untouched). The service role is still allowed to change them so future
-- backend tooling / manual reconciliation is not boxed in.
-- -----------------------------------------------------
-- 1. p2p_fiat_trades: freeze routing + amount columns
-- -----------------------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_trade_financial_immutability()
RETURNS TRIGGER AS $$
BEGIN
-- Allow the backend service role to change anything (reconciliation).
IF current_setting('role', true) = 'service_role'
OR current_setting('request.jwt.claim.role', true) = 'service_role' THEN
RETURN NEW;
END IF;
IF NEW.offer_id IS DISTINCT FROM OLD.offer_id
OR NEW.seller_id IS DISTINCT FROM OLD.seller_id
OR NEW.buyer_id IS DISTINCT FROM OLD.buyer_id
OR NEW.crypto_amount IS DISTINCT FROM OLD.crypto_amount
OR NEW.fiat_amount IS DISTINCT FROM OLD.fiat_amount
OR NEW.price_per_unit IS DISTINCT FROM OLD.price_per_unit
OR NEW.escrow_locked_amount IS DISTINCT FROM OLD.escrow_locked_amount THEN
RAISE EXCEPTION 'Trade fund-routing columns are immutable (offer_id, seller_id, buyer_id, crypto_amount, fiat_amount, price_per_unit, escrow_locked_amount)';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_freeze_trade_financials ON public.p2p_fiat_trades;
CREATE TRIGGER trg_freeze_trade_financials
BEFORE UPDATE ON public.p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION public.enforce_trade_financial_immutability();
-- -----------------------------------------------------
-- 2. p2p_fiat_offers: freeze token / seller / original amount
-- (token drives WHICH balance release_escrow_internal moves; seller_id is the
-- escrow owner). Status, remaining_amount, price and fiat fields stay editable
-- so the existing offer edit / pause / partial-fill flows keep working.
-- -----------------------------------------------------
CREATE OR REPLACE FUNCTION public.enforce_offer_financial_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF current_setting('role', true) = 'service_role'
OR current_setting('request.jwt.claim.role', true) = 'service_role' THEN
RETURN NEW;
END IF;
IF NEW.seller_id IS DISTINCT FROM OLD.seller_id
OR NEW.token IS DISTINCT FROM OLD.token
OR NEW.amount_crypto IS DISTINCT FROM OLD.amount_crypto THEN
RAISE EXCEPTION 'Offer fund-routing columns are immutable (seller_id, token, amount_crypto)';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_freeze_offer_financials ON public.p2p_fiat_offers;
CREATE TRIGGER trg_freeze_offer_financials
BEFORE UPDATE ON public.p2p_fiat_offers
FOR EACH ROW EXECUTE FUNCTION public.enforce_offer_financial_immutability();
COMMENT ON FUNCTION public.enforce_trade_financial_immutability IS
'Blocks anon/authenticated UPDATEs from changing a trade''s fund-routing/amount
columns (escrow redirection defense). Service role is exempt.';
COMMENT ON FUNCTION public.enforce_offer_financial_immutability IS
'Blocks anon/authenticated UPDATEs from changing an offer''s seller/token/amount
(escrow token mis-routing defense). Service role is exempt.';
-- ---------------------------------------------------------------------------
-- 3. Remove the dead legacy escrow-mover from the anon attack surface.
-- resolve_p2p_dispute() is SECURITY DEFINER and moves escrow, but its
-- auth.uid() admin gate is NULL under the public anon key, so it already
-- fails closed for anon. Revoking PUBLIC/anon/authenticated EXECUTE removes
-- it as an attack surface entirely (dispute resolution now flows through the
-- wallet-signed resolve-dispute edge function + admin_resolve_dispute).
-- ---------------------------------------------------------------------------
REVOKE EXECUTE ON FUNCTION resolve_p2p_dispute FROM PUBLIC, anon, authenticated;
@@ -0,0 +1,66 @@
// DB-backed verification of migration 20260725040000 (BYPASS #1 custody fix):
// the freeze trigger must make trade fund-routing columns immutable for non-
// service-role writers. Runs the EXACT trigger from the migration on a real
// Postgres engine (pglite/WASM, no system install). Run:
// deno run -A web/supabase/migrations/__tests__/freeze_trade_financials.test.ts
// NOTE: proves the trigger (primary custody guard, current_setting-based). The
// RLS/REVOKE role-enforcement in the sibling migrations is single-user-untestable
// here and must be verified on a multi-role staging Postgres (see DEPLOY_RUNBOOK).
import { PGlite } from "npm:@electric-sql/pglite";
const db = new PGlite();
let pass = 0, fail = 0;
const ok = (c:boolean,m:string)=>{ c?(pass++,console.log(" ✅ "+m)):(fail++,console.log(" ❌ "+m)); };
async function raises(sql:string){ try{ await db.exec(sql); return false; }catch(_e){ return true; } }
// minimal schema + EXACT trigger from migration 20260725040000
await db.exec(`
CREATE TABLE p2p_fiat_trades (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
offer_id uuid, seller_id uuid, buyer_id uuid,
crypto_amount numeric, fiat_amount numeric, price_per_unit numeric,
escrow_locked_amount numeric, status text, buyer_payment_reference text
);
CREATE OR REPLACE FUNCTION enforce_trade_financial_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF current_setting('role', true) = 'service_role'
OR current_setting('request.jwt.claim.role', true) = 'service_role' THEN
RETURN NEW;
END IF;
IF NEW.offer_id IS DISTINCT FROM OLD.offer_id
OR NEW.seller_id IS DISTINCT FROM OLD.seller_id
OR NEW.buyer_id IS DISTINCT FROM OLD.buyer_id
OR NEW.crypto_amount IS DISTINCT FROM OLD.crypto_amount
OR NEW.fiat_amount IS DISTINCT FROM OLD.fiat_amount
OR NEW.price_per_unit IS DISTINCT FROM OLD.price_per_unit
OR NEW.escrow_locked_amount IS DISTINCT FROM OLD.escrow_locked_amount THEN
RAISE EXCEPTION 'Trade fund-routing columns are immutable';
END IF;
RETURN NEW;
END;$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_freeze_trade_financials BEFORE UPDATE ON p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION enforce_trade_financial_immutability();
INSERT INTO p2p_fiat_trades (id, seller_id, buyer_id, crypto_amount, status)
VALUES ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222','33333333-3333-3333-3333-333333333333', 100, 'payment_sent');
`);
console.log("BYPASS #1 (escrow-release redirection via trade-row) — freeze trigger:");
// A) anon UPDATE buyer_id -> MUST raise
await db.exec(`SELECT set_config('request.jwt.claim.role','anon',false);`);
ok(await raises(`UPDATE p2p_fiat_trades SET buyer_id='44444444-4444-4444-4444-444444444444' WHERE id='11111111-1111-1111-1111-111111111111'`),
"anon UPDATE buyer_id (attacker redirect) -> BLOCKED");
// B) anon UPDATE crypto_amount -> MUST raise
ok(await raises(`UPDATE p2p_fiat_trades SET crypto_amount=999999 WHERE id='11111111-1111-1111-1111-111111111111'`),
"anon UPDATE crypto_amount (amount tamper) -> BLOCKED");
// C) anon UPDATE status only -> MUST succeed (non-custodial fields open)
ok(!(await raises(`UPDATE p2p_fiat_trades SET status='paid', buyer_payment_reference='x' WHERE id='11111111-1111-1111-1111-111111111111'`)),
"anon UPDATE status/proof only -> ALLOWED (correct)");
// D) service_role UPDATE buyer_id -> MUST succeed (exempt)
await db.exec(`SELECT set_config('request.jwt.claim.role','service_role',false);`);
ok(!(await raises(`UPDATE p2p_fiat_trades SET buyer_id='55555555-5555-5555-5555-555555555555' WHERE id='11111111-1111-1111-1111-111111111111'`)),
"service_role UPDATE buyer_id -> ALLOWED (reconciliation)");
// verify buyer_id was NOT changed by the blocked anon attempt
const r:any = await db.query(`SELECT buyer_id FROM p2p_fiat_trades WHERE id='11111111-1111-1111-1111-111111111111'`);
ok(r.rows[0].buyer_id==='55555555-5555-5555-5555-555555555555',
"final buyer_id = service_role value (attacker redirect never persisted)");
console.log(`\nRESULT: ${pass} passed, ${fail} failed`);
Deno.exit(fail>0?1:0);
@@ -0,0 +1,52 @@
// DB-backed verification of migrations 20260725010000 (financial RLS) + 20260725030000
// (escrow REVOKE). Replicates the Supabase RLS model on a real Postgres engine
// (pglite/WASM): CREATE ROLE anon/service_role + an auth.role() stub that reads the JWT
// claim GUC exactly as Supabase does, then proves: anon can't read balances/payment PII
// or execute the escrow RPCs, while the SECURITY DEFINER read path + service_role still
// work. Run: deno run -A web/supabase/migrations/__tests__/rls_revoke_enforcement.test.ts
// Proves policy/grant LOGIC; final in-situ confirmation is the staging deploy (owner-gated).
import { PGlite } from "npm:@electric-sql/pglite";
const db = new PGlite();
let pass=0,fail=0;
const ok=(c:boolean,m:string)=>{c?(pass++,console.log(" ✅ "+m)):(fail++,console.log(" ❌ "+m));};
async function denied(sql:string){ try{ await db.exec(sql); return false; }catch(e){ return String(e).includes("permission denied")||String(e).includes("denied"); } }
async function rows(sql:string){ const r:any=await db.query(sql); return r.rows.length; }
await db.exec(`
CREATE ROLE anon NOLOGIN; CREATE ROLE authenticated NOLOGIN; CREATE ROLE service_role NOLOGIN;
CREATE SCHEMA auth;
CREATE FUNCTION auth.role() RETURNS text AS $$ SELECT current_setting('request.jwt.claim.role', true) $$ LANGUAGE sql STABLE;
-- balances table: Supabase grants anon SELECT, RLS filters. After dropping the anon USING(true) policy -> default deny.
CREATE TABLE user_internal_balances (user_id uuid, token text, available numeric);
INSERT INTO user_internal_balances VALUES ('11111111-1111-1111-1111-111111111111','HEZ', 500);
GRANT SELECT ON user_internal_balances TO anon, authenticated;
ALTER TABLE user_internal_balances ENABLE ROW LEVEL SECURITY;
-- (no permissive policy for anon = the post-migration state; reads go via SECURITY DEFINER RPC)
CREATE FUNCTION get_user_internal_balance(p uuid) RETURNS SETOF user_internal_balances AS $$ SELECT * FROM user_internal_balances WHERE user_id=p $$ LANGUAGE sql STABLE SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION get_user_internal_balance(uuid) TO anon;
-- payment methods: REVOKE ALL from anon (IBAN PII)
CREATE TABLE p2p_user_payment_methods (user_id uuid, iban text);
INSERT INTO p2p_user_payment_methods VALUES ('11111111-1111-1111-1111-111111111111','TR00...');
ALTER TABLE p2p_user_payment_methods ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_pm_service_only ON p2p_user_payment_methods FOR ALL USING (auth.role()='service_role') WITH CHECK (auth.role()='service_role');
REVOKE ALL ON p2p_user_payment_methods FROM anon, authenticated, PUBLIC;
GRANT ALL ON p2p_user_payment_methods TO service_role;
-- escrow RPC: REVOKE from anon
CREATE FUNCTION release_escrow_internal(a uuid,b uuid,t text,amt numeric) RETURNS void AS $$ BEGIN END $$ LANGUAGE plpgsql;
REVOKE EXECUTE ON FUNCTION release_escrow_internal(uuid,uuid,text,numeric) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION release_escrow_internal(uuid,uuid,text,numeric) TO service_role;
`);
console.log("RLS / REVOKE role-enforcement (Supabase auth.role() simulated):");
// anon context
await db.exec(`SELECT set_config('request.jwt.claim.role','anon',false); SET ROLE anon;`);
ok(await rows(`SELECT * FROM user_internal_balances`)===0, "anon direct SELECT balances -> 0 rows (USING(true) leak closed)");
ok(await rows(`SELECT * FROM get_user_internal_balance('11111111-1111-1111-1111-111111111111')`)===1, "anon via SECURITY DEFINER RPC -> works (legit path intact)");
ok(await denied(`SELECT * FROM p2p_user_payment_methods`), "anon SELECT payment_methods (IBAN PII) -> permission DENIED");
ok(await denied(`SELECT release_escrow_internal('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222','HEZ',100)`), "anon EXECUTE release_escrow_internal -> permission DENIED (drain closed)");
// service_role context
await db.exec(`RESET ROLE; SELECT set_config('request.jwt.claim.role','service_role',false); SET ROLE service_role;`);
ok((await rows(`SELECT * FROM p2p_user_payment_methods`))>=1, "service_role SELECT payment_methods -> allowed");
ok(!(await denied(`SELECT release_escrow_internal('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222','HEZ',100)`)), "service_role EXECUTE release_escrow_internal -> allowed");
console.log(`\nRESULT: ${pass} passed, ${fail} failed`);
Deno.exit(fail>0?1:0);
+7 -1
View File
@@ -1,5 +1,5 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import { defineConfig, configDefaults } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { nodePolyfills } from 'vite-plugin-node-polyfills';
@@ -11,6 +11,9 @@ export default defineConfig(({ command }) => ({
globals: true,
environment: 'jsdom',
setupFiles: './src/tests/setup.ts',
// supabase/** holds Deno tests (Deno.test + pglite) run via `deno test`,
// not vitest — exclude them from the web (node/jsdom) test sweep.
exclude: [...configDefaults.exclude, 'supabase/**'],
alias: {
'vite-plugin-node-polyfills/shims/buffer': path.resolve(__dirname, './src/tests/mocks/buffer-shim.ts'),
'vite-plugin-node-polyfills/shims/global': path.resolve(__dirname, './src/tests/mocks/global-shim.ts'),
@@ -66,6 +69,9 @@ export default defineConfig(({ command }) => ({
optimizeDeps: {
include: ['@pezkuwi/util-crypto', '@pezkuwi/util', '@pezkuwi/api', '@pezkuwi/extension-dapp', '@pezkuwi/keyring', 'buffer'],
},
// Strip all console.* and debugger statements from production bundles so the
// 600+ dev-time console calls never ship. Dev keeps full logging.
esbuild: command === 'build' ? { drop: ['console', 'debugger'] } : {},
build: {
rollupOptions: {
external: [],