mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 14:25:41 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Stateful indexer DB — never commit (lives on a mounted volume in prod)
|
||||
transactions.db
|
||||
*.db
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
node_modules/
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
Generated
+647
-9
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 [];
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -8,6 +8,8 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"supabase/functions/**",
|
||||
"supabase/migrations/**",
|
||||
"node_modules/**",
|
||||
"eslint.config.js",
|
||||
"postcss.config.js",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Generated
+12
-12
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
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',
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
Executable
+78
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user