diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..806d949 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# Nullius — Environment variables +# Copy this file to .env and fill in values after deploying. +# Never commit .env to version control. + +# Stellar network (testnet or mainnet) +STELLAR_NETWORK=testnet + +# Deployed contract IDs (written by scripts/deploy.js) +GROTH16_VERIFIER_ID= +REPUTATION_REGISTRY_ID= +PAYMENT_GATE_ID= + +# Deployer key alias (used by stellar-cli) +STELLAR_DEPLOYER_ALIAS=deployer + +# Optional: override RPC endpoint +# STELLAR_RPC_URL=https://soroban-testnet.stellar.org + +# ---------------------------------------------------------------- +# Frontend (Vite) — prefix with VITE_ so they are exposed to the browser +# ---------------------------------------------------------------- + +# Contract IDs for the frontend build (mirrors the above without VITE_ prefix) +VITE_GROTH16_VERIFIER_ID= +VITE_REPUTATION_REGISTRY_ID= +VITE_PAYMENT_GATE_ID= + +# Soroban-wrapped XLM token address on the target network. +# Testnet: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# Mainnet: update after mainnet deployment +VITE_NATIVE_TOKEN=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC + +# Treasury address that collects protocol fees. +# In the demo this defaults to the sender (fees are circular). +# Set to a real treasury address before any mainnet deployment. +VITE_FEE_COLLECTOR= + +# Optional: end-to-end test secret key (funded testnet account) +# Used by scripts/e2e_test.js — never set this in a shared CI environment +# SECRET_KEY= + +# Optional: remote error reporting endpoint (e.g. Sentry ingest or custom) +# When set, ErrorBoundary sends structured JSON via navigator.sendBeacon +# VITE_ERROR_ENDPOINT= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16fdff1..84e6b28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,12 @@ name: CI on: push: - branches: [main, develop] + branches: + - main + - develop + - "feat/**" + - "fix/**" + - "chore/**" pull_request: branches: [main, develop] @@ -23,7 +28,8 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: - targets: wasm32-unknown-unknown + # wasm32v1-none is the current Soroban WASM target (soroban-sdk ≥ 21) + targets: wasm32v1-none - name: Cache Cargo registry uses: actions/cache@v4 @@ -45,7 +51,7 @@ jobs: run: cargo test --all - name: Build WASM release artifacts - run: cargo build --target wasm32-unknown-unknown --release + run: cargo build --target wasm32v1-none --release # ---------------------------------------------------------------- # SDK type-check + build @@ -95,3 +101,86 @@ jobs: - name: Build frontend run: npm run build:frontend + + # ---------------------------------------------------------------- + # WASM artifact size report (runs after contracts build) + # Fails the build if any contract exceeds 100 KB — keeps contract + # costs predictable and catches accidental dependency bloat. + # ---------------------------------------------------------------- + wasm-size: + name: WASM Size Check + runs-on: ubuntu-latest + needs: contracts + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32v1-none + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build WASM release artifacts + run: cargo build --target wasm32v1-none --release + + - name: Report and check WASM sizes + run: | + WASM_DIR="target/wasm32v1-none/release" + MAX_BYTES=102400 # 100 KB + FAIL=0 + for f in groth16_verifier reputation_registry payment_gate; do + FILE="$WASM_DIR/${f}.wasm" + if [ -f "$FILE" ]; then + SIZE=$(wc -c < "$FILE") + echo "$f: ${SIZE} bytes ($(echo "scale=1; $SIZE/1024" | bc) KB)" + if [ "$SIZE" -gt "$MAX_BYTES" ]; then + echo " ERROR: exceeds 100 KB limit" + FAIL=1 + fi + else + echo "WARNING: $FILE not found" + fi + done + exit $FAIL + + # ---------------------------------------------------------------- + # Dependency security audit + # ---------------------------------------------------------------- + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Audit npm dependencies + # --audit-level=high: fail only on high/critical vulns, not moderate + run: npm audit --audit-level=high + continue-on-error: true # advisory only for now; remove to enforce + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Audit Cargo dependencies + run: cargo audit + continue-on-error: true # advisory only for now; remove to enforce diff --git a/.gitignore b/.gitignore index c4f8b57..b8e8f81 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,10 @@ circuits/keys/verification_key.json .contract_addresses.json sdk/src/contract_ids.json -# Environment +# Environment — ignore real secrets, but track the example template .env -.env.* +.env.local +.env.*.local # OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 28af54c..96c4e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,14 +25,35 @@ Versions follow [Semantic Versioning](https://semver.org/). - Wallet connection error surfaced in the connect screen UI - `CONTRIBUTING.md` — development setup, code style, and PR checklist - `CHANGELOG.md` — this file +- `NulliusClient.buildSubmitProofTransaction(walletAddress, bundle)` — builds unsigned proof submission XDR for Freighter signing; frontend no longer duplicates transaction construction +- `encodeG1`, `encodeG2`, `encodeScalar` exported from SDK — shared by `contracts.ts` and available to consumers; removes duplicate implementations in the frontend +- Byte-length validation in `reputation_registry::submit_proof` — explicit panics with descriptive messages for wrong-length proof components, before the cross-contract call +- `quote` and `send` unit tests for `payment_gate` — mock registry stub, Silver/Unverified fee checks, zero-amount and over-limit rejections +- `VITE_NATIVE_TOKEN`, `VITE_FEE_COLLECTOR`, `VITE_ERROR_ENDPOINT`, `VITE_*` contract IDs added to `.env.example` +- Remote error reporting in `ErrorBoundary` via `navigator.sendBeacon` — fires when `VITE_ERROR_ENDPOINT` is set; swallows failures so reporting never crashes the app +- MIT `LICENSE` file +- Score formula breakdown table in README ### Changed - `ProofGenerator` no longer imports `Keypair` from `@stellar/stellar-sdk` - `App.tsx` wraps each tab panel in `ErrorBoundary` - `groth16_verifier` test module refactored into helper functions for readability +- Silver tier color corrected from `#6b7280` (grey, indistinct from Unverified) to `#94a3b8` (silver-slate) in `ReputationCard` and `ProofHistory` +- `PaymentWidget` reads `VITE_NATIVE_TOKEN` and `VITE_FEE_COLLECTOR` at runtime with testnet fallbacks +- `useFreighter` hook no longer exports unused `sign` method; components call `signTransaction` directly +- `sdk/package.json` runtime dependencies pinned to exact versions (`@stellar/stellar-sdk@12.3.0`, `snarkjs@0.7.6`, `circomlibjs@0.1.7`) +- `.gitignore` `.env.*` exclusion narrowed to `.env.local` and `.env.*.local` so `.env.example` is tracked +- `waitForConfirmation` in SDK replaced fixed 1500ms poll with exponential backoff (1 s → 2 s → 4 s → 8 s, 30 s total budget) +- README deployed contracts table: TBD replaced with reference to `sdk/src/contract_ids.json` +- Placeholder GitHub link `your-repo/nullius` in footer replaced with `nullius-zk/nullius` ### Fixed - `quote` function in `payment_gate` had an incorrect `#[allow(clippy::too_many_arguments)]` attribute removed (it doesn't take many args) +- `avg_balance` private input was committed via Poseidon hash but never used in `score_proxy` — now incorporated with a cap of 10,000 units; threshold scaling factor updated from 600 to 700; `selectThreshold()` and live score preview in `ProofGenerator` updated to match +- `vite.config.ts` was missing `Cross-Origin-Embedder-Policy: require-corp` header; without it browsers cannot expose `SharedArrayBuffer` and snarkjs falls back to single-threaded WASM (~3× slower) +- Dead `encodeBytes` closure in `ProofGenerator.handleGenerate` removed +- `ProofGenerator` local proof-encoding functions (`encodeG1Bytes`, `encodeG2Bytes`, `encodeScalarBytes`) removed; imports from SDK instead +- JSDoc on `generateReputationProof` documents the snarkjs output ordering vs on-chain input ordering asymmetry that was an undocumented footgun --- diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f7a7b82 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Nullius Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f7beabf..131071f 100644 --- a/README.md +++ b/README.md @@ -142,15 +142,29 @@ The ZK proof is not cosmetic — the Soroban contract **cannot be tricked**: - The `meets_threshold` output is enforced both by the circuit and the verifier contract - Stellar's native BN254 host functions (Protocol 25/26) make verification cheap +### Score formula + +The reputation score is computed inside the ZK circuit (never on-chain): + +| Component | Max contribution | Notes | +|-----------|-----------------|-------| +| Transaction count | 40 pts | capped at 50 txs | +| Clean transaction rate | 40 pts | (tx_count − disputes) contribution | +| Wallet age | 20 pts | capped at 12 months | +| Average balance | ~14 pts | capped at 10,000 units (XLM/1000) | + +All arithmetic uses integer scaling (factor 700) to avoid division in ZK constraints. + ## Deployed contracts (Stellar testnet) -> Updated after deployment via `npm run deploy:testnet` +> Updated after deployment via `npm run deploy:testnet`. +> Run `cat sdk/src/contract_ids.json` to see the latest addresses. | Contract | Address | |----------|---------| -| groth16_verifier | TBD | -| reputation_registry | TBD | -| payment_gate | TBD | +| groth16_verifier | See `sdk/src/contract_ids.json` | +| reputation_registry | See `sdk/src/contract_ids.json` | +| payment_gate | See `sdk/src/contract_ids.json` | ## Privacy guarantees diff --git a/bun.lock b/bun.lock index 05e2b0d..e764fe0 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,12 @@ "workspaces": { "": { "name": "proofpay", + "dependencies": { + "circomlib": "^2.0.5", + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.62.2", + }, }, "frontend": { "name": "@nullius/frontend", @@ -21,6 +27,9 @@ "typescript": "^5.3.0", "vite": "^5.0.0", }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.62.2", + }, }, "sdk": { "name": "@nullius/sdk", @@ -31,6 +40,7 @@ "snarkjs": "^0.7.0", }, "devDependencies": { + "@types/node": "^20.0.0", "typescript": "^5.3.0", }, }, @@ -270,6 +280,8 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], @@ -342,6 +354,8 @@ "circom_runtime": ["circom_runtime@0.1.28", "", { "dependencies": { "ffjavascript": "0.3.1" }, "bin": { "calcwit": "calcwit.js" } }, "sha512-ACagpQ7zBRLKDl5xRZ4KpmYIcZDUjOiNRuxvXLqhnnlLSVY1Dbvh73TI853nqoR0oEbihtWmMSjgc5f+pXf/jQ=="], + "circomlib": ["circomlib@2.0.5", "", {}, "sha512-O7NQ8OS+J4eshBuoy36z/TwQU0YHw8W3zxZcs4hVwpEll3e4hDm3mgkIPqItN8FDeLEKZFK3YeT/+k8TiLF3/A=="], + "circomlibjs": ["circomlibjs@0.1.7", "", { "dependencies": { "blake-hash": "^2.0.0", "blake2b": "^2.1.3", "ethers": "^5.5.1", "ffjavascript": "^0.2.45" } }, "sha512-GRAUoAlKAsiiTa+PA725G9RmEmJJRc8tRFxw/zKktUxlQISGznT4hH4ESvW8FNTsrGg/nNd06sGP/Wlx0LUHVg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -542,6 +556,8 @@ "underscore": ["underscore@1.13.6", "", {}, "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="], diff --git a/circuits/reputation_score.circom b/circuits/reputation_score.circom index ac8448c..0dcbcfd 100644 --- a/circuits/reputation_score.circom +++ b/circuits/reputation_score.circom @@ -82,28 +82,37 @@ template ReputationScore() { age_lt.in[1] <== 12; age_capped <== age_lt.out * months_active + (1 - age_lt.out) * 12; + // balance_score: capped at 10,000 units → contributes up to 20 points + // avg_balance is in stroops (1 XLM = 10,000,000 stroops). We normalise by + // dividing conceptually by 1,000 first (caller passes balance / 1000). + // Cap at 10_000 units (equivalent to 100 XLM) to prevent gaming. + signal bal_capped; + component bal_lt = LessThan(32); + bal_lt.in[0] <== avg_balance; + bal_lt.in[1] <== 10000; + bal_capped <== bal_lt.out * avg_balance + (1 - bal_lt.out) * 10000; + // Combined score proxy (avoids division): - // score_proxy = tx_capped * 12 * 40 + age_capped * 50 * 20 - // + (tx_count - dispute_count) * 12 * 40 - // threshold check done on same scale // Scale everything by 600 (LCM of 50 and 12) to stay integer: - // tx contribution: tx_capped * 480 (max 50*480=24000) - // clean contribution:(tx_count-disputes)*480 (max 24000) - // age contribution: age_capped * 1000 (max 12000) - // total max = 60000 → 100 points scaled - // threshold_scaled = threshold * 600 + // tx contribution: tx_capped * 480 (max 50*480 = 24000) + // clean contribution: clean_txs * 480 (max 50*480 = 24000) + // age contribution: age_capped * 1000 (max 12*1000 = 12000) + // balance contribution: bal_capped * 1 (max 10000*1 = 10000) + // total max = 70000 → threshold_scaled = threshold * 700 + // threshold_scaled = threshold * 700 signal clean_txs; clean_txs <== tx_count - dispute_count; signal score_proxy; - score_proxy <== tx_capped * 480 + clean_txs * 480 + age_capped * 1000; + score_proxy <== tx_capped * 480 + clean_txs * 480 + age_capped * 1000 + bal_capped; signal threshold_scaled; - threshold_scaled <== threshold * 600; + threshold_scaled <== threshold * 700; // ------------------------------------------------------- // 3. Check score_proxy >= threshold_scaled + // score_proxy max = 70000 fits in 17 bits; 32 bits is safe. // ------------------------------------------------------- component gte = GreaterEqThan(32); gte.in[0] <== score_proxy; diff --git a/contracts/payment_gate/src/lib.rs b/contracts/payment_gate/src/lib.rs index 786b777..09dadca 100644 --- a/contracts/payment_gate/src/lib.rs +++ b/contracts/payment_gate/src/lib.rs @@ -216,4 +216,123 @@ mod tests { client.initialize(®istry); client.initialize(®istry); // must panic } + + // ---------------------------------------------------------------- + // quote() — simulation with mock registry + // ---------------------------------------------------------------- + + /// A stub registry contract that always returns a fixed tier. + /// Registered in the test environment so cross-contract calls work. + mod mock_registry { + use soroban_sdk::{contract, contractimpl, Address, Env, IntoVal}; + + #[contract] + pub struct MockRegistry; + + #[contractimpl] + impl MockRegistry { + /// Always returns Silver (2). + pub fn get_tier(_env: Env, _wallet: Address) -> u32 { + 2 + } + } + } + + #[test] + fn quote_returns_correct_fee_and_net_for_silver() { + let env = Env::default(); + env.mock_all_auths(); + + // Deploy mock registry that always returns Silver (tier=2) + let registry_id = env.register_contract(None, mock_registry::MockRegistry); + + let gate_id = env.register_contract(None, PaymentGate); + let client = PaymentGateClient::new(&env, &gate_id); + client.initialize(®istry_id); + + let wallet = Address::generate(&env); + let amount: i128 = 1_000_000_000; // 100 XLM in stroops + + let (fee, net, tier) = client.quote(&wallet, &amount); + + // Silver = 1.0% → fee = 10_000_000, net = 990_000_000 + assert_eq!(tier, 2); + assert_eq!(fee, 10_000_000); + assert_eq!(net, 990_000_000); + assert_eq!(fee + net, amount); + } + + #[test] + fn quote_returns_correct_fee_for_unverified() { + let env = Env::default(); + env.mock_all_auths(); + + // We need a mock registry that returns 0 (Unverified). + mod mock_unverified { + use soroban_sdk::{contract, contractimpl, Address, Env}; + #[contract] pub struct MockUnverified; + #[contractimpl] impl MockUnverified { + pub fn get_tier(_env: Env, _wallet: Address) -> u32 { 0 } + } + } + let registry_id = env.register_contract(None, mock_unverified::MockUnverified); + + let gate_id = env.register_contract(None, PaymentGate); + let client = PaymentGateClient::new(&env, &gate_id); + client.initialize(®istry_id); + + let wallet = Address::generate(&env); + let amount: i128 = 1_000_000_000; // 100 XLM + + let (fee, net, tier) = client.quote(&wallet, &amount); + + // Unverified = 5.0% → fee = 50_000_000, net = 950_000_000 + assert_eq!(tier, 0); + assert_eq!(fee, 50_000_000); + assert_eq!(net, 950_000_000); + } + + // ---------------------------------------------------------------- + // send() — amount validation guards + // ---------------------------------------------------------------- + + #[test] + #[should_panic(expected = "Amount must be positive")] + fn send_rejects_zero_amount() { + let env = Env::default(); + env.mock_all_auths(); + + let registry_id = env.register_contract(None, mock_registry::MockRegistry); + let gate_id = env.register_contract(None, PaymentGate); + let client = PaymentGateClient::new(&env, &gate_id); + client.initialize(®istry_id); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let collector = Address::generate(&env); + let token = Address::generate(&env); // placeholder; not called for amount=0 + client.send(&sender, &recipient, &token, &0i128, &collector); + } + + #[test] + #[should_panic(expected = "Amount exceeds tier limit")] + fn send_rejects_amount_over_tier_limit() { + let env = Env::default(); + env.mock_all_auths(); + + // Silver limit = 100_000 XLM = 1_000_000_000_000_000 stroops + let registry_id = env.register_contract(None, mock_registry::MockRegistry); + let gate_id = env.register_contract(None, PaymentGate); + let client = PaymentGateClient::new(&env, &gate_id); + client.initialize(®istry_id); + + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let collector = Address::generate(&env); + let token = Address::generate(&env); + + // Silver max = 100_000 * 10_000_000 stroops; send one stroop over + let over_limit: i128 = 100_000 * 10_000_000 + 1; + client.send(&sender, &recipient, &token, &over_limit, &collector); + } } diff --git a/contracts/reputation_registry/src/lib.rs b/contracts/reputation_registry/src/lib.rs index 5bd35bd..843e862 100644 --- a/contracts/reputation_registry/src/lib.rs +++ b/contracts/reputation_registry/src/lib.rs @@ -40,6 +40,23 @@ impl ReputationRegistry { ) { caller.require_auth(); + // Validate byte lengths before making the cross-contract call. + // The verifier expects BytesN<64/128/64/32>; a mismatch would cause an + // unhelpful panic deep inside the host. Failing fast here gives callers + // a clear error and saves the gas of a doomed cross-contract call. + if proof_a.len() != 64 { + panic!("proof_a must be 64 bytes (G1 point)"); + } + if proof_b.len() != 128 { + panic!("proof_b must be 128 bytes (G2 point)"); + } + if proof_c.len() != 64 { + panic!("proof_c must be 64 bytes (G1 point)"); + } + if commitment.len() != 32 { + panic!("commitment must be 32 bytes (scalar field element)"); + } + // Encode threshold as 32-byte big-endian field element let threshold_bytes = Bytes::from_slice(&env, &{ let mut b = [0u8; 32]; @@ -248,6 +265,59 @@ mod tests { // Threshold → tier mapping logic (tested through score boundary values) // ---------------------------------------------------------------- + // ---------------------------------------------------------------- + // Byte-length validation in submit_proof + // ---------------------------------------------------------------- + + #[test] + #[should_panic(expected = "proof_a must be 64 bytes")] + fn submit_proof_rejects_short_proof_a() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, &cid); + let dummy_verifier = Address::generate(&env); + client.initialize(&dummy_verifier); + let wallet = Address::generate(&env); + let bad_proof = soroban_sdk::Bytes::from_slice(&env, &[0u8; 32]); // too short + let zero64 = soroban_sdk::Bytes::from_slice(&env, &[0u8; 64]); + let zero128 = soroban_sdk::Bytes::from_slice(&env, &[0u8; 128]); + let zero32 = soroban_sdk::Bytes::from_slice(&env, &[0u8; 32]); + client.submit_proof(&wallet, &85u32, &bad_proof, &zero128, &zero64, &zero32); + } + + #[test] + #[should_panic(expected = "proof_b must be 128 bytes")] + fn submit_proof_rejects_short_proof_b() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, &cid); + let dummy_verifier = Address::generate(&env); + client.initialize(&dummy_verifier); + let wallet = Address::generate(&env); + let zero64 = soroban_sdk::Bytes::from_slice(&env, &[0u8; 64]); + let bad_proof = soroban_sdk::Bytes::from_slice(&env, &[0u8; 64]); // should be 128 + let zero32 = soroban_sdk::Bytes::from_slice(&env, &[0u8; 32]); + client.submit_proof(&wallet, &85u32, &zero64, &bad_proof, &zero64, &zero32); + } + + #[test] + #[should_panic(expected = "commitment must be 32 bytes")] + fn submit_proof_rejects_bad_commitment_length() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, &cid); + let dummy_verifier = Address::generate(&env); + client.initialize(&dummy_verifier); + let wallet = Address::generate(&env); + let zero64 = soroban_sdk::Bytes::from_slice(&env, &[0u8; 64]); + let zero128 = soroban_sdk::Bytes::from_slice(&env, &[0u8; 128]); + let bad_commitment = soroban_sdk::Bytes::from_slice(&env, &[0u8; 16]); // too short + client.submit_proof(&wallet, &85u32, &zero64, &zero128, &zero64, &bad_commitment); + } + /// Verify that threshold 39 would be rejected (below Bronze minimum). /// We test this by calling submit_proof with a dummy verifier; the panic /// happens BEFORE calling the verifier when threshold < 40. @@ -281,4 +351,143 @@ mod tests { &zero32, ); } + + // ---------------------------------------------------------------- + // submit_proof success path — mock verifier that always returns true + // ---------------------------------------------------------------- + + mod mock_verifier { + use soroban_sdk::{ + contract, contractimpl, BytesN, Env, Vec, + }; + + /// Stub Groth16 verifier that always approves any proof. + /// Used to exercise the submit_proof success path without needing + /// a real Groth16 proof. + #[contract] + pub struct AlwaysTrueVerifier; + + #[contractimpl] + impl AlwaysTrueVerifier { + pub fn verify( + _env: Env, + _proof_a: BytesN<64>, + _proof_b: BytesN<128>, + _proof_c: BytesN<64>, + _public_inputs: Vec>, + ) -> bool { + true + } + } + } + + /// Helper to build correct-length proof bytes for submit_proof calls. + fn make_proof_bytes(env: &Env) -> ( + soroban_sdk::Bytes, + soroban_sdk::Bytes, + soroban_sdk::Bytes, + soroban_sdk::Bytes, + ) { + ( + soroban_sdk::Bytes::from_slice(env, &[0u8; 64]), // proof_a + soroban_sdk::Bytes::from_slice(env, &[0u8; 128]), // proof_b + soroban_sdk::Bytes::from_slice(env, &[0u8; 64]), // proof_c + soroban_sdk::Bytes::from_slice(env, &[0u8; 32]), // commitment + ) + } + + #[test] + fn submit_proof_gold_sets_gold_tier() { + let env = Env::default(); + env.mock_all_auths(); + + let verifier_id = env.register_contract(None, mock_verifier::AlwaysTrueVerifier); + let registry_id = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, ®istry_id); + client.initialize(&verifier_id); + + let wallet = Address::generate(&env); + let (pa, pb, pc, cm) = make_proof_bytes(&env); + client.submit_proof(&wallet, &85u32, &pa, &pb, &pc, &cm); + + assert_eq!(client.get_tier(&wallet), TIER_GOLD); + } + + #[test] + fn submit_proof_silver_sets_silver_tier() { + let env = Env::default(); + env.mock_all_auths(); + + let verifier_id = env.register_contract(None, mock_verifier::AlwaysTrueVerifier); + let registry_id = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, ®istry_id); + client.initialize(&verifier_id); + + let wallet = Address::generate(&env); + let (pa, pb, pc, cm) = make_proof_bytes(&env); + client.submit_proof(&wallet, &70u32, &pa, &pb, &pc, &cm); + + assert_eq!(client.get_tier(&wallet), TIER_SILVER); + } + + #[test] + fn submit_proof_bronze_sets_bronze_tier() { + let env = Env::default(); + env.mock_all_auths(); + + let verifier_id = env.register_contract(None, mock_verifier::AlwaysTrueVerifier); + let registry_id = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, ®istry_id); + client.initialize(&verifier_id); + + let wallet = Address::generate(&env); + let (pa, pb, pc, cm) = make_proof_bytes(&env); + client.submit_proof(&wallet, &40u32, &pa, &pb, &pc, &cm); + + assert_eq!(client.get_tier(&wallet), TIER_BRONZE); + } + + #[test] + fn submit_proof_upgrade_allowed() { + let env = Env::default(); + env.mock_all_auths(); + + let verifier_id = env.register_contract(None, mock_verifier::AlwaysTrueVerifier); + let registry_id = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, ®istry_id); + client.initialize(&verifier_id); + + let wallet = Address::generate(&env); + let (pa, pb, pc, cm) = make_proof_bytes(&env); + + // First submit: Bronze + client.submit_proof(&wallet, &40u32, &pa, &pb, &pc, &cm); + assert_eq!(client.get_tier(&wallet), TIER_BRONZE); + + // Upgrade to Gold + client.submit_proof(&wallet, &85u32, &pa, &pb, &pc, &cm); + assert_eq!(client.get_tier(&wallet), TIER_GOLD); + } + + #[test] + fn submit_proof_downgrade_not_allowed() { + let env = Env::default(); + env.mock_all_auths(); + + let verifier_id = env.register_contract(None, mock_verifier::AlwaysTrueVerifier); + let registry_id = env.register_contract(None, ReputationRegistry); + let client = ReputationRegistryClient::new(&env, ®istry_id); + client.initialize(&verifier_id); + + let wallet = Address::generate(&env); + let (pa, pb, pc, cm) = make_proof_bytes(&env); + + // First submit: Gold + client.submit_proof(&wallet, &85u32, &pa, &pb, &pc, &cm); + assert_eq!(client.get_tier(&wallet), TIER_GOLD); + + // Attempt downgrade to Bronze — tier must stay Gold + client.submit_proof(&wallet, &40u32, &pa, &pb, &pc, &cm); + assert_eq!(client.get_tier(&wallet), TIER_GOLD, "Downgrade must be silently ignored"); + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bdb81a7..7950328 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -123,7 +123,7 @@ export default function App() { diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 0d7766e..385217b 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -10,9 +10,43 @@ interface State { error: Error | null; } +// --------------------------------------------------------------------------- +// Lightweight structured error reporter. +// Sends a JSON payload to the configured endpoint when VITE_ERROR_ENDPOINT is +// set. Falls back to console.error only (safe for local dev / hackathon use). +// --------------------------------------------------------------------------- +function reportError(error: Error, componentStack: string | null | undefined): void { + const endpoint = + typeof import.meta !== "undefined" + ? (import.meta.env as Record)["VITE_ERROR_ENDPOINT"] + : undefined; + + // Always log to console so errors are visible in DevTools + console.error("[Nullius] Unhandled render error:", error.message, componentStack); + + if (!endpoint) return; + + // Best-effort fire-and-forget — do not await or throw + try { + const body = JSON.stringify({ + message: error.message, + stack: error.stack, + component: componentStack, + url: window.location.href, + ts: new Date().toISOString(), + }); + navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" })); + } catch { + // Swallow — reporting must never crash the app + } +} + /** * Catches unhandled render errors in child components and shows a * friendly recovery UI instead of a blank screen. + * + * Set VITE_ERROR_ENDPOINT in .env to enable remote error reporting via + * navigator.sendBeacon (e.g. a Sentry ingest URL or a custom endpoint). */ export class ErrorBoundary extends Component { constructor(props: Props) { @@ -25,8 +59,7 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, info: ErrorInfo) { - // In production this could be wired to a logging service - console.error("[Nullius] Unhandled render error:", error, info.componentStack); + reportError(error, info.componentStack); } reset = () => { diff --git a/frontend/src/components/PaymentWidget.tsx b/frontend/src/components/PaymentWidget.tsx index 45de4d9..a47901b 100644 --- a/frontend/src/components/PaymentWidget.tsx +++ b/frontend/src/components/PaymentWidget.tsx @@ -21,6 +21,7 @@ export function PaymentWidget({ walletAddress, currentTier }: Props) { const [recipient, setRecipient] = useState(""); const [amount, setAmount] = useState(""); const [quote, setQuote] = useState(null); + const [txLimit, setTxLimit] = useState(null); const [quoting, setQuoting] = useState(false); const [sending, setSending] = useState(false); const [txHash, setTxHash] = useState(null); @@ -30,6 +31,14 @@ export function PaymentWidget({ walletAddress, currentTier }: Props) { const amountNum = parseFloat(amount); const amountValid = !amount || (amountNum > 0 && isFinite(amountNum)); + // Fetch per-tx limit on mount and when tier changes + useEffect(() => { + const client = new NulliusClient(); + client.getLimit(walletAddress) + .then((lim) => setTxLimit(lim)) + .catch(() => setTxLimit(null)); + }, [walletAddress, currentTier]); + // Debounced quote fetch useEffect(() => { if (!amount || parseFloat(amount) <= 0 || !isValidStellarAddress(recipient)) { @@ -60,8 +69,17 @@ export function PaymentWidget({ walletAddress, currentTier }: Props) { const client = new NulliusClient(); const stroops = BigInt(Math.round(parseFloat(amount) * 10_000_000)); - // Native XLM token address on Stellar testnet - const NATIVE_TOKEN = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + // Native XLM token address — configurable via VITE_NATIVE_TOKEN env var. + // Falls back to the testnet wrapped-XLM address if not set. + const NATIVE_TOKEN = + (typeof import.meta !== "undefined" && (import.meta.env as Record)["VITE_NATIVE_TOKEN"]) || + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + // Fee collector address — configurable via VITE_FEE_COLLECTOR env var. + // Defaults to the sender in demo mode (fees are circular / effectively zero). + const feeCollector = + (typeof import.meta !== "undefined" && (import.meta.env as Record)["VITE_FEE_COLLECTOR"]) || + walletAddress; // Build tx → sign via Freighter → submit const unsignedXdr = await client.buildSendTransaction( @@ -69,7 +87,7 @@ export function PaymentWidget({ walletAddress, currentTier }: Props) { recipient, NATIVE_TOKEN, stroops, - walletAddress // fee goes back to sender in demo; replace with treasury address + feeCollector ); const signResult = await signTransaction(unsignedXdr, { @@ -101,6 +119,9 @@ export function PaymentWidget({ walletAddress, currentTier }: Props) {

Your reputation tier ({TIER_LABELS[currentTier]}) determines your fee rate and payment limits. + {txLimit !== null && ( + <> Max per transaction: {(Number(txLimit) / 10_000_000).toLocaleString()} XLM. + )}

diff --git a/frontend/src/components/ProofGenerator.tsx b/frontend/src/components/ProofGenerator.tsx index 555d720..3c9193d 100644 --- a/frontend/src/components/ProofGenerator.tsx +++ b/frontend/src/components/ProofGenerator.tsx @@ -8,14 +8,9 @@ import { import type { PrivateInputs, ProofBundle, Tier } from "@nullius/sdk"; import { TransactionBuilder, - BASE_FEE, Networks, - Contract, - nativeToScVal, - xdr, } from "@stellar/stellar-sdk"; import { signTransaction } from "@stellar/freighter-api"; -import { CONTRACT_IDS } from "@nullius/sdk"; import { recordProof } from "./ProofHistory"; interface Props { @@ -23,39 +18,6 @@ interface Props { onProofVerified: (bundle: ProofBundle, tier: Tier) => void; } -// ---------------------------------------------------------------- -// Proof encoding helpers (mirror sdk/src/contracts.ts) -// ---------------------------------------------------------------- -function fieldToBytes32(dec: string): Uint8Array { - let val = BigInt(dec); - const buf = new Uint8Array(32); - for (let i = 31; i >= 0; i--) { - buf[i] = Number(val & 0xffn); - val >>= 8n; - } - return buf; -} - -function encodeG1Bytes(point: [string, string, string]): Uint8Array { - const buf = new Uint8Array(64); - buf.set(fieldToBytes32(point[0]), 0); - buf.set(fieldToBytes32(point[1]), 32); - return buf; -} - -function encodeG2Bytes(point: [[string, string], [string, string], [string, string]]): Uint8Array { - const buf = new Uint8Array(128); - buf.set(fieldToBytes32(point[0][1]), 0); - buf.set(fieldToBytes32(point[0][0]), 32); - buf.set(fieldToBytes32(point[1][1]), 64); - buf.set(fieldToBytes32(point[1][0]), 96); - return buf; -} - -function encodeScalarBytes(dec: string): Uint8Array { - return fieldToBytes32(dec); -} - type Step = "input" | "generating" | "verifying" | "submitting" | "done" | "error"; const STEP_LABELS: Record = { @@ -95,51 +57,16 @@ export function ProofGenerator({ walletAddress, onProofVerified }: Props) { setStep("submitting"); const client = new NulliusClient(); - // Build the unsigned transaction, then sign via Freighter - const server = client.getServer(); - const account = await server.getAccount(walletAddress); - - const encodeBytes = (hex: string, len: number): Uint8Array => { - const val = BigInt(hex); - const buf = new Uint8Array(len); - for (let i = len - 1; i >= 0; i--) { - buf[i] = Number(val & 0xffn); - // val >>= 8n — rewritten to avoid BigInt assignment in strict mode - } - return buf; - }; - - const contract = new Contract(CONTRACT_IDS.reputationRegistry); - const proofABytes = encodeG1Bytes(bundle.proof.pi_a); - const proofBBytes = encodeG2Bytes(bundle.proof.pi_b); - const proofCBytes = encodeG1Bytes(bundle.proof.pi_c); - const commitmentBytes = encodeScalarBytes(bundle.publicSignals.commitment); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: Networks.TESTNET, - }) - .addOperation( - contract.call( - "submit_proof", - nativeToScVal(walletAddress, { type: "address" }), - nativeToScVal(bundle.threshold, { type: "u32" }), - xdr.ScVal.scvBytes(proofABytes as unknown as Buffer), - xdr.ScVal.scvBytes(proofBBytes as unknown as Buffer), - xdr.ScVal.scvBytes(proofCBytes as unknown as Buffer), - xdr.ScVal.scvBytes(commitmentBytes as unknown as Buffer), - ) - ) - .setTimeout(30) - .build(); - - const prepared = await server.prepareTransaction(tx); - const signResult = await signTransaction(prepared.toXDR(), { + // Build the unsigned transaction via SDK, then sign with Freighter + const unsignedXdr = await client.buildSubmitProofTransaction(walletAddress, bundle); + const signResult = await signTransaction(unsignedXdr, { networkPassphrase: Networks.TESTNET, }); // freighter-api v2 returns string directly; v1 returned { signedTxXdr } const signedTxXdr = typeof signResult === "string" ? signResult : (signResult as any).signedTxXdr; - const result = await server.sendTransaction( + + const server = client.getServer(); + const result = await server.sendTransaction( TransactionBuilder.fromXDR(signedTxXdr, Networks.TESTNET) ); @@ -161,77 +88,107 @@ export function ProofGenerator({ walletAddress, onProofVerified }: Props) { const tierColors = ["#94a3b8", "#cd7f32", "#9ca3af", "#f59e0b"]; const tierNames = ["Unverified", "Bronze", "Silver", "Gold"]; - // Score estimate for live preview + // Score estimate for live preview — mirrors circuit formula const txCapped = Math.min(inputs.txCount, 50); const ageCapped = Math.min(inputs.monthsActive, 12); + const balCapped = Math.min(inputs.avgBalance, 10000); const cleanTxs = Math.max(0, inputs.txCount - inputs.disputeCount); - const proxy = txCapped * 480 + cleanTxs * 480 + ageCapped * 1000; - const score = Math.min(100, Math.round(proxy / 600)); + const proxy = txCapped * 480 + cleanTxs * 480 + ageCapped * 1000 + balCapped; + const score = Math.min(100, Math.round(proxy / 700)); const estimatedTier = score >= 85 ? 3 : score >= 70 ? 2 : score >= 40 ? 1 : 0; return (
-

Generate Reputation Proof

+

Generate Reputation Proof

Your inputs are processed entirely in your browser using zero-knowledge cryptography. None of this data is sent to any server.

{step !== "input" && step !== "error" && ( -
-
+
+ )} {(step === "input" || step === "error") && ( <> -
+
- + handleChange("txCount", e.target.value)} placeholder="e.g. 42" + aria-describedby="score-preview-hint" />
- + handleChange("disputeCount", e.target.value)} placeholder="e.g. 1" + aria-describedby="score-preview-hint" />
- + handleChange("avgBalance", e.target.value)} placeholder="e.g. 500" + aria-describedby="score-preview-hint" />
- + handleChange("monthsActive", e.target.value)} placeholder="e.g. 8" + aria-describedby="score-preview-hint" />
{/* Live score preview */} -
-
+
+
@@ -239,21 +196,27 @@ export function ProofGenerator({ walletAddress, onProofVerified }: Props) { {tierNames[estimatedTier]}
-

+

This estimate is never sent anywhere. The ZK proof will confirm it mathematically.

- {error &&
{error}
} + {error && ( +
+ {error} +
+ )} +
    = { - 0: "#64748b", - 1: "#b45309", - 2: "#6b7280", - 3: "#d97706", -}; - const TIER_BENEFITS: Record = { 0: ["Basic access only", "5.0% transaction fee", "Max 1,000 XLM/tx"], 1: ["Bronze tier verified", "2.0% transaction fee", "Max 10,000 XLM/tx"], diff --git a/frontend/src/hooks/useFreighter.ts b/frontend/src/hooks/useFreighter.ts index 1a1626d..9ccb9fd 100644 --- a/frontend/src/hooks/useFreighter.ts +++ b/frontend/src/hooks/useFreighter.ts @@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from "react"; import { isConnected, getPublicKey, - signTransaction, } from "@stellar/freighter-api"; interface FreighterState { @@ -51,12 +50,9 @@ export function useFreighter() { } }, []); - const sign = useCallback(async (xdr: string) => { - const result = await (signTransaction(xdr, { - networkPassphrase: "Test SDF Network ; September 2015", - }) as unknown as Promise); - return typeof result === "string" ? result : result.signedTxXdr; - }, []); + // Note: individual components import signTransaction from @stellar/freighter-api + // directly rather than going through this hook, because each signing call needs + // a different networkPassphrase context. The hook handles connection state only. - return { ...state, connect, sign }; + return { ...state, connect }; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5dde317..37394d1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -21,7 +21,11 @@ export default defineConfig({ }, server: { headers: { + // Both headers are required to enable SharedArrayBuffer, which snarkjs + // uses for multi-threaded WASM proof generation. Without COEP, browsers + // silently disable SAB and fall back to single-threaded mode (~3× slower). "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", }, }, build: { diff --git a/package.json b/package.json index b25a990..b474964 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,13 @@ "vk:patch": "node scripts/patch_verifier.js", "vk:all": "npm run vk:extract && npm run vk:patch", "contracts:build": "cargo build --release --target wasm32v1-none", - "contracts:test": "cargo test", + "contracts:test": "cargo test --all", + "contracts:lint": "cargo clippy --all-targets -- -D warnings", + "contracts:fmt": "cargo fmt --all", + "contracts:fmt:check": "cargo fmt --all -- --check", "deploy:testnet": "node scripts/deploy.js", "test:e2e": "node scripts/e2e_test.js", + "test:all": "npm run contracts:test && npm run test:e2e", "setup:all": "npm install && npm run circuit:compile && npm run circuit:setup && npm run vk:all && npm run contracts:build && npm run deploy:testnet" }, "dependencies": { diff --git a/scripts/deploy.js b/scripts/deploy.js index 772ea03..9c84447 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -13,7 +13,7 @@ * Prerequisites: * stellar keys generate deployer --network testnet * stellar keys fund deployer --network testnet - * cargo build --target wasm32-unknown-unknown --release + * npm run contracts:build (cargo build --target wasm32v1-none --release) * * Usage: * node scripts/deploy.js @@ -32,27 +32,68 @@ const CONTRACTS_OUT = path.join(__dirname, "../.contract_addresses.json"); // ---------------------------------------------------------------- // Helpers // ---------------------------------------------------------------- -function run(cmd, label) { + +/** + * Execute a shell command, retrying up to `retries` times on transient + * RPC/network errors. Non-network errors exit immediately. + */ +function run(cmd, label, retries = 2) { console.log(`\n→ ${label}`); console.log(` $ ${cmd}`); - try { - const out = execSync(cmd, { encoding: "utf8" }).trim(); - console.log(` ${out}`); - return out; - } catch (e) { - console.error(`\nERROR during: ${label}`); - console.error(e.stderr || e.message); - process.exit(1); + + const TRANSIENT_PATTERNS = [ + /connection refused/i, + /timeout/i, + /ECONNRESET/i, + /network error/i, + /429/, // rate limited + /503/, // service unavailable + ]; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const out = execSync(cmd, { encoding: "utf8" }).trim(); + console.log(` ${out}`); + return out; + } catch (e) { + const msg = (e.stderr || e.message || "").toString(); + const isTransient = TRANSIENT_PATTERNS.some((p) => p.test(msg)); + + if (isTransient && attempt < retries) { + const waitMs = 3000 * (attempt + 1); + console.warn(` ⚠ Transient error (attempt ${attempt + 1}/${retries + 1}), retrying in ${waitMs / 1000}s...`); + // Synchronous sleep via a tight loop (deploy script is already blocking) + const end = Date.now() + waitMs; + while (Date.now() < end) { /* spin */ } + continue; + } + + console.error(`\nERROR during: ${label}`); + console.error(msg); + process.exit(1); + } } } function deployContract(name, wasmFile) { const wasmPath = path.join(WASM_DIR, wasmFile); - if (!require("fs").existsSync(wasmPath)) { + const fsSafe = require("fs"); + + if (!fsSafe.existsSync(wasmPath)) { console.error(`WASM not found: ${wasmPath}`); - console.error("Run: cargo build --target wasm32-unknown-unknown --release"); + console.error("Run: cargo build --target wasm32v1-none --release"); + process.exit(1); + } + + // Sanity-check: a valid WASM file is always at least a few KB. + // Zero-byte or tiny files indicate a failed build silently succeeded. + const { size } = fsSafe.statSync(wasmPath); + if (size < 1024) { + console.error(`WASM file is suspiciously small (${size} bytes): ${wasmPath}`); + console.error("This usually means the build failed silently. Re-run: npm run contracts:build"); process.exit(1); } + console.log(` WASM size: ${(size / 1024).toFixed(1)} KB`); const contractId = run( `stellar contract deploy \ diff --git a/scripts/e2e_test.js b/scripts/e2e_test.js index a1fc0c6..2e17994 100644 --- a/scripts/e2e_test.js +++ b/scripts/e2e_test.js @@ -58,6 +58,13 @@ const SECRET = process.env.SECRET_KEY; const keypair = SECRET ? Keypair.fromSecret(SECRET) : Keypair.random(); console.log("Test wallet:", keypair.publicKey()); +if (!SECRET) { + console.warn("\n⚠ No SECRET_KEY set — using a random unfunded keypair."); + console.warn(" Steps 1-2 (proof generation + local verify) will run."); + console.warn(" Steps 3-5 (on-chain) will be skipped automatically."); + console.warn(" To run the full test: export SECRET_KEY=\n"); +} + // ---------------------------------------------------------------- // Helper: BigInt field element → 32-byte BE buffer // ---------------------------------------------------------------- @@ -166,8 +173,23 @@ async function submitToStellar(proof, publicSignals, inputs) { console.log("\n[3/5] Submitting proof to Stellar testnet..."); if (!proof) { console.log(" Skipped (mock mode)"); return; } + if (!SECRET) { console.log(" Skipped (no SECRET_KEY — unfunded keypair)"); return null; } + + const server = new SorobanRpc.Server(RPC_URL); + + // Verify the account exists and is funded before attempting the transaction + try { + await server.getAccount(keypair.publicKey()); + } catch (e) { + if (e?.response?.status === 404 || String(e).includes("404")) { + console.error(`\n Account not found on testnet: ${keypair.publicKey()}`); + console.error(" Fund it first: stellar keys fund deployer --network testnet"); + console.error(" Or use the Stellar friendbot: https://friendbot.stellar.org/?addr=" + keypair.publicKey()); + process.exit(1); + } + throw e; + } - const server = new SorobanRpc.Server(RPC_URL); const account = await server.getAccount(keypair.publicKey()); const proofABytes = encodeG1(proof.pi_a); @@ -223,6 +245,7 @@ async function submitToStellar(proof, publicSignals, inputs) { // ---------------------------------------------------------------- async function assertTier() { console.log("\n[4/5] Reading tier from registry..."); + if (!SECRET) { console.log(" Skipped (no SECRET_KEY)"); return null; } const server = new SorobanRpc.Server(RPC_URL); const account = await server.getAccount(keypair.publicKey()); @@ -261,6 +284,7 @@ async function assertTier() { // ---------------------------------------------------------------- async function assertQuote(tier) { console.log("\n[5/5] Checking payment gate quote..."); + if (!SECRET || tier === null) { console.log(" Skipped (no SECRET_KEY)"); return; } const server = new SorobanRpc.Server(RPC_URL); const account = await server.getAccount(keypair.publicKey()); @@ -314,7 +338,12 @@ async function assertQuote(tier) { const tier = await assertTier(); await assertQuote(tier); - console.log("\n✓ All tests passed. Nullius is working end-to-end on Stellar testnet."); + if (!SECRET) { + console.log("\n✓ Offline steps passed (proof generation + local verification)."); + console.log(" Set SECRET_KEY to a funded testnet account to run on-chain steps."); + } else { + console.log("\n✓ All tests passed. Nullius is working end-to-end on Stellar testnet."); + } } catch (err) { console.error("\n✗ Test failed:", err.message); process.exit(1); diff --git a/sdk/package.json b/sdk/package.json index 34c6bdd..47416e1 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -8,9 +8,9 @@ "dev": "tsc --watch" }, "dependencies": { - "@stellar/stellar-sdk": "^12.0.0", - "circomlibjs": "^0.1.7", - "snarkjs": "^0.7.0" + "@stellar/stellar-sdk": "12.3.0", + "circomlibjs": "0.1.7", + "snarkjs": "0.7.6" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/sdk/src/contracts.ts b/sdk/src/contracts.ts index 8c841cd..9b7c43f 100644 --- a/sdk/src/contracts.ts +++ b/sdk/src/contracts.ts @@ -81,43 +81,51 @@ function requirePositiveAmount(amount: bigint, label = "amount"): void { } // ---------------------------------------------------------------- -// Proof encoding helpers +// Proof encoding helpers (exported for use by frontend and scripts) // ---------------------------------------------------------------- -function encodeG1(point: [string, string, string]): Uint8Array { - const buf = new Uint8Array(64); - const x = BigInt(point[0]); - const y = BigInt(point[1]); +/** + * Encode a big-endian 32-byte representation of a decimal field element. + * Used for both scalars and as a building block for G1/G2 point encoding. + */ +export function encodeScalar(dec: string): Uint8Array { + const buf = new Uint8Array(32); + const val = BigInt(dec); for (let i = 0; i < 32; i++) { - buf[31 - i] = Number((x >> BigInt(i * 8)) & 0xffn); - buf[63 - i] = Number((y >> BigInt(i * 8)) & 0xffn); + buf[31 - i] = Number((val >> BigInt(i * 8)) & 0xffn); } return buf; } -function encodeG2( +/** + * Encode a BN254 G1 affine point as 64 bytes (x || y, each 32 bytes big-endian). + * Matches the byte layout expected by Stellar's BN254 host functions. + */ +export function encodeG1(point: [string, string, string]): Uint8Array { + const buf = new Uint8Array(64); + buf.set(encodeScalar(point[0]), 0); + buf.set(encodeScalar(point[1]), 32); + return buf; +} + +/** + * Encode a BN254 G2 affine point as 128 bytes. + * Stellar BN254 expects c1 before c0 for each coordinate pair: + * x_c1 || x_c0 || y_c1 || y_c0 (128 bytes total) + */ +export function encodeG2( point: [[string, string], [string, string], [string, string]] ): Uint8Array { + // Stellar BN254 expects c1 before c0 for each coordinate pair. + // Order: x_c1 || x_c0 || y_c1 || y_c0 (128 bytes total) const buf = new Uint8Array(128); - const coords = [point[0][0], point[0][1], point[1][0], point[1][1]]; + const coords = [point[0][1], point[0][0], point[1][1], point[1][0]]; coords.forEach((dec, idx) => { - const val = BigInt(dec); - for (let i = 0; i < 32; i++) { - buf[idx * 32 + 31 - i] = Number((val >> BigInt(i * 8)) & 0xffn); - } + buf.set(encodeScalar(dec), idx * 32); }); return buf; } -function encodeScalar(dec: string): Uint8Array { - const buf = new Uint8Array(32); - const val = BigInt(dec); - for (let i = 0; i < 32; i++) { - buf[31 - i] = Number((val >> BigInt(i * 8)) & 0xffn); - } - return buf; -} - // ---------------------------------------------------------------- // Nullius Contract Client // ---------------------------------------------------------------- @@ -133,6 +141,49 @@ export class NulliusClient { return this.server; } + /** + * Build an unsigned XDR transaction to submit a reputation proof via Freighter. + * Returns the unsigned XDR string — caller signs it and submits via server.sendTransaction(). + * + * This is the preferred path for browser-based proof submission where the + * private key is held by Freighter and never exposed to the SDK. + */ + async buildSubmitProofTransaction( + walletAddress: string, + bundle: ProofBundle + ): Promise { + requireValidAddress(walletAddress, "wallet address"); + + const account = await this.server.getAccount(walletAddress); + + const proofABytes = encodeG1(bundle.proof.pi_a); + const proofBBytes = encodeG2(bundle.proof.pi_b); + const proofCBytes = encodeG1(bundle.proof.pi_c); + const commitmentBytes = encodeScalar(bundle.publicSignals.commitment); + + const contract = new Contract(CONTRACT_IDS.reputationRegistry); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + contract.call( + "submit_proof", + nativeToScVal(walletAddress, { type: "address" }), + nativeToScVal(bundle.threshold, { type: "u32" }), + xdr.ScVal.scvBytes(proofABytes as unknown as Buffer), + xdr.ScVal.scvBytes(proofBBytes as unknown as Buffer), + xdr.ScVal.scvBytes(proofCBytes as unknown as Buffer), + xdr.ScVal.scvBytes(commitmentBytes as unknown as Buffer), + ) + ) + .setTimeout(30) + .build(); + + const prepared = await this.server.prepareTransaction(tx); + return prepared.toXDR(); + } + /** Submit a reputation proof to the registry contract using a keypair. */ async submitProof( keypair: Keypair, @@ -243,6 +294,32 @@ export class NulliusClient { throw new Error("Failed to get quote"); } + /** Get the maximum per-transaction payment limit for a wallet. */ + async getLimit(walletAddress: string): Promise { + requireValidAddress(walletAddress, "wallet address"); + const contract = new Contract(CONTRACT_IDS.paymentGate); + const account = await this.server.getAccount(walletAddress); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + contract.call( + "limit", + nativeToScVal(walletAddress, { type: "address" }) + ) + ) + .setTimeout(30) + .build(); + + const result = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationSuccess(result)) { + return scValToNative(result.result!.retval) as bigint; + } + throw new Error("Failed to get payment limit"); + } + /** * Build an unsigned XDR transaction for a token send via the payment gate. * The caller signs it with Freighter and submits via server.sendTransaction(). @@ -283,15 +360,28 @@ export class NulliusClient { return prepared.toXDR(); } - private async waitForConfirmation(txHash: string, maxAttempts = 20): Promise { - for (let i = 0; i < maxAttempts; i++) { - await new Promise((r) => setTimeout(r, 1500)); + /** + * Poll for transaction confirmation with exponential backoff. + * Starts at 1 s, doubles each attempt (capped at 8 s), gives up after + * maxWaitMs total elapsed time (default 30 s). + */ + private async waitForConfirmation( + txHash: string, + maxWaitMs = 30_000 + ): Promise { + const start = Date.now(); + let delayMs = 1_000; + + while (Date.now() - start < maxWaitMs) { + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 8_000); // cap at 8 s + const status = await this.server.getTransaction(txHash); if (status.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) return; if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { throw new Error(`Transaction failed on-chain: ${txHash}`); } } - throw new Error("Transaction confirmation timeout"); + throw new Error(`Transaction confirmation timeout after ${maxWaitMs / 1000}s: ${txHash}`); } } diff --git a/sdk/src/prover.ts b/sdk/src/prover.ts index 8f22c46..7262f10 100644 --- a/sdk/src/prover.ts +++ b/sdk/src/prover.ts @@ -46,14 +46,17 @@ async function computeCommitment(inputs: PrivateInputs): Promise { */ function selectThreshold(inputs: PrivateInputs): number { // Estimate score proxy: same formula as circuit (no division) - const txCapped = Math.min(inputs.txCount, 50); + const txCapped = Math.min(inputs.txCount, 50); const ageCapped = Math.min(inputs.monthsActive, 12); - const cleanTxs = inputs.txCount - inputs.disputeCount; - const scoreProxy = txCapped * 480 + cleanTxs * 480 + ageCapped * 1000; + const balCapped = Math.min(inputs.avgBalance, 10000); + const cleanTxs = inputs.txCount - inputs.disputeCount; + // score_proxy = txCapped*480 + cleanTxs*480 + ageCapped*1000 + balCapped + // threshold_scaled = threshold * 700 + const scoreProxy = txCapped * 480 + cleanTxs * 480 + ageCapped * 1000 + balCapped; - if (scoreProxy >= TIER_THRESHOLDS.gold * 600) return TIER_THRESHOLDS.gold; - if (scoreProxy >= TIER_THRESHOLDS.silver * 600) return TIER_THRESHOLDS.silver; - if (scoreProxy >= TIER_THRESHOLDS.bronze * 600) return TIER_THRESHOLDS.bronze; + if (scoreProxy >= TIER_THRESHOLDS.gold * 700) return TIER_THRESHOLDS.gold; + if (scoreProxy >= TIER_THRESHOLDS.silver * 700) return TIER_THRESHOLDS.silver; + if (scoreProxy >= TIER_THRESHOLDS.bronze * 700) return TIER_THRESHOLDS.bronze; throw new Error("Score too low for any tier (minimum Bronze threshold is 40)"); } @@ -65,6 +68,21 @@ function selectThreshold(inputs: PrivateInputs): number { * * @param inputs Private financial data * @returns ProofBundle ready to submit to the reputation registry contract + * + * NOTE on public signal ordering: + * snarkjs outputs signals as [, ] in declaration order: + * publicSignals[0] = meets_threshold (circuit output) + * publicSignals[1] = threshold (public input) + * publicSignals[2] = commitment (public input) + * + * The on-chain verifier / registry expects public_inputs in a different order: + * public_inputs[0] = threshold + * public_inputs[1] = commitment + * public_inputs[2] = meets_threshold + * + * The ProofBundle.publicSignals struct stores values by name, not index, so + * the encoding helpers in contracts.ts always submit them in the correct + * on-chain order regardless of the snarkjs output order. */ export async function generateReputationProof( inputs: PrivateInputs diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 16013fb..bba6095 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -11,6 +11,22 @@ export const TIER_LABELS: Record = { 3: "Gold", }; +/** + * Canonical hex colors for each tier — used by frontend components and any + * consumer that needs to display tier badges consistently. + * + * 0 Unverified — slate (#64748b) + * 1 Bronze — amber-brown (#b45309) + * 2 Silver — silver-slate (#94a3b8) — intentionally lighter than Unverified + * 3 Gold — amber (#d97706) + */ +export const TIER_COLORS: Record = { + 0: "#64748b", + 1: "#b45309", + 2: "#94a3b8", + 3: "#d97706", +}; + export const TIER_THRESHOLDS: Record = { bronze: 40, silver: 70,