From 68422d67b4e5747ac191507d43628fa30dc5aade Mon Sep 17 00:00:00 2001 From: web-ghost-dotcom <207631711+web-ghost-dotcom@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:49:21 +0100 Subject: [PATCH] feat: audit log IP and append-only table, reward supply invariant proofs, claim flow e2e, coverage gate Closes #1318, #1317, #1319, #1320 --- .github/workflows/backend-ci.yml | 30 +++ .github/workflows/contracts-ci.yml | 9 + .github/workflows/frontend-ci.yml | 37 +++ .gitignore | 2 + backend/README.md | 21 ++ backend/coverage-thresholds.json | 6 + backend/package.json | 3 +- backend/scripts/check-coverage.mjs | 106 ++++++++ backend/src/config/envValidation.test.js | 70 +++++ backend/src/dal/sqliteAuditLogRepository.js | 35 ++- .../src/dal/sqliteAuditLogRepository.test.js | 65 +++++ .../src/db/migrations/046_session_tokens.js | 7 + .../047_campaign_tenant_isolation.js | 7 + .../048_campaign_reward_notifications.js | 7 + .../db/migrations/052_audit_log_ip_address.js | 37 +++ backend/src/index.js | 3 + backend/src/lib/logger.js | 1 + .../src/middleware/cacheMiddleware.test.js | 118 ++++++++ backend/src/middleware/errorHandler.test.js | 70 +++++ backend/src/services/auditChain.js | 5 +- backend/src/services/auditChain.test.js | 37 +++ backend/src/services/auditLogService.js | 4 +- backend/src/services/auditLogService.test.js | 165 ++++++++++++ .../01_credit_preserves_conservation.smt2 | 22 ++ .../02_claim_preserves_conservation.smt2 | 24 ++ ...admin_transfer_preserves_conservation.smt2 | 23 ++ ...4_batch_credit_preserves_conservation.smt2 | 24 ++ ...t_supply_update_violates_conservation.smt2 | 20 ++ .../formal/06_claim_cannot_inflate.smt2 | 21 ++ .../formal/07_total_claimed_saturation.smt2 | 14 + .../08_initial_state_satisfies_invariant.smt2 | 13 + contracts/rewards/src/lib.rs | 39 ++- contracts/rewards/src/test.rs | 74 +++++ docs/FORMAL_VERIFICATION.md | 78 ++++++ frontend/package.json | 3 +- frontend/playwright.flow.config.js | 37 +++ frontend/src/stellar.js | 55 +++- frontend/tests/e2e/README.md | 17 ++ frontend/tests/flow/campaign-to-claim.spec.js | 203 ++++++++++++++ .../tests/flow/support/soroban-chain-mock.js | 255 ++++++++++++++++++ scripts/verify-reward-invariants.sh | 35 +++ 41 files changed, 1774 insertions(+), 28 deletions(-) create mode 100644 backend/coverage-thresholds.json create mode 100644 backend/scripts/check-coverage.mjs create mode 100644 backend/src/config/envValidation.test.js create mode 100644 backend/src/db/migrations/052_audit_log_ip_address.js create mode 100644 backend/src/lib/logger.js create mode 100644 backend/src/middleware/cacheMiddleware.test.js create mode 100644 backend/src/middleware/errorHandler.test.js create mode 100644 backend/src/services/auditChain.test.js create mode 100644 backend/src/services/auditLogService.test.js create mode 100644 contracts/rewards/formal/01_credit_preserves_conservation.smt2 create mode 100644 contracts/rewards/formal/02_claim_preserves_conservation.smt2 create mode 100644 contracts/rewards/formal/03_admin_transfer_preserves_conservation.smt2 create mode 100644 contracts/rewards/formal/04_batch_credit_preserves_conservation.smt2 create mode 100644 contracts/rewards/formal/05_batch_credit_without_supply_update_violates_conservation.smt2 create mode 100644 contracts/rewards/formal/06_claim_cannot_inflate.smt2 create mode 100644 contracts/rewards/formal/07_total_claimed_saturation.smt2 create mode 100644 contracts/rewards/formal/08_initial_state_satisfies_invariant.smt2 create mode 100644 frontend/playwright.flow.config.js create mode 100644 frontend/tests/flow/campaign-to-claim.spec.js create mode 100644 frontend/tests/flow/support/soroban-chain-mock.js create mode 100755 scripts/verify-reward-invariants.sh diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 677e29d6..722f9061 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -61,3 +61,33 @@ jobs: else echo "✅ All snapshots match" fi + + # #1320: coverage gate. Runs the whole backend suite with Node's built-in coverage (needs Node 22 + # for the lcov reporter) and fails when line, branch or function coverage drops below the floors in + # backend/coverage-thresholds.json. Test failures are reported by the steps above, not here. + backend-coverage: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Backend coverage gate + run: npm run test:coverage --workspace=backend + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: backend/coverage/lcov.info + if-no-files-found: ignore diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml index ccfea073..f6b5ffe3 100644 --- a/.github/workflows/contracts-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -69,6 +69,15 @@ jobs: node --test scripts/check-contract-events.test.mjs node scripts/check-contract-events.mjs + # #1317: SMT proofs (Z3) of the reward-accounting invariant + # TOTAL_SUPPLY == sum of balances for credit, batch_credit, claim and + # admin_transfer, plus negative controls. See docs/FORMAL_VERIFICATION.md. + - name: Verify reward accounting invariants (Z3) + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq z3 + ./scripts/verify-reward-invariants.sh + - name: Run contract tests run: cargo test --workspace diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index d2c53564..5da75d6e 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -212,3 +212,40 @@ jobs: else echo "⚠️ No test report generated for ${{ matrix.browser }}" >> $GITHUB_STEP_SUMMARY fi + + # #1319: hermetic browser flow — admin creates a campaign, the campaign page loads and a + # participant claims rewards. Builds its own bundle with an admin wallet and rewards contract + # configured; the REST API, Horizon, Soroban RPC and the wallet extension are mocked in the test. + campaign-to-claim-e2e: + name: Campaign creation to reward claim (Playwright) + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run campaign-to-claim flow + run: npm run test:e2e:flow + working-directory: frontend + + - name: Upload flow results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-flow-results + path: | + frontend/playwright-report + frontend/test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 391c6221..fd03820f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ Thumbs.db **/playwright-report-visual/ **/test-results/ backend/build/ +**/dist-e2e/ +backend/coverage/ diff --git a/backend/README.md b/backend/README.md index c677e320..2c2aa225 100644 --- a/backend/README.md +++ b/backend/README.md @@ -567,6 +567,27 @@ Requires API key if `TRIVELA_API_KEY` is set. - `action` – Filter by action (`create`, `update`, `delete`) - `page`, `limit`, `offset` – Same pagination params as other list endpoints +Every entry records **who** (`actor`), **what** (`action`, `entity`, `entityId`, `diff`), **when** +(`timestamp`) and **from where** (`ipAddress`, the request's client IP, `null` for entries written +before the column existed or without a request). The `audit_logs` table is **append-only**: database +triggers reject any `UPDATE` or `DELETE`, and each row is part of a SHA-256 hash chain (the IP is +included in the hash when present) that `GET /api/v1/admin/audit/verify` re-checks, so a rewritten or +removed entry is detected even by someone who removes the triggers. Behind a reverse proxy, set +Express's `trust proxy` so `req.ip` is the real client address rather than the proxy's. + +## Test coverage (issue #1320) + +```bash +npm run test:coverage --workspace=backend # needs Node >= 22 +``` + +This runs the whole backend suite with Node's built-in coverage, writes `backend/coverage/lcov.info` +and fails when line, branch or function coverage is below the floors in +`backend/coverage-thresholds.json` (the `backend-coverage` job in `.github/workflows/backend-ci.yml` +runs it on every pull request). The target is **85 %**; the floors are a ratchet that sits just under +the current level — when a change raises coverage, raise the floors with it, and never lower them. +The gate judges coverage only; failing tests are reported by the regular test steps. + ## PostgreSQL (issue #284) The backend ships with two repository implementations behind a single DAL factory. `better-sqlite3` diff --git a/backend/coverage-thresholds.json b/backend/coverage-thresholds.json new file mode 100644 index 00000000..cef717ff --- /dev/null +++ b/backend/coverage-thresholds.json @@ -0,0 +1,6 @@ +{ + "target": 85, + "lines": 60, + "branches": 80, + "functions": 62 +} diff --git a/backend/package.json b/backend/package.json index b44d1840..83798397 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,8 @@ "docs:build": "node scripts/build-redoc.js", "openapi:lint": "redocly lint openapi.yaml", "security:xss": "node scripts/security-audit.js", - "security-headers-check": "curl -s -I ${CHECK_URL:-http://localhost:3001/health} | grep -iE '(x-content-type|x-frame|strict-transport|content-security|referrer|permissions)'" + "security-headers-check": "curl -s -I ${CHECK_URL:-http://localhost:3001/health} | grep -iE '(x-content-type|x-frame|strict-transport|content-security|referrer|permissions)'", + "test:coverage": "node scripts/check-coverage.mjs" }, "dependencies": { "@aws-sdk/client-s3": "^3.1057.0", diff --git a/backend/scripts/check-coverage.mjs b/backend/scripts/check-coverage.mjs new file mode 100644 index 00000000..9d55f961 --- /dev/null +++ b/backend/scripts/check-coverage.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// Backend coverage gate (#1320). +// +// Runs the backend test suite with Node's built-in coverage, writes an lcov report to +// backend/coverage/lcov.info, prints a summary and fails when line, branch or function coverage +// falls below the floors in backend/coverage-thresholds.json. +// +// Why a script instead of `--test-coverage-lines=N`: the gate must judge *coverage*, independent +// of whether every test passes (test failures are reported by the regular test steps), and it +// reports all three metrics at once. Needs Node >= 22 (lcov test reporter). +// +// npm run test:coverage --workspace=backend # run tests + gate +// node scripts/check-coverage.mjs --from-lcov coverage/lcov.info # gate an existing report +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const lcovPath = join(root, 'coverage', 'lcov.info'); +const thresholds = JSON.parse(readFileSync(join(root, 'coverage-thresholds.json'), 'utf8')); + +/** Sum the LF/LH, BRF/BRH and FNF/FNH totals of an lcov report. */ +export function summariseLcov(text) { + const totals = { lines: [0, 0], branches: [0, 0], functions: [0, 0] }; + const keys = { + LF: ['lines', 0], + LH: ['lines', 1], + BRF: ['branches', 0], + BRH: ['branches', 1], + FNF: ['functions', 0], + FNH: ['functions', 1], + }; + for (const line of text.split('\n')) { + const [tag, value] = line.split(':'); + const target = keys[tag]; + if (target) totals[target[0]][target[1]] += Number(value) || 0; + } + const pct = ([found, hit]) => (found === 0 ? 100 : (hit / found) * 100); + return { + lines: pct(totals.lines), + branches: pct(totals.branches), + functions: pct(totals.functions), + }; +} + +function findTests(dir, out = []) { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'coverage') continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) findTests(full, out); + else if (entry.endsWith('.test.js')) out.push(relative(root, full)); + } + return out; +} + +function runTests() { + mkdirSync(dirname(lcovPath), { recursive: true }); + const result = spawnSync( + process.execPath, + [ + '--test', + '--experimental-test-coverage', + '--test-coverage-exclude=**/*.test.js', + '--test-coverage-exclude=**/node_modules/**', + '--test-timeout=20000', + '--test-concurrency=8', + '--test-reporter=lcov', + `--test-reporter-destination=${lcovPath}`, + '--test-reporter=dot', + '--test-reporter-destination=stdout', + ...findTests(join(root, 'src')), + ], + { cwd: root, stdio: 'inherit' }, + ); + if (result.error) throw result.error; + // A non-zero exit here only means some tests failed; those are enforced by the test steps. +} + +const fromIndex = process.argv.indexOf('--from-lcov'); +const source = fromIndex > -1 ? resolve(process.argv[fromIndex + 1]) : lcovPath; +if (fromIndex === -1) runTests(); + +if (!existsSync(source)) { + console.error(`No lcov report at ${source}; the test run produced no coverage data.`); + process.exit(2); +} + +const actual = summariseLcov(readFileSync(source, 'utf8')); +let failed = false; +console.log('\nBackend coverage'); +for (const metric of ['lines', 'branches', 'functions']) { + const floor = thresholds[metric]; + const ok = actual[metric] >= floor; + failed ||= !ok; + console.log( + ` ${ok ? 'PASS' : 'FAIL'} ${metric.padEnd(9)} ${actual[metric].toFixed(2)}% (minimum ${floor}%, target ${thresholds.target}%)`, + ); +} +if (failed) { + console.error( + '\nCoverage is below the floor. Add tests for the code you changed; do not lower coverage-thresholds.json.', + ); + process.exit(1); +} +console.log('\nFloors are ratchets: when coverage rises, raise them in coverage-thresholds.json.'); diff --git a/backend/src/config/envValidation.test.js b/backend/src/config/envValidation.test.js new file mode 100644 index 00000000..cfaa9e23 --- /dev/null +++ b/backend/src/config/envValidation.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validateBackendEnv } from './envValidation.js'; + +test('an empty environment is valid', () => { + assert.doesNotThrow(() => validateBackendEnv({})); +}); + +test('a well-formed configuration is valid', () => { + assert.doesNotThrow(() => + validateBackendEnv({ + PORT: '3001', + RATE_LIMIT_WINDOW_MS: '60000', + RATE_LIMIT_MAX_REQUESTS: '60', + CORS_ALLOWED_ORIGINS: 'https://a.example.com, http://localhost:5173', + TRIVELA_API_KEYS: 'key-one,key-two', + LOCK_TTL_MS: '5000', + EXPORT_RETENTION_DAYS: '30', + }), + ); +}); + +test('numeric settings must be positive integers', () => { + for (const bad of ['0', '-1', 'abc']) { + assert.throws(() => validateBackendEnv({ PORT: bad }), /PORT must be a positive integer/, bad); + } + assert.throws( + () => validateBackendEnv({ RATE_LIMIT_MAX_REQUESTS: '0' }), + /RATE_LIMIT_MAX_REQUESTS/, + ); + assert.throws( + () => validateBackendEnv({ SOFT_DELETE_RETENTION_DAYS: 'x' }), + /SOFT_DELETE_RETENTION_DAYS/, + ); +}); + +test('every problem is reported together in one error', () => { + try { + validateBackendEnv({ PORT: '0', LOCK_TTL_MS: 'x', EXPORT_RETENTION_DAYS: '-2' }); + assert.fail('expected a validation error'); + } catch (error) { + assert.match(error.message, /Invalid environment configuration:/); + assert.match(error.message, /- PORT must be a positive integer/); + assert.match(error.message, /- LOCK_TTL_MS must be a positive integer/); + assert.match(error.message, /- EXPORT_RETENTION_DAYS must be a positive integer/); + assert.match(error.message, /Fix the values and try again\./); + } +}); + +test('CORS origins must be valid http(s) URLs, with * allowed', () => { + assert.doesNotThrow(() => validateBackendEnv({ CORS_ALLOWED_ORIGINS: '*' })); + assert.doesNotThrow(() => validateBackendEnv({ CORS_ORIGIN: 'https://ok.example.com' })); + assert.throws(() => validateBackendEnv({ CORS_ALLOWED_ORIGINS: 'not a url' }), /invalid origin/); + assert.throws( + () => validateBackendEnv({ CORS_ALLOWED_ORIGINS: 'ftp://files.example.com' }), + /must be http\(s\)/, + ); + assert.throws(() => validateBackendEnv({ CORS_ALLOWED_ORIGINS: ' , ' }), /at least one origin/); +}); + +test('API key lists tolerate stray commas but must contain at least one key', () => { + assert.doesNotThrow(() => validateBackendEnv({ TRIVELA_API_KEY: 'solo' })); + assert.doesNotThrow(() => validateBackendEnv({ TRIVELA_API_KEYS: 'a,,b,' })); + assert.throws(() => validateBackendEnv({ TRIVELA_API_KEYS: ' , ' }), /at least one key/); +}); + +test('DB_PATH must be a string when set', () => { + assert.throws(() => validateBackendEnv({ DB_PATH: 123 }), /DB_PATH must be a string path/); + assert.doesNotThrow(() => validateBackendEnv({ DB_PATH: './trivela.db' })); +}); diff --git a/backend/src/dal/sqliteAuditLogRepository.js b/backend/src/dal/sqliteAuditLogRepository.js index 0c7b9796..a0e10f52 100644 --- a/backend/src/dal/sqliteAuditLogRepository.js +++ b/backend/src/dal/sqliteAuditLogRepository.js @@ -10,6 +10,7 @@ function rowToAuditLog(row) { entityId: row.entity_id ?? null, diff: row.diff ? JSON.parse(row.diff) : null, orgId: row.org_id ?? null, + ipAddress: row.ip_address ?? null, timestamp: row.created_at, seq: row.seq ?? null, prevHash: row.prev_hash ?? null, @@ -20,8 +21,8 @@ function rowToAuditLog(row) { export function createSqliteAuditLogRepository({ db }) { const insertStmt = db.prepare( `INSERT INTO audit_logs - (actor, action, entity, entity_id, diff, org_id, created_at, seq, prev_hash, entry_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (actor, action, entity, entity_id, diff, org_id, created_at, seq, prev_hash, entry_hash, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const lastSeqStmt = db.prepare( @@ -29,7 +30,7 @@ export function createSqliteAuditLogRepository({ db }) { ); const appendEntry = db.transaction( - ({ actor, action, entity, entityId, diffJson, orgId, createdAt }) => { + ({ actor, action, entity, entityId, diffJson, orgId, createdAt, ipAddress }) => { const last = lastSeqStmt.get(); const seq = last ? last.seq + 1 : 1; const prevHash = last ? last.entry_hash : GENESIS_HASH; @@ -41,6 +42,7 @@ export function createSqliteAuditLogRepository({ db }) { diff: diffJson ? JSON.parse(diffJson) : null, orgId, createdAt, + ipAddress, }); const info = insertStmt.run( actor, @@ -53,6 +55,7 @@ export function createSqliteAuditLogRepository({ db }) { seq, prevHash, entryHash, + ipAddress, ); return db.prepare('SELECT * FROM audit_logs WHERE id = ?').get(info.lastInsertRowid); }, @@ -66,10 +69,20 @@ export function createSqliteAuditLogRepository({ db }) { diff = null, orgId = null, timestamp = null, + ipAddress = null, }) { const createdAt = timestamp ?? new Date().toISOString(); const diffJson = diff ? JSON.stringify(diff) : null; - const row = appendEntry({ actor, action, entity, entityId, diffJson, orgId, createdAt }); + const row = appendEntry({ + actor, + action, + entity, + entityId, + diffJson, + orgId, + createdAt, + ipAddress: ipAddress || null, + }); return rowToAuditLog(row); } @@ -79,6 +92,7 @@ export function createSqliteAuditLogRepository({ db }) { action, orgId, actor, + ipAddress, startDate, endDate, limit = 100, @@ -107,6 +121,10 @@ export function createSqliteAuditLogRepository({ db }) { filters.push('actor = ?'); values.push(actor); } + if (ipAddress) { + filters.push('ip_address = ?'); + values.push(ipAddress); + } if (startDate) { filters.push('created_at >= ?'); values.push(startDate); @@ -125,7 +143,7 @@ export function createSqliteAuditLogRepository({ db }) { .map(rowToAuditLog); } - function count({ entity, entityId, action, orgId, actor, startDate, endDate } = {}) { + function count({ entity, entityId, action, orgId, actor, ipAddress, startDate, endDate } = {}) { const filters = []; const values = []; @@ -149,6 +167,10 @@ export function createSqliteAuditLogRepository({ db }) { filters.push('actor = ?'); values.push(actor); } + if (ipAddress) { + filters.push('ip_address = ?'); + values.push(ipAddress); + } if (startDate) { filters.push('created_at >= ?'); values.push(startDate); @@ -171,7 +193,7 @@ export function createSqliteAuditLogRepository({ db }) { function verify() { const rows = db .prepare( - `SELECT seq, actor, action, entity, entity_id, diff, org_id, created_at, prev_hash, entry_hash + `SELECT seq, actor, action, entity, entity_id, diff, org_id, ip_address, created_at, prev_hash, entry_hash FROM audit_logs WHERE seq IS NOT NULL ORDER BY seq ASC`, @@ -194,6 +216,7 @@ export function createSqliteAuditLogRepository({ db }) { diff: row.diff ? JSON.parse(row.diff) : null, orgId: row.org_id ?? null, createdAt: row.created_at, + ipAddress: row.ip_address ?? null, }); if (row.prev_hash !== expectedPrev || row.entry_hash !== recomputed) { diff --git a/backend/src/dal/sqliteAuditLogRepository.test.js b/backend/src/dal/sqliteAuditLogRepository.test.js index 2422e543..f5ddcbf2 100644 --- a/backend/src/dal/sqliteAuditLogRepository.test.js +++ b/backend/src/dal/sqliteAuditLogRepository.test.js @@ -5,6 +5,11 @@ import { runMigrations } from '../db/migrate.js'; import { createSqliteAuditLogRepository } from './sqliteAuditLogRepository.js'; import { GENESIS_HASH, computeEntryHash } from '../services/auditChain.js'; +/** Simulates an attacker with raw database access by removing the append-only triggers first. */ +function allowTamper(db) { + db.exec('DROP TRIGGER IF EXISTS audit_logs_no_update; DROP TRIGGER IF EXISTS audit_logs_no_delete;'); +} + async function setup() { const db = new Database(':memory:'); await runMigrations(db); @@ -59,6 +64,7 @@ test('verify detects tampered entry_hash', async () => { repo.create({ actor: 'alice', action: 'create', entity: 'campaign', entityId: '42' }); repo.create({ actor: 'alice', action: 'update', entity: 'campaign', entityId: '42' }); + allowTamper(db); db.prepare(`UPDATE audit_logs SET entry_hash = 'deadbeef' WHERE seq = 1`).run(); const result = repo.verify(); @@ -71,6 +77,7 @@ test('verify detects modified actor field', async () => { repo.create({ actor: 'alice', action: 'create', entity: 'campaign', entityId: '1' }); repo.create({ actor: 'bob', action: 'update', entity: 'campaign', entityId: '1' }); + allowTamper(db); db.prepare(`UPDATE audit_logs SET actor = 'mallory' WHERE seq = 1`).run(); const result = repo.verify(); @@ -84,6 +91,7 @@ test('verify detects break in the middle of chain', async () => { repo.create({ actor: 'alice', action: 'update', entity: 'campaign', entityId: '1' }); repo.create({ actor: 'alice', action: 'delete', entity: 'campaign', entityId: '1' }); + allowTamper(db); db.prepare(`UPDATE audit_logs SET action = 'hacked' WHERE seq = 2`).run(); const result = repo.verify(); @@ -124,3 +132,60 @@ test('entry_hash is deterministic for same input', () => { assert.equal(h1, h2); assert.equal(h1.length, 64); }); + +test('audit log records the client IP address and returns it', async () => { + const { repo } = await setup(); + const entry = repo.create({ + actor: 'alice', + action: 'update', + entity: 'campaign', + entityId: '7', + ipAddress: '203.0.113.9', + }); + assert.equal(entry.ipAddress, '203.0.113.9'); + assert.equal(repo.list({ entity: 'campaign' })[0].ipAddress, '203.0.113.9'); +}); + +test('entries without an IP keep a null ipAddress and a valid chain', async () => { + const { repo } = await setup(); + const entry = repo.create({ actor: 'alice', action: 'create', entity: 'campaign', entityId: '1' }); + assert.equal(entry.ipAddress, null); + repo.create({ actor: 'bob', action: 'update', entity: 'campaign', entityId: '1', ipAddress: '198.51.100.4' }); + assert.equal(repo.verify().valid, true); +}); + +test('the IP address is covered by the hash chain', async () => { + const { db, repo } = await setup(); + repo.create({ actor: 'alice', action: 'update', entity: 'campaign', entityId: '1', ipAddress: '203.0.113.9' }); + allowTamper(db); + db.prepare(`UPDATE audit_logs SET ip_address = '10.0.0.1' WHERE seq = 1`).run(); + const result = repo.verify(); + assert.equal(result.valid, false); + assert.equal(result.firstBrokenSeq, 1); +}); + +test('list and count can filter by IP address', async () => { + const { repo } = await setup(); + repo.create({ actor: 'a', action: 'update', entity: 'campaign', entityId: '1', ipAddress: '203.0.113.9' }); + repo.create({ actor: 'b', action: 'update', entity: 'campaign', entityId: '2', ipAddress: '198.51.100.4' }); + repo.create({ actor: 'c', action: 'update', entity: 'campaign', entityId: '3', ipAddress: '203.0.113.9' }); + + assert.equal(repo.count({ ipAddress: '203.0.113.9' }), 2); + assert.deepEqual( + repo.list({ ipAddress: '203.0.113.9' }).map((e) => e.actor).sort(), + ['a', 'c'], + ); +}); + +test('audit_logs is append-only: UPDATE and DELETE are rejected by the database', async () => { + const { db, repo } = await setup(); + repo.create({ actor: 'alice', action: 'create', entity: 'campaign', entityId: '1' }); + + assert.throws( + () => db.prepare(`UPDATE audit_logs SET actor = 'mallory' WHERE seq = 1`).run(), + /append-only/, + ); + assert.throws(() => db.prepare('DELETE FROM audit_logs WHERE seq = 1').run(), /append-only/); + assert.equal(repo.count(), 1); + assert.equal(repo.verify().valid, true); +}); diff --git a/backend/src/db/migrations/046_session_tokens.js b/backend/src/db/migrations/046_session_tokens.js index cf202bc6..49f78af6 100644 --- a/backend/src/db/migrations/046_session_tokens.js +++ b/backend/src/db/migrations/046_session_tokens.js @@ -23,3 +23,10 @@ export const migration = { `); }, }; + +// The migration runner reads `version`/`description`/`up` as named exports. +// Versions 46-48 are already taken by the other migrations that share those +// file numbers, so these use the next free numbers. +export const version = 49; +export const description = migration.description; +export const up = migration.up; diff --git a/backend/src/db/migrations/047_campaign_tenant_isolation.js b/backend/src/db/migrations/047_campaign_tenant_isolation.js index 074cdd33..3c43843b 100644 --- a/backend/src/db/migrations/047_campaign_tenant_isolation.js +++ b/backend/src/db/migrations/047_campaign_tenant_isolation.js @@ -13,3 +13,10 @@ export const migration = { `); }, }; + +// The migration runner reads `version`/`description`/`up` as named exports. +// Versions 46-48 are already taken by the other migrations that share those +// file numbers, so these use the next free numbers. +export const version = 50; +export const description = migration.description; +export const up = migration.up; diff --git a/backend/src/db/migrations/048_campaign_reward_notifications.js b/backend/src/db/migrations/048_campaign_reward_notifications.js index fba3b93c..de573f6e 100644 --- a/backend/src/db/migrations/048_campaign_reward_notifications.js +++ b/backend/src/db/migrations/048_campaign_reward_notifications.js @@ -24,3 +24,10 @@ export const migration = { `); }, }; + +// The migration runner reads `version`/`description`/`up` as named exports. +// Versions 46-48 are already taken by the other migrations that share those +// file numbers, so these use the next free numbers. +export const version = 51; +export const description = migration.description; +export const up = migration.up; diff --git a/backend/src/db/migrations/052_audit_log_ip_address.js b/backend/src/db/migrations/052_audit_log_ip_address.js new file mode 100644 index 00000000..8c083907 --- /dev/null +++ b/backend/src/db/migrations/052_audit_log_ip_address.js @@ -0,0 +1,37 @@ +// @ts-check +export const version = 52; +export const description = + 'Record the client IP on audit_logs and make the table append-only (#1318)'; + +/** + * - `ip_address` stores the address the change came from (`req.ip`), next to the + * existing actor and timestamp. It is part of the hash chain when present. + * - The triggers make `audit_logs` append-only at the database level: any + * UPDATE or DELETE aborts, so history cannot be rewritten through the app or + * an ad-hoc statement without first dropping the trigger deliberately. + */ +export function up(db) { + db.exec(` + ALTER TABLE audit_logs ADD COLUMN ip_address TEXT; + + CREATE TRIGGER IF NOT EXISTS audit_logs_no_update + BEFORE UPDATE ON audit_logs + BEGIN + SELECT RAISE(ABORT, 'audit_logs is append-only'); + END; + + CREATE TRIGGER IF NOT EXISTS audit_logs_no_delete + BEFORE DELETE ON audit_logs + BEGIN + SELECT RAISE(ABORT, 'audit_logs is append-only'); + END; + `); +} + +export function down(db) { + db.exec(` + DROP TRIGGER IF EXISTS audit_logs_no_update; + DROP TRIGGER IF EXISTS audit_logs_no_delete; + ALTER TABLE audit_logs DROP COLUMN ip_address; + `); +} diff --git a/backend/src/index.js b/backend/src/index.js index ff484bba..27abfec1 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1106,6 +1106,7 @@ export async function createApp(options = {}) { entityId, diff, orgId: req.auth?.orgId || null, + ipAddress: req.ip || req.socket?.remoteAddress || null, timestamp: new Date().toISOString(), }); } catch (error) { @@ -3357,6 +3358,8 @@ export async function createApp(options = {}) { prefix, rateLimiter, createIpfsPinRoutes({ getService: () => ipfsPinService, campaignRepository, guard }), + ); + // #1258 — fraud flag review (master key) app.use( `${prefix}/admin/fraud`, diff --git a/backend/src/lib/logger.js b/backend/src/lib/logger.js new file mode 100644 index 00000000..9df606ca --- /dev/null +++ b/backend/src/lib/logger.js @@ -0,0 +1 @@ +export { log as logger } from '../middleware/logger.js'; diff --git a/backend/src/middleware/cacheMiddleware.test.js b/backend/src/middleware/cacheMiddleware.test.js new file mode 100644 index 00000000..cc0d8bb6 --- /dev/null +++ b/backend/src/middleware/cacheMiddleware.test.js @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCacheMiddleware } from './cacheMiddleware.js'; + +function memoryCache(initial = {}) { + const store = new Map(Object.entries(initial)); + return { + store, + sets: [], + async get(key) { + return store.has(key) ? store.get(key) : null; + }, + async set(key, value, ttl) { + this.sets.push({ key, ttl }); + store.set(key, value); + }, + }; +} + +function makeRes(statusCode = 200) { + return { + statusCode, + headers: {}, + sent: null, + setHeader(name, value) { + this.headers[name] = value; + }, + json(body) { + this.sent = body; + return this; + }, + }; +} + +test('non-GET requests bypass the cache entirely', async () => { + const cache = memoryCache(); + let nextCalled = false; + const res = makeRes(); + await createCacheMiddleware({ cacheService: cache })( + { method: 'POST', originalUrl: '/x' }, + res, + () => { + nextCalled = true; + }, + ); + assert.equal(nextCalled, true); + assert.equal(res.headers['X-Cache'], undefined); +}); + +test('a cache hit short-circuits with X-Cache: HIT', async () => { + const cache = memoryCache({ 'cache:http:/api/v1/campaigns': { data: [1] } }); + const res = makeRes(); + let nextCalled = false; + await createCacheMiddleware({ cacheService: cache })( + { method: 'GET', originalUrl: '/api/v1/campaigns' }, + res, + () => { + nextCalled = true; + }, + ); + assert.equal(nextCalled, false); + assert.equal(res.headers['X-Cache'], 'HIT'); + assert.deepEqual(res.sent, { data: [1] }); +}); + +test('a miss continues and caches a successful JSON response with the configured TTL and prefix', async () => { + const cache = memoryCache(); + const res = makeRes(200); + await createCacheMiddleware({ cacheService: cache, ttlSec: 15, keyPrefix: 'k' })( + { method: 'GET', originalUrl: '/a?b=1' }, + res, + () => {}, + ); + assert.equal(res.headers['X-Cache'], 'MISS'); + + res.json({ ok: true }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(cache.sets, [{ key: 'k:/a?b=1', ttl: 15 }]); + assert.deepEqual(res.sent, { ok: true }); +}); + +test('error responses are not cached', async () => { + const cache = memoryCache(); + const res = makeRes(500); + await createCacheMiddleware({ cacheService: cache })( + { method: 'GET', url: '/boom' }, + res, + () => {}, + ); + res.json({ error: 'x' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(cache.sets.length, 0); +}); + +test('cache backend failures never break the request', async () => { + const broken = { + get: async () => { + throw new Error('redis down'); + }, + set: async () => { + throw new Error('redis down'); + }, + }; + const res = makeRes(200); + let nextCalled = false; + await createCacheMiddleware({ cacheService: broken })( + { method: 'GET', originalUrl: '/a' }, + res, + () => { + nextCalled = true; + }, + ); + assert.equal(nextCalled, true); + assert.equal(res.headers['X-Cache'], 'MISS'); + res.json({ ok: 1 }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(res.sent, { ok: 1 }); +}); diff --git a/backend/src/middleware/errorHandler.test.js b/backend/src/middleware/errorHandler.test.js new file mode 100644 index 00000000..f278f190 --- /dev/null +++ b/backend/src/middleware/errorHandler.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import errorHandler from './errorHandler.js'; + +function makeRes({ headersSent = false } = {}) { + return { + headersSent, + locals: { requestId: 'req-1' }, + statusCode: null, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + }; +} + +test('maps a pool saturation error to a typed 503', () => { + const res = makeRes(); + errorHandler(Object.assign(new Error('no slot'), { code: 'POOL_SATURATED' }), {}, res, () => {}); + assert.equal(res.statusCode, 503); + assert.deepEqual(res.body, { + error: 'Service temporarily unavailable', + code: 'POOL_SATURATED', + }); +}); + +test('does not write a second response once headers were sent', () => { + const res = makeRes({ headersSent: true }); + errorHandler(Object.assign(new Error('x'), { code: 'POOL_SATURATED' }), {}, res, () => {}); + assert.equal(res.statusCode, null); + assert.equal(res.body, null); +}); + +test('uses the error statusCode and a JSON body with a stable code', () => { + const res = makeRes(); + errorHandler(Object.assign(new Error('bad input'), { statusCode: 422 }), {}, res, () => {}); + assert.equal(res.statusCode, 422); + assert.equal(res.body.code, 'INTERNAL_SERVER_ERROR'); + assert.equal(res.body.error, 'bad input'); +}); + +test('defaults to 500 for errors without a status code', () => { + const res = makeRes(); + errorHandler(new Error('boom'), {}, res, () => {}); + assert.equal(res.statusCode, 500); +}); + +test('includes the stack outside production only', () => { + const res = makeRes(); + errorHandler(new Error('boom'), {}, res, () => {}); + assert.equal(typeof res.body.stack, 'string'); +}); + +test('handles non-Error values without throwing', () => { + for (const thrown of ['a string', { statusCode: 400, secret: 'x' }, null, undefined, 42]) { + const res = makeRes(); + errorHandler(thrown, {}, res, () => {}); + assert.ok(res.statusCode >= 400, `status for ${String(thrown)}`); + assert.equal(res.body.code, 'INTERNAL_SERVER_ERROR'); + assert.equal('stack' in res.body, false); + } + const res = makeRes(); + errorHandler({ statusCode: 400 }, {}, res, () => {}); + assert.equal(res.statusCode, 400); +}); diff --git a/backend/src/services/auditChain.js b/backend/src/services/auditChain.js index 8ff7361a..98b9c964 100644 --- a/backend/src/services/auditChain.js +++ b/backend/src/services/auditChain.js @@ -6,7 +6,7 @@ export const GENESIS_HASH = '000000000000000000000000000000000000000000000000000 * Deterministic, sorted JSON serialisation of an audit entry. * Only stable fields are included so the hash is reproducible. */ -export function canonicalise({ actor, action, entity, entityId, diff, orgId, createdAt }) { +export function canonicalise({ actor, action, entity, entityId, diff, orgId, createdAt, ipAddress }) { return JSON.stringify({ actor: actor ?? null, action: action ?? null, @@ -15,6 +15,9 @@ export function canonicalise({ actor, action, entity, entityId, diff, orgId, cre diff: diff ?? null, orgId: orgId ?? null, createdAt: createdAt ?? null, + // Only present when recorded, so hashes of entries written before the IP + // column existed (#1318) stay valid. + ...(ipAddress ? { ipAddress } : {}), }); } diff --git a/backend/src/services/auditChain.test.js b/backend/src/services/auditChain.test.js new file mode 100644 index 00000000..f89c726c --- /dev/null +++ b/backend/src/services/auditChain.test.js @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { canonicalise, computeEntryHash, GENESIS_HASH } from './auditChain.js'; + +const entry = { + actor: 'apiKey:abcd...wxyz', + action: 'update', + entity: 'campaign', + entityId: '1', + diff: { before: { name: 'a' }, after: { name: 'b' } }, + orgId: null, + createdAt: '2026-10-18T00:00:00.000Z', +}; + +test('canonicalise is stable and fills missing fields with null', () => { + assert.equal(canonicalise({}), canonicalise({ actor: null, action: null })); + assert.deepEqual(JSON.parse(canonicalise(entry)).diff, entry.diff); +}); + +test('an entry without an IP hashes exactly as it did before the IP column existed', () => { + assert.equal('ipAddress' in JSON.parse(canonicalise(entry)), false); + assert.equal(canonicalise({ ...entry, ipAddress: null }), canonicalise(entry)); + assert.equal(canonicalise({ ...entry, ipAddress: '' }), canonicalise(entry)); +}); + +test('an IP address changes the canonical form and the hash', () => { + const withIp = { ...entry, ipAddress: '203.0.113.9' }; + assert.equal(JSON.parse(canonicalise(withIp)).ipAddress, '203.0.113.9'); + assert.notEqual(computeEntryHash(GENESIS_HASH, withIp), computeEntryHash(GENESIS_HASH, entry)); +}); + +test('the hash depends on the previous hash', () => { + const a = computeEntryHash(GENESIS_HASH, entry); + const b = computeEntryHash(a, entry); + assert.notEqual(a, b); + assert.match(a, /^[0-9a-f]{64}$/); +}); diff --git a/backend/src/services/auditLogService.js b/backend/src/services/auditLogService.js index 6aa4955e..e72f3ace 100644 --- a/backend/src/services/auditLogService.js +++ b/backend/src/services/auditLogService.js @@ -13,6 +13,7 @@ export function createAuditLogService({ auditLogRepository, orgMemberRepository * entityId?: string | null, * diff?: object | null, * orgId?: string | null, + * ipAddress?: string | null, * timestamp?: string | null * }} params */ @@ -111,7 +112,7 @@ export function createAuditLogService({ auditLogRepository, orgMemberRepository }); // CSV headers - const headers = ['ID', 'Actor', 'Action', 'Entity', 'Entity ID', 'Timestamp', 'Changes']; + const headers = ['ID', 'Actor', 'Action', 'Entity', 'Entity ID', 'IP Address', 'Timestamp', 'Changes']; // Convert logs to CSV rows const rows = logs.map((log) => [ @@ -120,6 +121,7 @@ export function createAuditLogService({ auditLogRepository, orgMemberRepository log.action, log.entity, log.entityId || '', + log.ipAddress || '', log.timestamp, log.diff ? JSON.stringify(log.diff) : '', ]); diff --git a/backend/src/services/auditLogService.test.js b/backend/src/services/auditLogService.test.js new file mode 100644 index 00000000..0110f162 --- /dev/null +++ b/backend/src/services/auditLogService.test.js @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAuditLogService } from './auditLogService.js'; + +/** In-memory stand-in for the audit log repository. */ +function fakeRepository(rows = []) { + const matches = (row, f) => + (!f.orgId || row.orgId === f.orgId) && + (!f.actor || row.actor === f.actor) && + (!f.action || row.action === f.action) && + (!f.entity || row.entity === f.entity) && + (!f.startDate || row.timestamp >= f.startDate); + return { + created: [], + create(params) { + this.created.push(params); + return { id: String(this.created.length), ...params }; + }, + list(filters) { + return rows + .filter((r) => matches(r, filters)) + .slice(filters.offset ?? 0, (filters.offset ?? 0) + (filters.limit ?? 100)); + }, + count(filters) { + return rows.filter((r) => matches(r, filters)).length; + }, + }; +} + +const row = (over = {}) => ({ + id: '1', + actor: 'apiKey:abcd...wxyz', + action: 'update', + entity: 'campaign', + entityId: '7', + diff: { before: { name: 'a' }, after: { name: 'b', active: false } }, + orgId: 'org-1', + ipAddress: '203.0.113.9', + timestamp: '2026-10-18T10:00:00.000Z', + ...over, +}); + +test('logAction passes the entry, including the IP address, to the repository', () => { + const repo = fakeRepository(); + const service = createAuditLogService({ auditLogRepository: repo, orgMemberRepository: {} }); + service.logAction({ + actor: 'alice', + action: 'create', + entity: 'campaign', + ipAddress: '198.51.100.4', + }); + assert.equal(repo.created[0].ipAddress, '198.51.100.4'); +}); + +test('getOrgAuditLogs paginates, caps the page size at 100 and reports totals', () => { + const rows = Array.from({ length: 120 }, (_, i) => row({ id: String(i), actor: `a${i}` })); + const service = createAuditLogService({ + auditLogRepository: fakeRepository(rows), + orgMemberRepository: {}, + }); + + const first = service.getOrgAuditLogs('org-1', { page: 1, pageSize: 500 }); + assert.equal(first.data.length, 100); + assert.deepEqual(first.pagination, { + page: 1, + pageSize: 100, + totalCount: 120, + totalPages: 2, + hasNextPage: true, + hasPreviousPage: false, + }); + + const second = service.getOrgAuditLogs('org-1', { page: 2, pageSize: 100 }); + assert.equal(second.data.length, 20); + assert.equal(second.pagination.hasNextPage, false); + assert.equal(second.pagination.hasPreviousPage, true); +}); + +test('the activity feed describes each action in plain language and caps the limit at 50', () => { + const rows = [ + row({ action: 'create', entityId: '1' }), + row({ action: 'update' }), + row({ action: 'update', diff: null, entityId: null }), + row({ action: 'delete' }), + row({ action: 'approve' }), + row({ action: 'migrate' }), + ]; + const service = createAuditLogService({ + auditLogRepository: fakeRepository(rows), + orgMemberRepository: {}, + }); + const feed = service.getActivityFeed('org-1', { limit: 999 }); + + assert.equal(feed.length, 6); + assert.match(feed[0].description, /created campaign "1"/); + assert.match(feed[1].description, /updated campaign "7" \(changed: name, active\)/); + assert.match(feed[2].description, /updated campaign$/); + assert.match(feed[3].description, /deleted campaign "7"/); + assert.match(feed[4].description, /approved campaign "7"/); + assert.match(feed[5].description, /performed "migrate" on campaign "7"/); +}); + +test('CSV export includes the IP address column and escapes quotes', () => { + const rows = [ + row({ actor: 'say "hi"', ipAddress: '203.0.113.9' }), + row({ id: '2', ipAddress: null }), + ]; + const service = createAuditLogService({ + auditLogRepository: fakeRepository(rows), + orgMemberRepository: {}, + }); + const { content, filename, mimeType } = service.exportToCsv('org-1'); + + const [header, first, second] = content.split('\n'); + assert.equal( + header, + '"ID","Actor","Action","Entity","Entity ID","IP Address","Timestamp","Changes"', + ); + assert.match(first, /"say ""hi"""/); + assert.match(first, /"203\.0\.113\.9"/); + assert.match(second, /"7",""/, 'a missing IP exports as an empty cell'); + assert.match(filename, /^audit-log-org-1-\d{4}-\d{2}-\d{2}\.csv$/); + assert.equal(mimeType, 'text/csv'); +}); + +test('JSON export carries the records, filters and a count', () => { + const rows = [row(), row({ id: '2' })]; + const service = createAuditLogService({ + auditLogRepository: fakeRepository(rows), + orgMemberRepository: {}, + }); + const { content, mimeType } = service.exportToJson('org-1', { + action: 'update', + page: 3, + pageSize: 5, + }); + const parsed = JSON.parse(content); + + assert.equal(parsed.orgId, 'org-1'); + assert.equal(parsed.totalRecords, 2); + assert.deepEqual(parsed.filters, { action: 'update' }); + assert.equal(parsed.auditLogs[0].ipAddress, '203.0.113.9'); + assert.equal(mimeType, 'application/json'); +}); + +test('stats break actions, entities and actors down and rank the top actors', () => { + const rows = [ + row({ actor: 'a', action: 'create' }), + row({ actor: 'a', action: 'update' }), + row({ actor: 'b', action: 'update', entity: 'webhook' }), + ]; + const service = createAuditLogService({ + auditLogRepository: fakeRepository(rows), + orgMemberRepository: {}, + }); + const stats = service.getOrgAuditStats('org-1'); + + assert.equal(stats.totalActions, 3); + assert.deepEqual(stats.actionBreakdown, { create: 1, update: 2 }); + assert.deepEqual(stats.entityBreakdown, { campaign: 2, webhook: 1 }); + assert.deepEqual(stats.topActors, [ + { actor: 'a', count: 2 }, + { actor: 'b', count: 1 }, + ]); +}); diff --git a/contracts/rewards/formal/01_credit_preserves_conservation.smt2 b/contracts/rewards/formal/01_credit_preserves_conservation.smt2 new file mode 100644 index 00000000..65720c1a --- /dev/null +++ b/contracts/rewards/formal/01_credit_preserves_conservation.smt2 @@ -0,0 +1,22 @@ +; expect: unsat +; Property: credit(user, a) preserves S = B (and the supporting facts) whenever it succeeds. +; Source: RewardsContract::credit (new_balance = x + a, supply = S + a, both checked_add). +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const S Int) (declare-const B Int) (declare-const x Int) (declare-const a Int) + +; pre-state satisfies the invariant +(assert (and (= S B) (<= 0 x) (<= x B) (<= B U))) +; inputs: a > 0 (ZeroAmount otherwise); both checked_add calls must succeed (else Error::Overflow, no write) +(assert (and (> a 0) (<= a U) (<= (+ x a) U) (<= (+ S a) U))) + +(define-fun S2 () Int (+ S a)) +(define-fun B2 () Int (+ B a)) +(define-fun x2 () Int (+ x a)) + +; search for a post-state that violates the invariant +(assert (not (and (= S2 B2) (<= 0 x2) (<= x2 B2) (<= B2 U)))) +(check-sat) diff --git a/contracts/rewards/formal/02_claim_preserves_conservation.smt2 b/contracts/rewards/formal/02_claim_preserves_conservation.smt2 new file mode 100644 index 00000000..b97262e4 --- /dev/null +++ b/contracts/rewards/formal/02_claim_preserves_conservation.smt2 @@ -0,0 +1,24 @@ +; expect: unsat +; Property: claim(user, a) preserves S = B, and the saturating_sub on TOTAL_SUPPLY never clamps. +; Source: RewardsContract::claim (new_balance = x.checked_sub(a); supply = S.saturating_sub(a)). +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const S Int) (declare-const B Int) (declare-const x Int) (declare-const a Int) + +(assert (and (= S B) (<= 0 x) (<= x B) (<= B U))) +; a > 0 and checked_sub succeeds (a <= x); otherwise Error::InsufficientBalance and nothing is written +(assert (and (> a 0) (<= a x))) + +; saturating_sub(S, a) = if S >= a then S - a else 0 +(define-fun S2 () Int (ite (>= S a) (- S a) 0)) +(define-fun B2 () Int (- B a)) +(define-fun x2 () Int (- x a)) + +; violation: invariant broken, or saturation actually clamped +(assert (or (not (= S2 B2)) + (< S a) + (not (and (<= 0 x2) (<= x2 B2) (<= B2 U))))) +(check-sat) diff --git a/contracts/rewards/formal/03_admin_transfer_preserves_conservation.smt2 b/contracts/rewards/formal/03_admin_transfer_preserves_conservation.smt2 new file mode 100644 index 00000000..d0589fc5 --- /dev/null +++ b/contracts/rewards/formal/03_admin_transfer_preserves_conservation.smt2 @@ -0,0 +1,23 @@ +; expect: unsat +; Property: admin_transfer(from, to, a) moves points without creating or destroying any (S and B unchanged). +; Source: RewardsContract::admin_transfer (from != to is enforced: SelfTransfer). +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const S Int) (declare-const B Int) +(declare-const xf Int) (declare-const xt Int) (declare-const a Int) + +; two distinct users: their balances are disjoint parts of B +(assert (and (= S B) (<= 0 xf) (<= 0 xt) (<= (+ xf xt) B) (<= B U))) +; a > 0, checked_sub on the sender succeeds, checked_add on the receiver succeeds +(assert (and (> a 0) (<= a xf) (<= (+ xt a) U))) + +(define-fun xf2 () Int (- xf a)) +(define-fun xt2 () Int (+ xt a)) +(define-fun B2 () Int (+ (- B xf) xf2 (- xt) xt2)) ; B recomputed from the changed balances +(define-fun S2 () Int S) + +(assert (not (and (= S2 B2) (<= 0 xf2) (<= 0 xt2) (<= (+ xf2 xt2) B2) (<= B2 U)))) +(check-sat) diff --git a/contracts/rewards/formal/04_batch_credit_preserves_conservation.smt2 b/contracts/rewards/formal/04_batch_credit_preserves_conservation.smt2 new file mode 100644 index 00000000..5c81f85d --- /dev/null +++ b/contracts/rewards/formal/04_batch_credit_preserves_conservation.smt2 @@ -0,0 +1,24 @@ +; expect: unsat +; Property: batch_credit over three recipients preserves S = B (the fixed implementation). +; Every recipient amount is credited exactly once (duplicates accumulate), TOTAL_SUPPLY grows by the +; batch total, and an overflow of either the batch total or the supply aborts before any write. +; Source: RewardsContract::batch_credit. +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const S Int) (declare-const B Int) +(declare-const a1 Int) (declare-const a2 Int) (declare-const a3 Int) + +(assert (and (= S B) (<= B U))) +(assert (and (> a1 0) (> a2 0) (> a3 0))) +(define-fun total () Int (+ a1 a2 a3)) +; batch_total.checked_add and supply.checked_add both succeed (per-user checked_add also succeeds) +(assert (and (<= total U) (<= (+ S total) U))) + +(define-fun S2 () Int (+ S total)) +(define-fun B2 () Int (+ B a1 a2 a3)) + +(assert (not (and (= S2 B2) (<= B2 U)))) +(check-sat) diff --git a/contracts/rewards/formal/05_batch_credit_without_supply_update_violates_conservation.smt2 b/contracts/rewards/formal/05_batch_credit_without_supply_update_violates_conservation.smt2 new file mode 100644 index 00000000..8202c08c --- /dev/null +++ b/contracts/rewards/formal/05_batch_credit_without_supply_update_violates_conservation.smt2 @@ -0,0 +1,20 @@ +; expect: sat +; NEGATIVE CONTROL: the batch_credit that wrote balances but not TOTAL_SUPPLY (before #1317). +; A satisfiable result is the counterexample: after one batch S != B. It shows the conservation +; property is not vacuous and is what exposed the missing supply update. +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const S Int) (declare-const B Int) (declare-const a1 Int) (declare-const a2 Int) + +(assert (and (= S B) (<= B U))) +(assert (and (> a1 0) (> a2 0) (<= (+ B a1 a2) U))) + +(define-fun S2 () Int S) ; old code: supply untouched +(define-fun B2 () Int (+ B a1 a2)) + +(assert (not (= S2 B2))) +(check-sat) +(get-value (S B a1 a2)) diff --git a/contracts/rewards/formal/06_claim_cannot_inflate.smt2 b/contracts/rewards/formal/06_claim_cannot_inflate.smt2 new file mode 100644 index 00000000..532796d6 --- /dev/null +++ b/contracts/rewards/formal/06_claim_cannot_inflate.smt2 @@ -0,0 +1,21 @@ +; expect: unsat +; Property: a successful claim never increases the user's balance, the supply or the total points in +; circulation, and always increases CLAIMED by exactly the claimed amount when it does not saturate. +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const S Int) (declare-const B Int) (declare-const x Int) (declare-const a Int) (declare-const C Int) + +(assert (and (= S B) (<= 0 x) (<= x B) (<= B U) (<= 0 C) (<= C U))) +(assert (and (> a 0) (<= a x))) + +(define-fun x2 () Int (- x a)) +(define-fun S2 () Int (- S a)) +(define-fun C2 () Int (ite (<= (+ C a) U) (+ C a) U)) ; saturating_add + +; (balance and supply never increase) and (when CLAIMED does not saturate, S + C is unchanged) +(assert (not (and (<= x2 x) (<= S2 S) + (=> (<= (+ C a) U) (= (+ S2 C2) (+ S C)))))) +(check-sat) diff --git a/contracts/rewards/formal/07_total_claimed_saturation.smt2 b/contracts/rewards/formal/07_total_claimed_saturation.smt2 new file mode 100644 index 00000000..85cd8ffe --- /dev/null +++ b/contracts/rewards/formal/07_total_claimed_saturation.smt2 @@ -0,0 +1,14 @@ +; expect: sat +; INFORMATIONAL: CLAIMED uses saturating_add, so once it reaches u64::MAX it stops counting. The model +; finds such a state; it needs more than 1.8e19 points claimed over the contract's lifetime and is not +; reachable in practice. Recorded so the limit is explicit rather than assumed. +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const C Int) (declare-const a Int) +(assert (and (<= 0 C) (<= C U) (> a 0) (<= a U))) +(assert (> (+ C a) U)) +(check-sat) +(get-value (C a)) diff --git a/contracts/rewards/formal/08_initial_state_satisfies_invariant.smt2 b/contracts/rewards/formal/08_initial_state_satisfies_invariant.smt2 new file mode 100644 index 00000000..a869683c --- /dev/null +++ b/contracts/rewards/formal/08_initial_state_satisfies_invariant.smt2 @@ -0,0 +1,13 @@ +; expect: unsat +; Base case: after initialize() the supply, the sum of balances and CLAIMED are all 0, so the +; invariant holds before the first operation (induction base for files 01-04). +; Source: RewardsContract::initialize sets CLAIMED = 0; TOTAL_SUPPLY and balances default to 0. +; Rewards contract accounting model (issue #1317). Amounts are u64: 0 <= v <= 18446744073709551615. +; State: S = TOTAL_SUPPLY, B = sum of every user balance, C = CLAIMED, x = the balance of the user an +; operation touches. Main invariant (#1021): S = B. Supporting facts: 0 <= x <= B, B <= U. +(set-logic QF_LIA) +(define-fun U () Int 18446744073709551615) +(declare-const S Int) (declare-const B Int) (declare-const C Int) +(assert (and (= S 0) (= B 0) (= C 0))) +(assert (not (and (= S B) (<= B U)))) +(check-sat) diff --git a/contracts/rewards/src/lib.rs b/contracts/rewards/src/lib.rs index b412c8b7..9b277d3f 100644 --- a/contracts/rewards/src/lib.rs +++ b/contracts/rewards/src/lib.rs @@ -1331,23 +1331,52 @@ impl RewardsContract { ensure_credit_not_paused(&env)?; check_and_increment_rate(&env, &from, recipients.len())?; - let mut staged = Vec::new(&env); + let mut staged: Vec<(Address, u64)> = Vec::new(&env); + let mut batch_total: u64 = 0; for (user, amount) in recipients.iter() { if amount == 0 { return Err(Error::ZeroAmount); } - let key = (BALANCE, user.clone()); - let current: u64 = env.storage().instance().get(&key).unwrap_or(0); - let new_balance = current.checked_add(amount).ok_or(Error::Overflow)?; - staged.push_back((user, new_balance)); + batch_total = batch_total.checked_add(amount).ok_or(Error::Overflow)?; + + // A recipient listed more than once accumulates onto its staged + // balance, so every amount is credited and the supply update below + // matches the sum of the balance changes exactly. + let mut staged_index: Option = None; + for (i, (staged_user, _)) in staged.iter().enumerate() { + if staged_user == user { + staged_index = Some(i as u32); + break; + } + } + match staged_index { + Some(i) => { + let (staged_user, staged_balance) = staged.get(i).unwrap(); + let new_balance = staged_balance.checked_add(amount).ok_or(Error::Overflow)?; + staged.set(i, (staged_user, new_balance)); + } + None => { + let key = (BALANCE, user.clone()); + let current: u64 = env.storage().instance().get(&key).unwrap_or(0); + let new_balance = current.checked_add(amount).ok_or(Error::Overflow)?; + staged.push_back((user, new_balance)); + } + } } + // Keep TOTAL_SUPPLY == sum of balances (#1021, #1317): `credit` already + // does this; the batch path must too. Computed before any write so an + // overflow leaves state untouched. + let supply: u64 = env.storage().instance().get(&TOTAL_SUPPLY).unwrap_or(0); + let new_supply = supply.checked_add(batch_total).ok_or(Error::Overflow)?; + for (user, new_balance) in staged.iter() { env.storage() .instance() .set(&(BALANCE, user.clone()), &new_balance); } + env.storage().instance().set(&TOTAL_SUPPLY, &new_supply); for (user, amount) in recipients.iter() { env.events().publish((CREDIT_EVENT, user), amount); diff --git a/contracts/rewards/src/test.rs b/contracts/rewards/src/test.rs index fddf980e..d0d4ad33 100644 --- a/contracts/rewards/src/test.rs +++ b/contracts/rewards/src/test.rs @@ -241,6 +241,80 @@ fn test_batch_credit() { assert_eq!(client.balance(&user_b), 75); } +#[test] +fn test_batch_credit_updates_total_supply() { + let (env, client, _admin) = setup_rewards(); + let creditor = Address::generate(&env); + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + env.mock_all_auths(); + + client.credit(&creditor, &user_a, &100); + let recipients = vec![&env, (user_a.clone(), 50u64), (user_b.clone(), 75u64)]; + client.batch_credit(&creditor, &recipients); + + assert_eq!(client.balance(&user_a), 150); + assert_eq!(client.balance(&user_b), 75); + // TOTAL_SUPPLY must equal the sum of balances (#1021, formal model in contracts/rewards/formal). + assert_eq!(client.total_supply(), 225); +} + +#[test] +fn test_batch_credit_duplicate_recipient_is_credited_in_full() { + let (env, client, _admin) = setup_rewards(); + let creditor = Address::generate(&env); + let user = Address::generate(&env); + env.mock_all_auths(); + + let recipients = vec![&env, (user.clone(), 30u64), (user.clone(), 20u64)]; + client.batch_credit(&creditor, &recipients); + + assert_eq!(client.balance(&user), 50); + assert_eq!(client.total_supply(), 50); +} + +#[test] +fn test_batch_credit_is_atomic_when_total_supply_would_overflow() { + let (env, client, _admin) = setup_rewards(); + let creditor = Address::generate(&env); + let funded = Address::generate(&env); + let fresh = Address::generate(&env); + env.mock_all_auths(); + + client.credit(&creditor, &funded, &u64::MAX); + + // `fresh`'s own balance would not overflow, but the supply would. + let recipients = vec![&env, (fresh.clone(), 1u64)]; + let result = client.try_batch_credit(&creditor, &recipients); + + assert!(result.is_err()); + assert_eq!(client.balance(&fresh), 0); + assert_eq!(client.total_supply(), u64::MAX); +} + +#[test] +fn test_total_supply_equals_sum_of_balances_across_mixed_operations() { + let (env, client, admin) = setup_rewards(); + let creditor = Address::generate(&env); + let a = Address::generate(&env); + let b = Address::generate(&env); + let c = Address::generate(&env); + env.mock_all_auths(); + + client.credit(&creditor, &a, &1_000); + client.batch_credit( + &creditor, + &vec![&env, (b.clone(), 400u64), (c.clone(), 250u64), (a.clone(), 100u64)], + ); + client.claim(&a, &300); + client.admin_transfer(&admin, &b, &c, &150); + client.claim(&c, &50); + + let sum = client.balance(&a) + client.balance(&b) + client.balance(&c); + assert_eq!(client.total_supply(), sum); + assert_eq!(client.total_supply(), 1_000 + 400 + 250 + 100 - 300 - 50); +} + #[test] fn test_credit_overflow_errors() { let env = Env::default(); diff --git a/docs/FORMAL_VERIFICATION.md b/docs/FORMAL_VERIFICATION.md index ee2a3297..1c4cf343 100644 --- a/docs/FORMAL_VERIFICATION.md +++ b/docs/FORMAL_VERIFICATION.md @@ -9,6 +9,7 @@ This document describes the formally verified invariants for the Trivela Soroban | Contract | Tool | Invariants | Status | | -------- | -------- | ------------------------------ | ----------- | | Rewards | Kani | 4 arithmetic safety properties | ✅ Verified | +| Rewards | Z3 (SMT) | Supply/balance conservation (#1317) | ✅ Proved for credit, batch_credit, claim, admin_transfer | | Rewards | proptest | 7 behavioral invariants | ✅ Passing | | Campaign | proptest | 7 behavioral invariants | ✅ Passing | @@ -58,6 +59,82 @@ input ranges (`base_amount <= MAX/2`, `multiplier_bps <= 100,000`). **Property**: Balance additions using `checked_add` prevent silent wrapping and correctly propagate `Error::Overflow`. +## Reward Accounting Invariant (Z3 / SMT) — #1317 + +Kani above proves the *arithmetic* is safe. This section proves the *accounting*: the points the +contract has issued and not yet claimed always equal the points users hold. + +> **Invariant (I1, #1021):** `TOTAL_SUPPLY = Σ balance(user)` over all users. + +Supporting facts used in the proofs: every balance is at most the sum (`x ≤ B`), the sum fits in a +`u64` (`B ≤ 2^64 − 1`), and `CLAIMED` only grows. + +### Method + +Each state-changing operation is modelled as an exact transformation of three quantities — +`S` (`TOTAL_SUPPLY`), `B` (sum of balances) and the balance `x` of the user it touches — over +unbounded integers constrained to the `u64` range, including the checked arithmetic that makes an +operation fail (and therefore write nothing) on overflow. Z3 is asked for a counter-example: a +pre-state satisfying the invariant, in which the operation succeeds, but the post-state violates it. +`unsat` means no such state exists, so the operation preserves the invariant (inductive step). The +base case is `initialize`. Together these prove the invariant holds in every reachable state of the +modelled operations. + +Models live in `contracts/rewards/formal/` (SMT-LIB 2, one property per file; the first line declares +the expected result) and are checked by `scripts/verify-reward-invariants.sh`. + +| Model | Operation | Expected | Meaning | +| ----- | --------- | -------- | ------- | +| `01_credit_preserves_conservation` | `credit` | unsat | Proved: `S = B` is preserved. | +| `02_claim_preserves_conservation` | `claim` | unsat | Proved, and the `saturating_sub` on supply **never clamps**. | +| `03_admin_transfer_preserves_conservation` | `admin_transfer` | unsat | Proved: transfers neither create nor destroy points. | +| `04_batch_credit_preserves_conservation` | `batch_credit` (fixed) | unsat | Proved for the version that also updates the supply. | +| `05_batch_credit_without_supply_update_violates_conservation` | `batch_credit` (old) | **sat** | Negative control: the old code breaks `S = B`; Z3 returns the counter-example. | +| `06_claim_cannot_inflate` | `claim` | unsat | A claim never raises a balance or the supply. | +| `07_total_claimed_saturation` | `CLAIMED` | **sat** | Informational: `saturating_add` can pin `total_claimed` at `u64::MAX` (unreachable in practice). | +| `08_initial_state_satisfies_invariant` | `initialize` | unsat | Induction base. | + +### What it found + +The first run of the model showed that `batch_credit` credited every recipient's balance but never +touched `TOTAL_SUPPLY` (model 05 is satisfiable — for example one batch of two 1-point credits on an +empty contract leaves `S = 0`, `B = 2`). The consequences were a wrong `total_supply()`, and a later +`claim` silently clamping the supply at zero (`saturating_sub`) instead of reducing it. `batch_credit` +now adds the batch total to `TOTAL_SUPPLY` (checked, and before any write so an overflow stays +atomic). While fixing it, a second defect showed up: a recipient listed twice in one batch had only +the *last* amount credited (each entry was staged from the stored balance), which would have made the +supply and the balances disagree again. Duplicates now accumulate. Regression tests: +`test_batch_credit_updates_total_supply`, `test_batch_credit_duplicate_recipient_is_credited_in_full`, +`test_batch_credit_is_atomic_when_total_supply_would_overflow` and +`test_total_supply_equals_sum_of_balances_across_mixed_operations` in `contracts/rewards/src/test.rs`. + +### Scope and limits + +- **Modelled:** `credit` (and therefore `credit_for_campaign` / `credit_with_distribution`, which + call it), `batch_credit`, `claim`, `admin_transfer`. +- **Not modelled:** the other balance-changing paths (vesting, staking, redemption, referral and + airdrop payouts, burn proposals). They should be added as further models; each one needs the same + `S`/`B` bookkeeping argument. +- The models are hand-written abstractions of the Rust code, not extracted from it, so they must be + updated when those functions change. The Rust regression tests and the proptest/Kani checks cover + the implementation itself. +- The proofs assume operations run one at a time and fail atomically, which Soroban guarantees per + invocation. +- The K Framework was not used; Z3 was enough for these linear-arithmetic properties. + +### Running + +```bash +brew install z3 # or: sudo apt-get install z3 +./scripts/verify-reward-invariants.sh # all models +./scripts/verify-reward-invariants.sh 04_*.smt2 # one model +z3 contracts/rewards/formal/05_batch_credit_without_supply_update_violates_conservation.smt2 # see the counter-example +``` + +The script fails if any model's result differs from its `; expect:` line, so a proof that stops +holding **and** a negative control that stops finding its counter-example are both caught. CI runs it +in the `contracts` job of `.github/workflows/contracts-ci.yml`. + ## proptest Behavioral Invariants ### Rewards Contract @@ -121,6 +198,7 @@ The `contracts-ci.yml` workflow includes: 2. **Kani verification**: Runs on PRs (optional, continue-on-error) 3. **Negative tests**: Validates harness correctness 4. **Coverage gate**: ≥60% line coverage for Rust contracts +5. **Z3 reward invariants**: `scripts/verify-reward-invariants.sh` (#1317) ## References diff --git a/frontend/package.json b/frontend/package.json index 0807c781..156755d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,8 @@ "test:visual:report": "playwright show-report playwright-report-visual", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "security:xss": "npm run lint && grep -r 'dangerouslySetInnerHTML' src/ || echo 'No dangerouslySetInnerHTML found - XSS audit passed'" + "security:xss": "npm run lint && grep -r 'dangerouslySetInnerHTML' src/ || echo 'No dangerouslySetInnerHTML found - XSS audit passed'", + "test:e2e:flow": "playwright test --config=playwright.flow.config.js" }, "dependencies": { "@stellar/stellar-sdk": "^14.0.0", diff --git a/frontend/playwright.flow.config.js b/frontend/playwright.flow.config.js new file mode 100644 index 00000000..bad380ab --- /dev/null +++ b/frontend/playwright.flow.config.js @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; +import { Keypair, StrKey } from '@stellar/stellar-sdk'; + +// Hermetic browser flow (#1319): a dedicated production build with an admin wallet and a rewards +// contract configured, served by `vite preview`. All network traffic (backend API, Soroban RPC) +// and the wallet extension are mocked inside the tests, so this needs no testnet, no backend and +// no real wallet. It is separate from playwright.config.js, whose build has no admin configured. + +// Deterministic, valid identifiers: the admin wallet (whose address the build treats as the admin) +// and the rewards contract the app is pointed at. +export const FLOW_ADMIN_ADDRESS = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).publicKey(); +export const FLOW_REWARDS_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 9)); +const PORT = 4180; + +export default defineConfig({ + testDir: './tests/flow', + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: `npx vite build --outDir dist-e2e && npx vite preview --outDir dist-e2e --port ${PORT} --strictPort`, + port: PORT, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { + VITE_ADMIN_ADDRESSES: FLOW_ADMIN_ADDRESS, + VITE_REWARDS_CONTRACT_ID: FLOW_REWARDS_CONTRACT_ID, + VITE_STELLAR_NETWORK: 'testnet', + }, + }, +}); diff --git a/frontend/src/stellar.js b/frontend/src/stellar.js index 7e7cb411..2321d845 100644 --- a/frontend/src/stellar.js +++ b/frontend/src/stellar.js @@ -76,6 +76,20 @@ export function getFreighterApi() { return freighterApi; } +/* ---------- contract call results ---------- */ + +/** + * `AssembledTransaction.simulate()` and `.signAndSend()` resolve to a transaction object whose + * decoded return value is on `.result` (see the generated bindings in ./contracts). Callers need the + * value, so unwrap it; plain values pass through unchanged. + */ +export function unwrapContractResult(value) { + if (value !== null && typeof value === 'object' && 'result' in value) { + return value.result; + } + return value; +} + /* ---------- formatting ---------- */ export function formatPoints(points) { @@ -167,7 +181,7 @@ export async function fetchRewardsBalance(walletAddress) { }); const tx = await client.balance({ user: walletAddress }); - return await tx.simulate(); + return unwrapContractResult(await tx.simulate()); } /* ---------- contract write helpers ---------- */ @@ -197,7 +211,7 @@ export async function submitClaimTransaction(walletAddress, amount) { amount: BigInt(amount), }); - const newBalanceVal = await tx.signAndSend(); + const newBalanceVal = unwrapContractResult(await tx.signAndSend()); const hash = tx.signed.hash().toString('hex'); const newBalance = formatPoints(newBalanceVal); @@ -237,7 +251,7 @@ export async function submitRedeemTransaction(walletAddress, pointsAmount) { points_amount: BigInt(pointsAmount), }); - const assetAmountVal = await tx.signAndSend(); + const assetAmountVal = unwrapContractResult(await tx.signAndSend()); const hash = tx.signed.hash().toString('hex'); const assetAmount = formatPoints(assetAmountVal); @@ -259,7 +273,7 @@ export async function fetchPayoutReserveBalance() { }); const tx = await client.payout_reserve_balance(); - const result = await tx.simulate(); + const result = unwrapContractResult(await tx.simulate()); return formatPoints(result); } @@ -278,11 +292,26 @@ export async function fetchCampaignOnChainState(contractId) { }); const [isActive, isWithinWindow, participantCount, maxCap, window] = await Promise.all([ - client.is_active().then((tx) => tx.simulate()), - client.is_within_window().then((tx) => tx.simulate()), - client.get_participant_count().then((tx) => tx.simulate()), - client.get_max_cap().then((tx) => tx.simulate()), - client.get_window().then((tx) => tx.simulate()), + client + .is_active() + .then((tx) => tx.simulate()) + .then(unwrapContractResult), + client + .is_within_window() + .then((tx) => tx.simulate()) + .then(unwrapContractResult), + client + .get_participant_count() + .then((tx) => tx.simulate()) + .then(unwrapContractResult), + client + .get_max_cap() + .then((tx) => tx.simulate()) + .then(unwrapContractResult), + client + .get_window() + .then((tx) => tx.simulate()) + .then(unwrapContractResult), ]); // get_window() returns (start, end) as ledger-timestamp (unix seconds). @@ -316,7 +345,7 @@ export async function checkParticipantStatus(walletAddress) { }); const tx = await client.is_participant({ participant: walletAddress }); - return await tx.simulate(); + return unwrapContractResult(await tx.simulate()); } /** @@ -356,7 +385,7 @@ export async function submitRegisterTransaction(walletAddress) { proof: emptyProof, }); - const wasNew = await tx.signAndSend(); + const wasNew = unwrapContractResult(await tx.signAndSend()); const hash = tx.signed.hash().toString('hex'); return { hash, alreadyRegistered: !wasNew }; @@ -411,7 +440,7 @@ export async function getCampaignAdminNonce(contractId) { }); const tx = await client.admin_nonce(); - return await tx.simulate(); + return unwrapContractResult(await tx.simulate()); } /** @@ -573,7 +602,7 @@ export async function fetchAllowance(owner, spender) { }); const tx = await client.sep41_allowance({ owner, spender }); - const result = await tx.simulate(); + const result = unwrapContractResult(await tx.simulate()); return Number(result ?? 0); } diff --git a/frontend/tests/e2e/README.md b/frontend/tests/e2e/README.md index 6e3c4423..aa4067ac 100644 --- a/frontend/tests/e2e/README.md +++ b/frontend/tests/e2e/README.md @@ -199,3 +199,20 @@ npx playwright show-trace test-results/*/trace.zip - [Playwright Configuration](../../playwright.config.js) - [Frontend CI Workflow](../../.github/workflows/frontend-ci.yml) - [Visual Regression Tests](../visual/README.md) + +## Campaign creation → reward claim flow (`frontend/tests/flow/`, #1319) + +`campaign-to-claim.spec.js` drives the real UI through the whole journey: connect an admin wallet, +create a campaign, open its page, then claim earned rewards with a signed Soroban transaction and see +the points balance drop. It is hermetic — the REST API, Horizon, the Soroban RPC +(`support/soroban-chain-mock.js`, real XDR with a stateful balance) and the wallet extension are all +mocked in the test — so it needs no testnet, backend or browser extension. + +```bash +npm run test:e2e:flow --workspace=frontend +``` + +It uses its own config (`playwright.flow.config.js`) because it needs a production build with an +admin wallet and rewards contract configured (`VITE_ADMIN_ADDRESSES`, `VITE_REWARDS_CONTRACT_ID`); +the config builds that bundle into `dist-e2e/` and serves it. The existing +`campaign-lifecycle.test.ts` is the testnet variant of the same journey. diff --git a/frontend/tests/flow/campaign-to-claim.spec.js b/frontend/tests/flow/campaign-to-claim.spec.js new file mode 100644 index 00000000..f5219d3a --- /dev/null +++ b/frontend/tests/flow/campaign-to-claim.spec.js @@ -0,0 +1,203 @@ +import { expect, test } from '@playwright/test'; +import { FLOW_ADMIN_ADDRESS } from '../../playwright.flow.config.js'; +import { createSorobanChainMock } from './support/soroban-chain-mock.js'; + +/** + * End-to-end browser flow (#1319): an admin connects a wallet and creates a campaign, the campaign + * page loads, and a participant claims earned rewards with an on-chain transaction that is signed in + * the (mocked) wallet and submitted to a (mocked) Soroban RPC, after which the points balance shown + * in the UI drops accordingly. + * + * Everything outside the browser is mocked in this file, so the test is deterministic and needs no + * testnet, backend or wallet extension: + * - the Trivela REST API -> an in-memory campaign store (below) + * - Horizon -> a fixed XLM balance + * - the Soroban RPC -> support/soroban-chain-mock.js (real XDR, stateful balance) + * - the wallet extension -> a Rabet stub injected before the app loads + * + * Run: npm run test:e2e:flow --workspace=frontend + */ + +const SOROBAN_RPC = 'https://soroban-testnet.stellar.org/**'; +const HORIZON = 'https://horizon-testnet.stellar.org/**'; +const JSON_HEADERS = { 'access-control-allow-origin': '*' }; + +const json = (route, body, status = 200) => + route.fulfill({ + status, + contentType: 'application/json', + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); + +const emptyPage = (data) => ({ + data, + pagination: { + total: data.length, + count: data.length, + page: 1, + limit: 20, + offset: 0, + totalPages: 1, + hasPreviousPage: false, + hasNextPage: false, + previousPage: null, + nextPage: null, + }, +}); + +/** Installs the mocks and returns handles to inspect what the app sent. */ +async function mockBackend(page, { rewardsBalance }) { + const campaigns = new Map(); + const apiRequests = []; + const chain = createSorobanChainMock({ balances: { [FLOW_ADMIN_ADDRESS]: rewardsBalance } }); + + await page.addInitScript((address) => { + window.localStorage.setItem('trivela:tour_completed', 'true'); + window.rabet = { + connect: () => Promise.resolve({ publicKey: address }), + // The mock RPC does not verify signatures, so returning the XDR unchanged is a valid "signature". + sign: (xdr) => Promise.resolve({ xdr }), + }; + }, FLOW_ADMIN_ADDRESS); + + await page.route('**/api/v1/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/api\/v1/, ''); + apiRequests.push({ method: request.method(), path, body: request.postData() }); + + if (path === '/campaigns' && request.method() === 'POST') { + const body = JSON.parse(request.postData() ?? '{}'); + const campaign = { + id: String(campaigns.size + 1), + active: true, + participantCount: 0, + tasks: [], + createdAt: new Date().toISOString(), + ...body, + }; + campaigns.set(campaign.id, campaign); + return json(route, campaign, 201); + } + if (path === '/campaigns') return json(route, emptyPage([...campaigns.values()])); + const detail = path.match(/^\/campaigns\/([^/]+)$/); + if (detail && request.method() === 'GET') { + const campaign = campaigns.get(detail[1]); + return campaign ? json(route, campaign) : json(route, { error: 'Not found' }, 404); + } + // config, referrals, analytics, ... : empty but well-formed + return json(route, { data: [], pagination: {} }); + }); + + await page.route(HORIZON, (route) => + json(route, { + id: FLOW_ADMIN_ADDRESS, + balances: [{ asset_type: 'native', balance: '1000.0000000' }], + }), + ); + await page.route(SOROBAN_RPC, (route) => chain.handle(route)); + + return { campaigns, apiRequests, chain }; +} + +async function connectRabet(page) { + await page + .getByRole('button', { name: /connect wallet/i }) + .first() + .click(); + await page.getByRole('dialog').getByRole('button', { name: /Rabet/ }).click(); + await expect(page.getByText('Wallet connected')).toBeVisible(); +} + +test.describe('campaign creation to reward claim', () => { + test('admin creates a campaign, the campaign page loads, and a participant claims rewards', async ({ + page, + }) => { + const { campaigns, apiRequests, chain } = await mockBackend(page, { rewardsBalance: 500 }); + + // 1. The admin area is gated until a wallet that is on the admin list is connected. + await page.goto('/admin/campaigns/new'); + await expect(page.getByRole('heading', { name: /admin access required/i })).toBeVisible(); + await connectRabet(page); + await expect(page.getByRole('heading', { name: /create new campaign/i })).toBeVisible(); + + // 2. Create the campaign through the form. + await page.getByLabel('Admin API key').fill('test-admin-key'); + await page.getByLabel(/Campaign name/).fill('Spring Referral Drive'); + await page.getByLabel('Description').fill('Invite friends and earn points for every sign-up.'); + await page.getByLabel(/Reward amount per participant/).fill('25'); + await page.getByRole('button', { name: /^create campaign$/i }).click(); + + // The form posted the right payload with the admin API key ... + await expect.poll(() => campaigns.size).toBe(1); + const created = apiRequests.find((r) => r.method === 'POST' && r.path === '/campaigns'); + expect(JSON.parse(created.body)).toMatchObject({ + name: 'Spring Referral Drive', + rewardPerAction: 25, + }); + + // 3. ... and the app navigated to the new campaign's page. + await expect(page).toHaveURL(/\/campaign\/1$/); + await expect(page.getByRole('heading', { name: 'Spring Referral Drive' })).toBeVisible(); + await expect(page.getByText('Campaign #1')).toBeVisible(); + + // 4. Claim rewards on the home page: the on-chain balance is read, then debited by the claim. + await page.getByRole('link', { name: 'Trivela home' }).first().click(); + await expect(page).toHaveURL(/\/$/); + await connectRabet(page); + const points = page.locator('.rewards-balance strong'); + await expect(points).toHaveText('500'); + + const claimSection = page.getByRole('region', { name: /claim rewards/i }); + await claimSection.getByLabel('Amount to claim').fill('200'); + await claimSection.getByRole('button', { name: 'Claim', exact: true }).click(); + + // The confirmation appears, the contract state changed, and the displayed balance is refreshed. + await expect(claimSection.getByText('Transaction confirmed')).toBeVisible(); + await expect(points).toHaveText('300'); + await expect.poll(() => chain.state.balances[FLOW_ADMIN_ADDRESS]).toBe(300n); + expect(chain.state.claimed).toBe(200n); + expect(chain.state.submitted.size).toBe(1); + }); + + test('claiming more than the balance is rejected with a readable error and changes nothing', async ({ + page, + }) => { + const { chain } = await mockBackend(page, { rewardsBalance: 50 }); + + await page.goto('/'); + await connectRabet(page); + await expect(page.locator('.rewards-balance strong')).toHaveText('50'); + + const claimSection = page.getByRole('region', { name: /claim rewards/i }); + await claimSection.getByLabel('Amount to claim').fill('80'); + await claimSection.getByRole('button', { name: 'Claim', exact: true }).click(); + + await expect(claimSection.getByRole('alert')).toContainText('Insufficient points'); + expect(chain.state.balances[FLOW_ADMIN_ADDRESS]).toBe(50n); + expect(chain.state.claimed).toBe(0n); + expect(chain.state.submitted.size).toBe(0); + }); + + test('the claim button stays disabled until a positive whole amount is entered', async ({ + page, + }) => { + await mockBackend(page, { rewardsBalance: 500 }); + + await page.goto('/'); + await connectRabet(page); + + const claimSection = page.getByRole('region', { name: /claim rewards/i }); + const submit = claimSection.getByRole('button', { name: 'Claim', exact: true }); + const amount = claimSection.getByLabel('Amount to claim'); + + await expect(submit).toBeDisabled(); + for (const invalid of ['0', '-5', '1.5']) { + await amount.fill(invalid); + await expect(submit).toBeDisabled(); + } + await amount.fill('10'); + await expect(submit).toBeEnabled(); + }); +}); diff --git a/frontend/tests/flow/support/soroban-chain-mock.js b/frontend/tests/flow/support/soroban-chain-mock.js new file mode 100644 index 00000000..7713e110 --- /dev/null +++ b/frontend/tests/flow/support/soroban-chain-mock.js @@ -0,0 +1,255 @@ +import { + Keypair, + StrKey, + Networks, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; + +/** + * A tiny in-memory Soroban RPC for the rewards contract, driven through Playwright's request + * routing. It answers the JSON-RPC calls the generated contract client makes (account lookup, + * simulation, submission, polling) with real XDR, and keeps the chain state in plain objects: + * `balance(user)` reads it, `claim(user, amount)` debits it and credits the claimed total. + * + * Only what the app calls is implemented; anything else returns a JSON-RPC "method not found". + */ + +const INT64 = (n) => xdr.Int64.fromString(String(n)); +const u64 = (n) => nativeToScVal(BigInt(n), { type: 'u64' }); + +// Error::InsufficientBalance as mapped by src/lib/contractErrors.js. +export const CONTRACT_ERROR_INSUFFICIENT_BALANCE = 2; + +export function createSorobanChainMock({ + networkPassphrase = Networks.TESTNET, + balances = {}, + startLedger = 1_000, +} = {}) { + const state = { + balances: { ...balances }, + claimed: 0n, + ledger: startLedger, + submitted: new Map(), // tx hash -> { method, returnValue, envelopeXdr } + calls: [], + sequences: new Map(), + }; + state.balances = Object.fromEntries( + Object.entries(state.balances).map(([k, v]) => [k, BigInt(v)]), + ); + + const accountLedgerEntry = (address) => + xdr.LedgerEntryData.account( + new xdr.AccountEntry({ + accountId: Keypair.fromPublicKey(address).xdrAccountId(), + balance: INT64('1000000000'), + seqNum: xdr.SequenceNumber.fromString(String(state.sequences.get(address) ?? 100)), + numSubEntries: 0, + inflationDest: null, + flags: 0, + homeDomain: '', + thresholds: Buffer.from([1, 0, 0, 0]), + signers: [], + ext: new xdr.AccountEntryExt(0), + }), + ); + + const decodeInvocation = (txXdr) => { + const tx = TransactionBuilder.fromXDR(txXdr, networkPassphrase); + const invoke = tx.operations[0].func.invokeContract(); + const method = invoke.functionName().toString(); + const args = invoke.args().map((arg) => scValToNative(arg)); + return { tx, method, args }; + }; + + const sorobanData = (readWriteKeys = []) => + new xdr.SorobanTransactionData({ + ext: new xdr.SorobanTransactionDataExt(0), + resources: new xdr.SorobanResources({ + footprint: new xdr.LedgerFootprint({ readOnly: [], readWrite: readWriteKeys }), + instructions: 1_000, + diskReadBytes: 0, + writeBytes: 0, + }), + resourceFee: INT64('100'), + }); + + const evaluate = ({ method, args }) => { + const [user, amount] = args; + if (method === 'balance') { + return { returnValue: u64(state.balances[user] ?? 0n), writes: [] }; + } + if (method === 'claim') { + const current = state.balances[user] ?? 0n; + const value = BigInt(amount); + if (value > current) { + return { error: `HostError: Error(Contract, #${CONTRACT_ERROR_INSUFFICIENT_BALANCE})` }; + } + return { + returnValue: u64(current - value), + writes: [ + xdr.LedgerKey.account( + new xdr.LedgerKeyAccount({ accountId: Keypair.fromPublicKey(user).xdrAccountId() }), + ), + ], + apply: () => { + state.balances[user] = current - value; + state.claimed += value; + }, + }; + } + return { error: `HostError: unknown function ${method}` }; + }; + + const handlers = { + getHealth: () => ({ + status: 'healthy', + latestLedger: state.ledger, + oldestLedger: 1, + ledgerRetentionWindow: 17280, + }), + getNetwork: () => ({ passphrase: networkPassphrase, protocolVersion: 23 }), + getLatestLedger: () => ({ id: 'mock', protocolVersion: 23, sequence: state.ledger }), + getLedgerEntries: (params) => { + const entries = (params.keys ?? []).map((key) => { + const ledgerKey = xdr.LedgerKey.fromXDR(key, 'base64'); + const address = StrKey.encodeEd25519PublicKey(ledgerKey.account().accountId().ed25519()); + return { + key, + xdr: accountLedgerEntry(address).toXDR('base64'), + lastModifiedLedgerSeq: state.ledger, + }; + }); + return { entries, latestLedger: state.ledger }; + }, + simulateTransaction: (params) => { + const invocation = decodeInvocation(params.transaction); + const result = evaluate(invocation); + if (result.error) return { error: result.error, latestLedger: state.ledger }; + return { + latestLedger: state.ledger, + minResourceFee: '100', + transactionData: sorobanData(result.writes).toXDR('base64'), + results: [{ auth: [], xdr: result.returnValue.toXDR('base64') }], + events: [], + }; + }, + sendTransaction: (params) => { + const invocation = decodeInvocation(params.transaction); + const result = evaluate(invocation); + const hash = invocation.tx.hash().toString('hex'); + if (result.error) { + return { status: 'ERROR', hash, latestLedger: state.ledger, latestLedgerCloseTime: '0' }; + } + result.apply?.(); + state.ledger += 1; + state.submitted.set(hash, { + returnValue: result.returnValue, + envelopeXdr: params.transaction, + }); + return { + status: 'PENDING', + hash, + latestLedger: state.ledger, + latestLedgerCloseTime: String(Math.floor(Date.now() / 1000)), + }; + }, + getTransaction: (params) => { + const record = state.submitted.get(params.hash); + if (!record) + return { + status: 'NOT_FOUND', + latestLedger: state.ledger, + latestLedgerCloseTime: '0', + oldestLedger: 1, + oldestLedgerCloseTime: '0', + }; + const meta = new xdr.TransactionMeta( + 3, + new xdr.TransactionMetaV3({ + ext: new xdr.ExtensionPoint(0), + txChangesBefore: [], + operations: [], + txChangesAfter: [], + sorobanMeta: new xdr.SorobanTransactionMeta({ + ext: new xdr.SorobanTransactionMetaExt(0), + events: [], + returnValue: record.returnValue, + diagnosticEvents: [], + }), + }), + ); + const result = new xdr.TransactionResult({ + feeCharged: INT64('100'), + result: xdr.TransactionResultResult.txSuccess([]), + ext: new xdr.TransactionResultExt(0), + }); + const nowSec = String(Math.floor(Date.now() / 1000)); + return { + status: 'SUCCESS', + latestLedger: state.ledger, + latestLedgerCloseTime: nowSec, + oldestLedger: 1, + oldestLedgerCloseTime: '0', + ledger: state.ledger, + createdAt: nowSec, + applicationOrder: 1, + feeBump: false, + envelopeXdr: record.envelopeXdr, + resultXdr: result.toXDR('base64'), + resultMetaXdr: meta.toXDR('base64'), + }; + }, + }; + + return { + state, + /** Playwright `route.fulfill` handler for POST requests to the Soroban RPC URL. */ + async handle(route) { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'POST, OPTIONS', + 'access-control-allow-headers': '*', + }, + }); + return; + } + const request = JSON.parse(route.request().postData() ?? '{}'); + state.calls.push(request.method); + const handler = handlers[request.method]; + let body; + if (!handler) { + body = { + jsonrpc: '2.0', + id: request.id, + error: { code: -32601, message: `Method not found: ${request.method}` }, + }; + } else { + try { + const result = handler(request.params ?? {}); + body = + result?.error && request.method !== 'simulateTransaction' + ? { jsonrpc: '2.0', id: request.id, error: { code: -32000, message: result.error } } + : { jsonrpc: '2.0', id: request.id, result }; + } catch (error) { + body = { + jsonrpc: '2.0', + id: request.id, + error: { code: -32603, message: String(error?.message ?? error) }, + }; + } + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { 'access-control-allow-origin': '*' }, + body: JSON.stringify(body), + }); + }, + }; +} diff --git a/scripts/verify-reward-invariants.sh b/scripts/verify-reward-invariants.sh new file mode 100755 index 00000000..89c3d1c0 --- /dev/null +++ b/scripts/verify-reward-invariants.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Checks the SMT models in contracts/rewards/formal/ with Z3 (issue #1317). +# +# Each file starts with "; expect: unsat" (the property is proved: no counterexample exists) or +# "; expect: sat" (a negative control / informational check that must keep finding a model). +# The script fails if any result differs from what the file declares, so a model that silently +# stops being meaningful is caught as well as one that stops being true. +# +# scripts/verify-reward-invariants.sh # all models +# scripts/verify-reward-invariants.sh 04_*.smt2 # selected models +set -euo pipefail + +if ! command -v z3 >/dev/null 2>&1; then + echo "z3 is required (brew install z3 / apt-get install z3)" >&2 + exit 2 +fi + +dir="$(cd "$(dirname "$0")/../contracts/rewards/formal" && pwd)" +cd "$dir" +files=("$@") +[ ${#files[@]} -eq 0 ] && files=(*.smt2) + +failed=0 +for f in "${files[@]}"; do + expected="$(sed -n 's/^; expect: *//p' "$f" | head -n1)" + actual="$(z3 -T:60 "$f" | sed -n 1p)" + if [ "$actual" = "$expected" ]; then + printf 'PASS %-62s %s\n' "$f" "$actual" + else + printf 'FAIL %-62s expected %s, got %s\n' "$f" "$expected" "$actual" + failed=1 + fi +done + +exit "$failed"