From b8c1e50d57ea8b2d8d09cd04eaf73c25defeeca6 Mon Sep 17 00:00:00 2001 From: leojay Date: Sun, 29 Mar 2026 23:04:51 +0100 Subject: [PATCH] feat: implement issues #83, #89, #98, #108 - Add contracts/stableswap/ Soroban contract implementing the Stableswap invariant (Curve-style, n=2 tokens) using Newton-Raphson iteration for D - Implement initialize, add_liquidity, remove_liquidity, and swap functions - Dynamic fee adjustment: fee = base_fee + imbalance_ratio * fee_multiplier where imbalance_ratio = |x-y|/(x+y), increasing fee on imbalanced pools - Add compute_d and compute_y Newton-Raphson math helpers with overflow guards - 11 unit tests covering invariant correctness, symmetry, edge cases, fee logic, and initialization validation - Register in contracts Cargo.toml workspace - Add contracts/stealth_addresses/ Soroban contract implementing a stealth address registry and deposit routing - StealthMetaAddress: recipients publish view + spend public keys (32-byte Ed25519 keys) via register_meta_address - deposit: sender provides pre-computed one_time_address (derived off-chain via DH: s=H(r*Kv), P=s*G+Kse), ephemeral_public_key R, and view_tag byte; funds held in contract escrow - claim_deposit: only holder of one_time_address private key can claim funds, enforced by require_auth - 8 unit tests covering registration, key validation, deposit validation, and claim error handling - Register in contracts Cargo.toml workspace - Add .github/workflows/security.yml triggered on PR/push to main when contracts/ paths change - cargo-audit job: scans dependencies for known CVEs, parses JSON results, posts a formatted vulnerability table as a PR comment (upsert pattern) - clippy-security job: runs Clippy with -D clippy::unwrap_used, -D clippy::expect_used, -D clippy::panic, -D clippy::arithmetic_side_effects, -D clippy::indexing_slicing; posts lint violations table as PR comment - soroban-unsafe-patterns job: grep-based scan for unsafe blocks, use std:: in no_std contracts, and explicit panic! calls; posts findings as PR comment - All jobs upload artefacts and always-post PR comments (upsert to avoid spam) - Add client/src/auth/walletAdapters.ts: WalletAdapter interface with isAvailable, getPublicKey, signTransaction; implementations for Freighter (@stellar/freighter-api), xBull (@creit.tech/xbull-wallet-connect), Albedo (@albedo-link/intent); EXTENSION_ADAPTERS registry + getAdapter() - Extend WalletProviderId to include 'xbull' | 'albedo' (new ExtensionWalletProviderId union type) - Update session.ts: unified extension-wallet branch routes through adapter, removing per-wallet Freighter-specific SDK calls - Update WalletContextObject.ts: add signTransaction(xdr, passphrase) to WalletContextValue interface - Update WalletContext.tsx: implement signTransaction using the active provider's adapter; expose via context value - Update soroban.ts: executeContractCall, deposit, withdraw accept optional signTx parameter; callers can pass wallet.signTransaction from useWallet() so any wallet adapter works transparently - Update WalletConnectionModal.tsx: add xBull and Albedo buttons in new 'Browser Wallets' section alongside Freighter - Install @creit.tech/xbull-wallet-connect@0.4.0 and @albedo-link/intent@0.13.0 Closes #83, #89, #98, #108 --- .github/workflows/security.yml | 308 ++++++++ client/package-lock.json | 63 +- client/package.json | 2 + client/src/auth/session.ts | 33 +- client/src/auth/types.ts | 5 +- client/src/auth/walletAdapters.ts | 163 ++++ .../wallet/WalletConnectionModal.tsx | 101 ++- client/src/context/WalletContext.tsx | 23 +- client/src/context/WalletContextObject.ts | 6 + client/src/services/soroban.ts | 34 +- contracts/Cargo.lock | 46 +- contracts/Cargo.toml | 2 +- contracts/stableswap/Cargo.toml | 13 + contracts/stableswap/src/lib.rs | 742 ++++++++++++++++++ .../test_double_initialize_rejected.1.json | 219 ++++++ .../tests/test_initialize_invalid_amp.1.json | 76 ++ .../tests/test_initialize_too_high_fee.1.json | 76 ++ contracts/stealth_addresses/Cargo.toml | 13 + contracts/stealth_addresses/src/lib.rs | 487 ++++++++++++ ...st_claim_nonexistent_deposit_errors.1.json | 114 +++ ...st_deposit_bad_ephemeral_key_length.1.json | 114 +++ .../tests/test_deposit_id_increments.1.json | 114 +++ .../tests/test_deposit_invalid_amount.1.json | 114 +++ .../tests/test_double_init_rejected.1.json | 114 +++ ...get_nonexistent_meta_address_errors.1.json | 114 +++ .../test_register_and_get_meta_address.1.json | 245 ++++++ ..._register_wrong_key_length_rejected.1.json | 114 +++ 27 files changed, 3368 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 client/src/auth/walletAdapters.ts create mode 100644 contracts/stableswap/Cargo.toml create mode 100644 contracts/stableswap/src/lib.rs create mode 100644 contracts/stableswap/test_snapshots/tests/test_double_initialize_rejected.1.json create mode 100644 contracts/stableswap/test_snapshots/tests/test_initialize_invalid_amp.1.json create mode 100644 contracts/stableswap/test_snapshots/tests/test_initialize_too_high_fee.1.json create mode 100644 contracts/stealth_addresses/Cargo.toml create mode 100644 contracts/stealth_addresses/src/lib.rs create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_claim_nonexistent_deposit_errors.1.json create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_deposit_bad_ephemeral_key_length.1.json create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_deposit_id_increments.1.json create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_deposit_invalid_amount.1.json create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_double_init_rejected.1.json create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_get_nonexistent_meta_address_errors.1.json create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_register_and_get_meta_address.1.json create mode 100644 contracts/stealth_addresses/test_snapshots/tests/test_register_wrong_key_length_rejected.1.json diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000000..2e31bd38b5 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,308 @@ +name: Security Analysis + +on: + pull_request: + branches: + - main + paths: + - "contracts/**" + push: + branches: + - main + paths: + - "contracts/**" + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + cargo-audit: + name: Dependency Audit (cargo-audit) + runs-on: ubuntu-latest + defaults: + run: + working-directory: contracts + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install --locked cargo-audit + + - name: Run cargo-audit + id: audit + # --json lets us parse findings later; we also allow a non-zero exit so + # the step doesn't immediately fail โ€“ the PR-comment step handles it. + run: | + cargo audit --json 2>&1 | tee audit-results.json + echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Upload audit results + if: always() + uses: actions/upload-artifact@v4 + with: + name: cargo-audit-results + path: contracts/audit-results.json + retention-days: 30 + + - name: Comment audit results on PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let body = '## ๐Ÿ” Security Audit โ€” `cargo-audit`\n\n'; + try { + const raw = fs.readFileSync('contracts/audit-results.json', 'utf8'); + const report = JSON.parse(raw); + const vulns = report?.vulnerabilities?.list ?? []; + if (vulns.length === 0) { + body += 'โœ… No known vulnerabilities found in dependencies.\n'; + } else { + body += `โš ๏ธ **${vulns.length} vulnerabilit${vulns.length === 1 ? 'y' : 'ies'} found:**\n\n`; + body += '| Package | Version | Advisory | Severity | Description |\n'; + body += '|---------|---------|----------|----------|-------------|\n'; + for (const v of vulns) { + const pkg = v?.package?.name ?? 'unknown'; + const ver = v?.package?.version ?? '?'; + const id = v?.advisory?.id ?? '?'; + const sev = v?.advisory?.cvss ?? 'unknown'; + const desc = (v?.advisory?.description ?? '').substring(0, 120).replace(/\|/g, '\\|'); + body += `| \`${pkg}\` | ${ver} | [${id}](https://rustsec.org/advisories/${id}) | ${sev} | ${desc}โ€ฆ |\n`; + } + } + } catch { + body += '_Could not parse audit results._\n'; + } + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.startsWith('## ๐Ÿ” Security Audit โ€” `cargo-audit`')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Fail on vulnerabilities + if: steps.audit.outputs.exit_code != '0' + run: exit 1 + + clippy-security: + name: Clippy Lints (security-focused) + runs-on: ubuntu-latest + defaults: + run: + working-directory: contracts + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Run Clippy with security lints + id: clippy + # We promote security-relevant lints to errors: + # - clippy::integer_overflow_check โ€“ unchecked arithmetic + # - clippy::unwrap_used โ€“ panics from unwrap + # - clippy::expect_used โ€“ panics from expect + # - clippy::panic โ€“ explicit panics + # - clippy::arithmetic_side_effects โ€“ overflow side-effects + run: | + cargo clippy --all-targets --message-format=json 2>&1 \ + -- \ + -D clippy::unwrap_used \ + -D clippy::expect_used \ + -D clippy::panic \ + -D clippy::arithmetic_side_effects \ + -D clippy::indexing_slicing \ + | tee clippy-results.json + echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Upload Clippy results + if: always() + uses: actions/upload-artifact@v4 + with: + name: clippy-results + path: contracts/clippy-results.json + retention-days: 30 + + - name: Comment Clippy findings on PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let body = '## ๐Ÿ”Ž Clippy Security Lints\n\n'; + try { + const raw = fs.readFileSync('contracts/clippy-results.json', 'utf8'); + // Each line is a separate JSON object (cargo --message-format=json) + const lines = raw.trim().split('\n'); + const diags = []; + for (const line of lines) { + try { + const msg = JSON.parse(line); + if (msg.reason === 'compiler-message' && msg.message?.level === 'error') { + const m = msg.message; + const code = m?.code?.code ?? ''; + const text = m?.message ?? ''; + const spans = m?.spans ?? []; + const loc = spans.length > 0 + ? `${spans[0].file_name}:${spans[0].line_start}` + : 'unknown location'; + diags.push({ code, text, loc }); + } + } catch { /* skip malformed line */ } + } + if (diags.length === 0) { + body += 'โœ… No Clippy security lint violations found.\n'; + } else { + body += `โš ๏ธ **${diags.length} lint violation${diags.length === 1 ? '' : 's'} found:**\n\n`; + body += '| Location | Lint | Description |\n'; + body += '|----------|------|-------------|\n'; + for (const d of diags) { + const loc = d.loc.replace(/\|/g, '\\|'); + const desc = d.text.substring(0, 100).replace(/\|/g, '\\|'); + body += `| \`${loc}\` | \`${d.code}\` | ${desc} |\n`; + } + } + } catch { + body += '_Could not parse Clippy results._\n'; + } + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.startsWith('## ๐Ÿ”Ž Clippy Security Lints')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Fail on lint violations + if: steps.clippy.outputs.exit_code != '0' + run: exit 1 + + soroban-unsafe-patterns: + name: Soroban Safety Check (custom patterns) + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Check for prohibited unsafe patterns + id: patterns + # Scan for patterns that are dangerous in Soroban contracts: + # 1. use of `unsafe` blocks + # 2. std::process::abort / panic! (would cause node issues) + # 3. unchecked arithmetic operators (wrapping_* is acceptable, but + # bare overflow is not โ€“ we enforce checked_* usage via Clippy above) + # 4. storage reads without fallback (catch potential panics from .unwrap() + # on storage โ€” covered by Clippy; this step adds a grep-level guard) + run: | + echo "## ๐Ÿ›ก๏ธ Custom Soroban Safety Patterns" >> pr_comment.md + echo "" >> pr_comment.md + + FAIL=0 + + # 1. unsafe blocks in contract sources + UNSAFE=$(grep -rn "unsafe {" contracts/*/src/ 2>/dev/null || true) + if [ -n "$UNSAFE" ]; then + echo "### โ›” `unsafe` blocks detected" >> pr_comment.md + echo '```' >> pr_comment.md + echo "$UNSAFE" >> pr_comment.md + echo '```' >> pr_comment.md + FAIL=1 + fi + + # 2. std usage (contracts must be #![no_std]) + STD_USE=$(grep -rn "^use std::" contracts/*/src/ 2>/dev/null || true) + if [ -n "$STD_USE" ]; then + echo "### โ›” \`use std::\` in \`#![no_std]\` contracts" >> pr_comment.md + echo '```' >> pr_comment.md + echo "$STD_USE" >> pr_comment.md + echo '```' >> pr_comment.md + FAIL=1 + fi + + # 3. explicit panics + PANICS=$(grep -rn "panic!(" contracts/*/src/ 2>/dev/null || true) + if [ -n "$PANICS" ]; then + echo "### โš ๏ธ Explicit \`panic!\` calls (consider returning errors)" >> pr_comment.md + echo '```' >> pr_comment.md + echo "$PANICS" >> pr_comment.md + echo '```' >> pr_comment.md + FAIL=1 + fi + + if [ $FAIL -eq 0 ]; then + echo "โœ… No prohibited patterns found." >> pr_comment.md + fi + + echo "fail=$FAIL" >> "$GITHUB_OUTPUT" + cat pr_comment.md + + - name: Comment custom pattern results on PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('pr_comment.md', 'utf8'); + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.startsWith('## ๐Ÿ›ก๏ธ Custom Soroban Safety Patterns')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Fail on prohibited patterns + if: steps.patterns.outputs.fail == '1' + run: exit 1 diff --git a/client/package-lock.json b/client/package-lock.json index 21ada10c1b..00803e9b6c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,8 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@albedo-link/intent": "^0.13.0", + "@creit.tech/xbull-wallet-connect": "^0.4.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@stellar/freighter-api": "^6.0.1", @@ -43,6 +45,12 @@ "vite": "^6.3.5" } }, + "node_modules/@albedo-link/intent": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@albedo-link/intent/-/intent-0.13.0.tgz", + "integrity": "sha512-A8CBXqGQEBMXhwxNXj5inC6HLjyx5Do7jW99NOFeecYd1nPUq8gfM0tvoNoR8H8JQ11aTl9tyQBuu/+l3xeBnQ==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -74,6 +82,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -354,6 +363,19 @@ "node": ">=6.9.0" } }, + "node_modules/@creit.tech/xbull-wallet-connect": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", + "integrity": "sha512-LrCUIqUz50SkZ4mv2hTqSmwews8CNRYVoZ9+VjLsK/1U8PByzXTxv1vZyenj6avRTG86ifpoeihz7D3D5YIDrQ==", + "dependencies": { + "rxjs": "^7.5.5", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -1151,6 +1173,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2279,6 +2302,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2293,8 +2317,8 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2329,6 +2353,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -2390,6 +2415,7 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -2691,6 +2717,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2915,6 +2942,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3502,6 +3530,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3838,6 +3867,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5292,6 +5322,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5328,6 +5359,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5417,6 +5449,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5426,6 +5459,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5641,6 +5675,15 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5845,7 +5888,8 @@ "version": "0.183.2", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -6008,6 +6052,18 @@ } } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6041,6 +6097,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6173,6 +6230,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6325,6 +6383,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/client/package.json b/client/package.json index 70d1ae992f..ba6ddd8e8b 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@albedo-link/intent": "^0.13.0", + "@creit.tech/xbull-wallet-connect": "^0.4.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@stellar/freighter-api": "^6.0.1", diff --git a/client/src/auth/session.ts b/client/src/auth/session.ts index 02c2e8f4f7..c83a44164c 100644 --- a/client/src/auth/session.ts +++ b/client/src/auth/session.ts @@ -1,12 +1,13 @@ import { Buffer } from "buffer"; -import { getAddress, isConnected, requestAccess } from "@stellar/freighter-api"; import { Keypair, StrKey } from "@stellar/stellar-sdk"; import type { ConnectWalletOptions, + ExtensionWalletProviderId, VerificationStatus, WalletProviderId, WalletSession, } from "./types"; +import { getAdapter } from "./walletAdapters"; const STORAGE_KEY = "stellar-yield.wallet-session"; const API_BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3001"; @@ -25,6 +26,8 @@ interface VerifyResponse { const providerLabels: Record = { freighter: "Freighter", + xbull: "xBull", + albedo: "Albedo", email: "Email Smart Wallet", google: "Google Smart Wallet", github: "GitHub Smart Wallet", @@ -151,33 +154,21 @@ export async function connectWalletSession( ): Promise { const providerId = options.providerId ?? "freighter"; - if (providerId === "freighter") { - const connectionResult = await isConnected(); - - if (connectionResult.error || !connectionResult.isConnected) { - throw new Error( - "Freighter extension was not detected. Install it to continue.", - ); - } - - const accessResult = await requestAccess(); - if (accessResult.error) { - throw new Error(accessResult.error); + // โ”€โ”€ Extension / browser wallet providers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const EXTENSION_PROVIDERS: ExtensionWalletProviderId[] = ["freighter", "xbull", "albedo"]; + if ((EXTENSION_PROVIDERS as WalletProviderId[]).includes(providerId)) { + const adapter = getAdapter(providerId as ExtensionWalletProviderId); + if (!adapter) { + throw new Error(`No adapter found for wallet provider: ${providerId}`); } - - const addressResult = await getAddress(); - if (addressResult.error || !addressResult.address) { - throw new Error(addressResult.error ?? "Failed to read wallet address."); - } - + const walletAddress = await adapter.getPublicKey(); const session: WalletSession = { - walletAddress: addressResult.address, + walletAddress, walletAddressType: "account", providerId, providerLabel: getProviderLabel(providerId), verificationStatus: "verified", }; - saveSession(session); return session; } diff --git a/client/src/auth/types.ts b/client/src/auth/types.ts index 57197f555c..74775e113c 100644 --- a/client/src/auth/types.ts +++ b/client/src/auth/types.ts @@ -1,4 +1,7 @@ -export type WalletProviderId = "freighter" | "email" | "google" | "github"; +/** Extension/browser wallet provider IDs */ +export type ExtensionWalletProviderId = "freighter" | "xbull" | "albedo"; + +export type WalletProviderId = ExtensionWalletProviderId | "email" | "google" | "github"; export type WalletAddressType = "account" | "contract"; diff --git a/client/src/auth/walletAdapters.ts b/client/src/auth/walletAdapters.ts new file mode 100644 index 0000000000..8f2299eb3e --- /dev/null +++ b/client/src/auth/walletAdapters.ts @@ -0,0 +1,163 @@ +/** + * Unified Wallet Adapter Layer + * + * Provides a single `WalletAdapter` interface for Freighter, xBull and Albedo. + * All wallet-specific SDK calls are isolated here so the rest of the app + * only depends on `WalletAdapter`. + */ + +import { getAddress, isConnected, requestAccess, signTransaction as freighterSign } from "@stellar/freighter-api"; +import { xBullWalletConnect } from "@creit.tech/xbull-wallet-connect"; +import albedo from "@albedo-link/intent"; + +import type { ExtensionWalletProviderId } from "./types"; + +// โ”€โ”€ Interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface WalletAdapter { + /** Provider identifier */ + id: ExtensionWalletProviderId; + /** Human-readable label */ + label: string; + /** Returns true if the wallet is available in this browser */ + isAvailable(): Promise; + /** Connect and return the user's public key */ + getPublicKey(): Promise; + /** Sign an XDR-encoded transaction and return the signed XDR */ + signTransaction(xdr: string, networkPassphrase: string): Promise; +} + +// โ”€โ”€ Freighter adapter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class FreighterAdapter implements WalletAdapter { + readonly id = "freighter" as const; + readonly label = "Freighter"; + + async isAvailable(): Promise { + try { + const result = await isConnected(); + return !result.error && result.isConnected; + } catch { + return false; + } + } + + async getPublicKey(): Promise { + const connectionResult = await isConnected(); + if (connectionResult.error || !connectionResult.isConnected) { + throw new Error("Freighter extension was not detected. Install it to continue."); + } + const accessResult = await requestAccess(); + if (accessResult.error) { + throw new Error(accessResult.error); + } + const addressResult = await getAddress(); + if (addressResult.error || !addressResult.address) { + throw new Error(addressResult.error ?? "Failed to read wallet address."); + } + return addressResult.address; + } + + async signTransaction(xdr: string, networkPassphrase: string): Promise { + const signed = await freighterSign(xdr, { networkPassphrase }); + const signedXdr = signed?.signedTxXdr; + if (!signedXdr) throw new Error("Transaction was rejected by Freighter."); + return signedXdr; + } +} + +// โ”€โ”€ xBull adapter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class XBullAdapter implements WalletAdapter { + readonly id = "xbull" as const; + readonly label = "xBull"; + + private getInstance(): InstanceType { + return new xBullWalletConnect(); + } + + async isAvailable(): Promise { + // xBull works via postMessage in-page; always available + return typeof window !== "undefined"; + } + + async getPublicKey(): Promise { + const wallet = this.getInstance(); + try { + await wallet.openWallet(); + const result = (await wallet.connect()) as { publicKey: string }; + wallet.closeWallet(); + if (!result?.publicKey) throw new Error("xBull did not return a public key."); + return result.publicKey; + } finally { + wallet.closeConnections(); + } + } + + async signTransaction(xdr: string, networkPassphrase: string): Promise { + const wallet = this.getInstance(); + try { + await wallet.openWallet(); + const result = (await wallet.sign({ xdr, publicKey: undefined, network: networkPassphrase })) as { + signedXDR: string; + }; + wallet.closeWallet(); + if (!result?.signedXDR) throw new Error("xBull rejected the transaction."); + return result.signedXDR; + } finally { + wallet.closeConnections(); + } + } +} + +// โ”€โ”€ Albedo adapter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class AlbedoAdapter implements WalletAdapter { + readonly id = "albedo" as const; + readonly label = "Albedo"; + + async isAvailable(): Promise { + return typeof window !== "undefined"; + } + + async getPublicKey(): Promise { + const result = (await (albedo as AlbedoInstance).publicKey({})) as { + pubkey: string; + }; + if (!result?.pubkey) throw new Error("Albedo did not return a public key."); + return result.pubkey; + } + + async signTransaction(xdr: string, networkPassphrase: string): Promise { + const result = (await (albedo as AlbedoInstance).tx({ + xdr, + network_passphrase: networkPassphrase, + })) as { signed_envelope_xdr: string }; + if (!result?.signed_envelope_xdr) throw new Error("Albedo rejected the transaction."); + return result.signed_envelope_xdr; + } +} + +// Albedo's JS is untyped; this minimal interface is enough for our use. +interface AlbedoInstance { + publicKey(params: Record): Promise; + tx(params: { xdr: string; network_passphrase: string; pubkey?: string }): Promise; +} + +// โ”€โ”€ Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const freighterAdapter = new FreighterAdapter(); +const xBullAdapter = new XBullAdapter(); +const albedoAdapter = new AlbedoAdapter(); + +/** All extension/browser wallet adapters in display order. */ +export const EXTENSION_ADAPTERS: WalletAdapter[] = [ + freighterAdapter, + xBullAdapter, + albedoAdapter, +]; + +/** Look up an adapter by provider ID. Returns undefined if not found. */ +export function getAdapter(id: ExtensionWalletProviderId): WalletAdapter | undefined { + return EXTENSION_ADAPTERS.find((a) => a.id === id); +} diff --git a/client/src/components/wallet/WalletConnectionModal.tsx b/client/src/components/wallet/WalletConnectionModal.tsx index ded301d637..2c1d2ccf97 100644 --- a/client/src/components/wallet/WalletConnectionModal.tsx +++ b/client/src/components/wallet/WalletConnectionModal.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Github, Mail, Shield, Wallet, X } from "lucide-react"; +import { ExternalLink, Github, Mail, Shield, Wallet, X, Zap } from "lucide-react"; import { useState } from "react"; import { useWallet } from "../../context/useWallet"; @@ -31,7 +31,7 @@ export default function WalletConnectionModal({ }; const handleConnect = async ( - providerId: "freighter" | "email" | "google" | "github", + providerId: "freighter" | "xbull" | "albedo" | "email" | "google" | "github", ) => { const didConnect = await connectWallet({ providerId, @@ -67,10 +67,8 @@ export default function WalletConnectionModal({

- Use Freighter for classic Stellar accounts, or create a session-based - smart wallet via email or social login. Smart wallet onboarding - derives a contract-style wallet address plus a session key under the - hood. + Choose a Stellar wallet to connect, or create a session-based smart + wallet via email or social login.

{errorMessage ? ( @@ -80,27 +78,58 @@ export default function WalletConnectionModal({ ) : null}
- {isFreighterInstalled === false ? ( - - Install Freighter - - - ) : ( - - )} + {/* โ”€โ”€ Extension wallets โ”€โ”€ */} +
+
+ + Browser Wallets +
+
+ {isFreighterInstalled === false ? ( + + Install Freighter + + + ) : ( + + )} + + +
+
+ {/* โ”€โ”€ Smart wallet โ”€โ”€ */}
@@ -148,15 +177,17 @@ export default function WalletConnectionModal({
-
- Backend session challenge status:{" "} - - {verificationStatus === "verified" - ? "verified" - : "local fallback"} - - . -
+ {verificationStatus ? ( +
+ Backend session challenge status:{" "} + + {verificationStatus === "verified" + ? "verified" + : "local fallback"} + + . +
+ ) : null}
diff --git a/client/src/context/WalletContext.tsx b/client/src/context/WalletContext.tsx index 75eaa9ad40..df2518e6c1 100644 --- a/client/src/context/WalletContext.tsx +++ b/client/src/context/WalletContext.tsx @@ -5,7 +5,8 @@ import { connectWalletSession, loadStoredSession, } from "../auth/session"; -import type { ConnectWalletOptions, WalletSession } from "../auth/types"; +import { getAdapter } from "../auth/walletAdapters"; +import type { ConnectWalletOptions, ExtensionWalletProviderId, WalletSession } from "../auth/types"; import { WalletContext } from "./WalletContextObject"; export function WalletProvider({ children }: { children: ReactNode }) { @@ -71,6 +72,22 @@ export function WalletProvider({ children }: { children: ReactNode }) { setErrorMessage(null); } + async function signTransaction(xdr: string, networkPassphrase: string): Promise { + if (!session) { + throw new Error("No wallet connected."); + } + const EXTENSION_PROVIDERS: ExtensionWalletProviderId[] = ["freighter", "xbull", "albedo"]; + if ((EXTENSION_PROVIDERS as string[]).includes(session.providerId)) { + const adapter = getAdapter(session.providerId as ExtensionWalletProviderId); + if (!adapter) { + throw new Error(`No adapter for provider: ${session.providerId}`); + } + return adapter.signTransaction(xdr, networkPassphrase); + } + // Smart wallet (email / google / github) sessions don't support direct signing + throw new Error("signTransaction is not supported for smart wallet sessions."); + } + const value = useMemo( () => ({ walletAddress: session?.walletAddress ?? null, @@ -85,7 +102,11 @@ export function WalletProvider({ children }: { children: ReactNode }) { connectWallet, disconnectWallet, clearError, + signTransaction, }), + // signTransaction is stable: it only captures `session` which is already + // in the deps array below. + // eslint-disable-next-line react-hooks/exhaustive-deps [session, isConnecting, isFreighterInstalled, errorMessage], ); diff --git a/client/src/context/WalletContextObject.ts b/client/src/context/WalletContextObject.ts index 98a4331c7f..8b5d7de3a6 100644 --- a/client/src/context/WalletContextObject.ts +++ b/client/src/context/WalletContextObject.ts @@ -14,6 +14,12 @@ export interface WalletContextValue { connectWallet: (options?: ConnectWalletOptions) => Promise; disconnectWallet: () => void; clearError: () => void; + /** + * Sign a Soroban transaction XDR using the currently connected wallet. + * Routes to the correct provider automatically โ€” callers don't need to know + * which wallet is active. + */ + signTransaction: (xdr: string, networkPassphrase: string) => Promise; } export const WalletContext = createContext( diff --git a/client/src/services/soroban.ts b/client/src/services/soroban.ts index b55b339e8f..79d166367f 100644 --- a/client/src/services/soroban.ts +++ b/client/src/services/soroban.ts @@ -1,7 +1,7 @@ /** * Soroban Transaction Engine * - * Constructs, signs via Freighter, and submits Soroban contract calls. + * Constructs, signs via the active wallet adapter, and submits Soroban contract calls. * Designed to work with the YieldVault contract for deposit/withdraw. */ @@ -78,6 +78,7 @@ async function buildContractCall( /** * Sign a transaction XDR with the user's Freighter wallet. + * @deprecated Use `signTransaction` parameter in `executeContractCall` instead. */ async function signWithFreighter(xdr: string): Promise { const signed = await freighter.signTransaction(xdr, { @@ -132,25 +133,30 @@ async function submitAndPoll(signedXdr: string): Promise { /** * Execute a full contract call: build โ†’ sign โ†’ submit โ†’ poll. * - * @param sourcePublicKey - Caller's Stellar public key - * @param method - Contract method name (e.g. "deposit") - * @param args - ScVal arguments - * @param onStatus - Optional callback for status updates - * @returns Transaction result + * @param sourcePublicKey - Caller's Stellar public key + * @param method - Contract method name (e.g. "deposit") + * @param args - ScVal arguments + * @param onStatus - Optional callback for status updates + * @param useFeeBump - Whether to wrap the tx in a fee-bump via the relayer + * @param signTx - Optional signer function; defaults to Freighter for + * backwards compatibility. Pass `wallet.signTransaction` + * from `useWallet()` to use the active wallet adapter. */ export async function executeContractCall( sourcePublicKey: string, method: string, args: StellarSdk.xdr.ScVal[], onStatus?: (status: TxStatus) => void, - useFeeBump: boolean = false + useFeeBump: boolean = false, + signTx?: (xdr: string, networkPassphrase: string) => Promise, ): Promise { try { onStatus?.("building"); const xdr = await buildContractCall(sourcePublicKey, method, ...args); onStatus?.("signing"); - const signedXdr = await signWithFreighter(xdr); + const signer = signTx ?? ((x: string) => signWithFreighter(x)); + const signedXdr = await signer(xdr, NETWORK_PASSPHRASE); let finalXdr = signedXdr; if (useFeeBump) { @@ -184,12 +190,15 @@ export async function executeContractCall( * @param userAddress - Depositor's public key * @param amount - Amount in stroops (1 XLM = 10_000_000 stroops) * @param onStatus - Status callback for UI updates + * @param useFeeBump - Whether to wrap the tx in a fee-bump via the relayer + * @param signTx - Optional signer; pass `wallet.signTransaction` to use any wallet adapter */ export async function deposit( userAddress: string, amount: bigint, onStatus?: (status: TxStatus) => void, - useFeeBump: boolean = true // Sponsor first deposit by default as per issue + useFeeBump: boolean = true, + signTx?: (xdr: string, networkPassphrase: string) => Promise, ): Promise { return executeContractCall( userAddress, @@ -199,7 +208,8 @@ export async function deposit( StellarSdk.nativeToScVal(amount, { type: "i128" }), ], onStatus, - useFeeBump + useFeeBump, + signTx, ); } @@ -209,11 +219,13 @@ export async function deposit( * @param userAddress - Withdrawer's public key * @param shares - Number of vault shares to redeem * @param onStatus - Status callback for UI updates + * @param signTx - Optional signer; pass `wallet.signTransaction` to use any wallet adapter */ export async function withdraw( userAddress: string, shares: bigint, onStatus?: (status: TxStatus) => void, + signTx?: (xdr: string, networkPassphrase: string) => Promise, ): Promise { return executeContractCall( userAddress, @@ -223,5 +235,7 @@ export async function withdraw( StellarSdk.nativeToScVal(shares, { type: "i128" }), ], onStatus, + false, + signTx, ); } diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index e9655bbfae..2954eb307c 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -250,9 +250,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -871,9 +871,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ "once_cell", "wasm-bindgen", @@ -1644,12 +1644,26 @@ dependencies = [ "der", ] +[[package]] +name = "stableswap" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stealth_addresses" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "stellar-strkey" version = "0.0.9" @@ -1840,9 +1854,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -1853,9 +1867,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1863,9 +1877,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -1876,9 +1890,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -2129,18 +2143,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 51cc3c56da..8642bd9b21 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["yield_vault", "zap", "options", "aa_recovery", "aa_factory", "settlement", "intent_swap", "dutch_auction", "liquid_staking", "emission_controller", "strategies/delta_neutral"] +members = ["yield_vault", "zap", "options", "aa_recovery", "aa_factory", "settlement", "intent_swap", "dutch_auction", "liquid_staking", "emission_controller", "strategies/delta_neutral", "stableswap", "stealth_addresses"] [profile.release] opt-level = "z" diff --git a/contracts/stableswap/Cargo.toml b/contracts/stableswap/Cargo.toml new file mode 100644 index 0000000000..ad5a84ad17 --- /dev/null +++ b/contracts/stableswap/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "stableswap" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/stableswap/src/lib.rs b/contracts/stableswap/src/lib.rs new file mode 100644 index 0000000000..84092ad1a8 --- /dev/null +++ b/contracts/stableswap/src/lib.rs @@ -0,0 +1,742 @@ +#![no_std] +#![allow(clippy::too_many_arguments)] + +//! # StableSwap AMM โ€” Stableswap Invariant Pool (Curve-style) +//! +//! Implements the Stableswap invariant: +//! Aยทn^nยทฮฃxแตข + D = Aยทn^nยทD + D^(n+1) / (n^nยทฮ xแตข) +//! +//! This provides deep liquidity with minimal slippage for pegged assets +//! (e.g., USDC/sUSD) compared to a standard constant-product AMM. +//! +//! ## Invariant โ€” simplified for n=2 tokens, solved iteratively: +//! Dยณ / (4ยทxยทy) + Aยท(x+y)ยท4 = Dยท(4ยทA + 1) +//! +//! Dynamic fee adjustment scales linearly with pool imbalance: +//! fee = base_fee + (imbalance_ratio ยท fee_multiplier) +//! where imbalance_ratio = |x/S - 0.5| / 0.5 (S = x+y) + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, +}; + +// โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Precision scaler used for fee arithmetic (1e7 = 100.00000%) +const FEE_PRECISION: i128 = 10_000_000; +/// Maximum allowed amplification coefficient +const MAX_A: u32 = 1_000_000; +/// Maximum base fee: 1% (100_000 / FEE_PRECISION) +const MAX_BASE_FEE_BPS: u32 = 1_000_000; +/// Newton's method convergence iterations +const NEWTON_ITERS: u32 = 255; +/// Number of tokens in the pool +const N_COINS: i128 = 2; + +// โ”€โ”€ Storage keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[contracttype] +enum DataKey { + Initialized, + Token0, + Token1, + LpToken, + Admin, + /// Amplification coefficient A (โ‰ฅ1) + AmpCoeff, + /// Base swap fee in FEE_PRECISION units (e.g. 30_000 = 0.3%) + BaseFee, + /// Extra fee added per unit of imbalance ratio (FEE_PRECISION units) + FeeMultiplier, + /// Reserve of token0 + Reserve0, + /// Reserve of token1 + Reserve1, + /// Total LP supply + TotalSupply, + /// LP balance per account + LpBalance(Address), +} + +// โ”€โ”€ Errors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum StableSwapError { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidAmount = 3, + InsufficientLiquidity = 4, + InsufficientOutput = 5, + Unauthorized = 6, + InvalidAmpCoeff = 7, + InvalidFee = 8, + MathOverflow = 9, + ZeroInvariant = 10, +} + +// โ”€โ”€ Contract โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[contract] +pub struct StableSwap; + +#[contractimpl] +impl StableSwap { + // โ”€โ”€ Initialisation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Initialize the pool. + /// - `amp_coeff`: amplification coefficient A (1..MAX_A) + /// - `base_fee`: swap fee in FEE_PRECISION units (e.g. 30_000 = 0.3%) + /// - `fee_multiplier`: additional fee per unit of imbalance (FEE_PRECISION) + pub fn initialize( + env: Env, + admin: Address, + token0: Address, + token1: Address, + lp_token: Address, + amp_coeff: u32, + base_fee: u32, + fee_multiplier: u32, + ) -> Result<(), StableSwapError> { + if env.storage().instance().has(&DataKey::Initialized) { + return Err(StableSwapError::AlreadyInitialized); + } + if !(1..=MAX_A).contains(&_coeff) { + return Err(StableSwapError::InvalidAmpCoeff); + } + if base_fee > MAX_BASE_FEE_BPS { + return Err(StableSwapError::InvalidFee); + } + + let s = env.storage().instance(); + s.set(&DataKey::Initialized, &true); + s.set(&DataKey::Admin, &admin); + s.set(&DataKey::Token0, &token0); + s.set(&DataKey::Token1, &token1); + s.set(&DataKey::LpToken, &lp_token); + s.set(&DataKey::AmpCoeff, &_coeff); + s.set(&DataKey::BaseFee, &base_fee); + s.set(&DataKey::FeeMultiplier, &fee_multiplier); + s.set(&DataKey::Reserve0, &0_i128); + s.set(&DataKey::Reserve1, &0_i128); + s.set(&DataKey::TotalSupply, &0_i128); + + env.events() + .publish((symbol_short!("init"),), (admin, amp_coeff, base_fee)); + Ok(()) + } + + // โ”€โ”€ Liquidity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Deposit `amount0` of token0 and `amount1` of token1, receive LP tokens. + /// `min_mint_amount` guards against sandwich attacks on the initial ratio. + pub fn add_liquidity( + env: Env, + sender: Address, + amount0: i128, + amount1: i128, + min_mint_amount: i128, + ) -> Result { + sender.require_auth(); + Self::assert_initialized(&env)?; + + if amount0 <= 0 || amount1 <= 0 { + return Err(StableSwapError::InvalidAmount); + } + + let token0: Address = env.storage().instance().get(&DataKey::Token0).unwrap(); + let token1: Address = env.storage().instance().get(&DataKey::Token1).unwrap(); + + let reserve0: i128 = env.storage().instance().get(&DataKey::Reserve0).unwrap(); + let reserve1: i128 = env.storage().instance().get(&DataKey::Reserve1).unwrap(); + let total_supply: i128 = env.storage().instance().get(&DataKey::TotalSupply).unwrap(); + let amp_coeff: u32 = env.storage().instance().get(&DataKey::AmpCoeff).unwrap(); + + // Pull tokens from sender + token::Client::new(&env, &token0).transfer( + &sender, + &env.current_contract_address(), + &amount0, + ); + token::Client::new(&env, &token1).transfer( + &sender, + &env.current_contract_address(), + &amount1, + ); + + let new_reserve0 = reserve0 + .checked_add(amount0) + .ok_or(StableSwapError::MathOverflow)?; + let new_reserve1 = reserve1 + .checked_add(amount1) + .ok_or(StableSwapError::MathOverflow)?; + + let d0 = if total_supply == 0 { + 0 + } else { + Self::compute_d(reserve0, reserve1, amp_coeff)? + }; + let d1 = Self::compute_d(new_reserve0, new_reserve1, amp_coeff)?; + + if d1 <= d0 { + return Err(StableSwapError::ZeroInvariant); + } + + let mint_amount = if total_supply == 0 { + d1 + } else { + total_supply + .checked_mul(d1 - d0) + .ok_or(StableSwapError::MathOverflow)? + .checked_div(d0) + .ok_or(StableSwapError::MathOverflow)? + }; + + if mint_amount < min_mint_amount { + return Err(StableSwapError::InsufficientOutput); + } + + // Update state + env.storage() + .instance() + .set(&DataKey::Reserve0, &new_reserve0); + env.storage() + .instance() + .set(&DataKey::Reserve1, &new_reserve1); + let new_supply = total_supply + .checked_add(mint_amount) + .ok_or(StableSwapError::MathOverflow)?; + env.storage() + .instance() + .set(&DataKey::TotalSupply, &new_supply); + + let lp_key = DataKey::LpBalance(sender.clone()); + let old_lp: i128 = env.storage().persistent().get(&lp_key).unwrap_or(0); + env.storage().persistent().set( + &lp_key, + &(old_lp + .checked_add(mint_amount) + .ok_or(StableSwapError::MathOverflow)?), + ); + + env.events().publish( + (symbol_short!("add_liq"),), + (sender, amount0, amount1, mint_amount), + ); + Ok(mint_amount) + } + + /// Burn `lp_amount` LP tokens and withdraw proportional pool assets. + pub fn remove_liquidity( + env: Env, + sender: Address, + lp_amount: i128, + min_amount0: i128, + min_amount1: i128, + ) -> Result<(i128, i128), StableSwapError> { + sender.require_auth(); + Self::assert_initialized(&env)?; + + if lp_amount <= 0 { + return Err(StableSwapError::InvalidAmount); + } + + let lp_key = DataKey::LpBalance(sender.clone()); + let sender_lp: i128 = env.storage().persistent().get(&lp_key).unwrap_or(0); + if sender_lp < lp_amount { + return Err(StableSwapError::InsufficientLiquidity); + } + + let reserve0: i128 = env.storage().instance().get(&DataKey::Reserve0).unwrap(); + let reserve1: i128 = env.storage().instance().get(&DataKey::Reserve1).unwrap(); + let total_supply: i128 = env.storage().instance().get(&DataKey::TotalSupply).unwrap(); + + let out0 = reserve0 + .checked_mul(lp_amount) + .ok_or(StableSwapError::MathOverflow)? + .checked_div(total_supply) + .ok_or(StableSwapError::MathOverflow)?; + let out1 = reserve1 + .checked_mul(lp_amount) + .ok_or(StableSwapError::MathOverflow)? + .checked_div(total_supply) + .ok_or(StableSwapError::MathOverflow)?; + + if out0 < min_amount0 || out1 < min_amount1 { + return Err(StableSwapError::InsufficientOutput); + } + + // Burn LP + env.storage() + .persistent() + .set(&lp_key, &(sender_lp - lp_amount)); + env.storage() + .instance() + .set(&DataKey::TotalSupply, &(total_supply - lp_amount)); + env.storage() + .instance() + .set(&DataKey::Reserve0, &(reserve0 - out0)); + env.storage() + .instance() + .set(&DataKey::Reserve1, &(reserve1 - out1)); + + let token0: Address = env.storage().instance().get(&DataKey::Token0).unwrap(); + let token1: Address = env.storage().instance().get(&DataKey::Token1).unwrap(); + token::Client::new(&env, &token0).transfer(&env.current_contract_address(), &sender, &out0); + token::Client::new(&env, &token1).transfer(&env.current_contract_address(), &sender, &out1); + + env.events() + .publish((symbol_short!("rem_liq"),), (sender, lp_amount, out0, out1)); + Ok((out0, out1)) + } + + // โ”€โ”€ Swap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Swap `token_in` for `token_out`. + /// + /// Applies a dynamic fee that increases when the pool is more imbalanced. + /// `min_out` provides slippage protection for the caller. + pub fn swap( + env: Env, + sender: Address, + token_in: Address, + amount_in: i128, + min_out: i128, + ) -> Result { + sender.require_auth(); + Self::assert_initialized(&env)?; + + if amount_in <= 0 { + return Err(StableSwapError::InvalidAmount); + } + + let token0: Address = env.storage().instance().get(&DataKey::Token0).unwrap(); + let token1: Address = env.storage().instance().get(&DataKey::Token1).unwrap(); + + let (reserve_in, reserve_out, token_out) = if token_in == token0 { + let r0: i128 = env.storage().instance().get(&DataKey::Reserve0).unwrap(); + let r1: i128 = env.storage().instance().get(&DataKey::Reserve1).unwrap(); + (r0, r1, token1.clone()) + } else if token_in == token1 { + let r0: i128 = env.storage().instance().get(&DataKey::Reserve0).unwrap(); + let r1: i128 = env.storage().instance().get(&DataKey::Reserve1).unwrap(); + (r1, r0, token0.clone()) + } else { + return Err(StableSwapError::InvalidAmount); + }; + + let amp_coeff: u32 = env.storage().instance().get(&DataKey::AmpCoeff).unwrap(); + let base_fee: u32 = env.storage().instance().get(&DataKey::BaseFee).unwrap(); + let fee_multiplier: u32 = env + .storage() + .instance() + .get(&DataKey::FeeMultiplier) + .unwrap(); + + // Compute dynamic fee based on imbalance before the swap + let fee = Self::compute_dynamic_fee(reserve_in, reserve_out, base_fee, fee_multiplier)?; + + // Amount after fee deduction + let amount_in_after_fee = amount_in + .checked_mul(FEE_PRECISION - fee) + .ok_or(StableSwapError::MathOverflow)? + .checked_div(FEE_PRECISION) + .ok_or(StableSwapError::MathOverflow)?; + + // Compute new reserve_out using the invariant + let new_reserve_in = reserve_in + .checked_add(amount_in_after_fee) + .ok_or(StableSwapError::MathOverflow)?; + let new_reserve_out = Self::compute_y(new_reserve_in, reserve_in + reserve_out, amp_coeff)?; + + let amount_out = reserve_out + .checked_sub(new_reserve_out) + .ok_or(StableSwapError::MathOverflow)?; + + if amount_out < min_out { + return Err(StableSwapError::InsufficientOutput); + } + + // Transfer tokens + token::Client::new(&env, &token_in).transfer( + &sender, + &env.current_contract_address(), + &amount_in, + ); + token::Client::new(&env, &token_out).transfer( + &env.current_contract_address(), + &sender, + &amount_out, + ); + + // Update reserves + let (new_r0, new_r1) = if token_in == token0 { + (new_reserve_in, new_reserve_out) + } else { + (new_reserve_out, new_reserve_in) + }; + env.storage().instance().set(&DataKey::Reserve0, &new_r0); + env.storage().instance().set(&DataKey::Reserve1, &new_r1); + + env.events().publish( + (symbol_short!("swap"),), + (sender, token_in, amount_in, amount_out, fee), + ); + Ok(amount_out) + } + + // โ”€โ”€ View helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + pub fn get_reserves(env: Env) -> Result<(i128, i128), StableSwapError> { + Self::assert_initialized(&env)?; + let r0: i128 = env.storage().instance().get(&DataKey::Reserve0).unwrap(); + let r1: i128 = env.storage().instance().get(&DataKey::Reserve1).unwrap(); + Ok((r0, r1)) + } + + pub fn get_lp_balance(env: Env, account: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::LpBalance(account)) + .unwrap_or(0) + } + + pub fn get_total_supply(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::TotalSupply) + .unwrap_or(0) + } + + // โ”€โ”€ Math internals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Compute the StableSwap invariant D for reserves (x, y) and coefficient A. + /// + /// Newtonโ€“Raphson iteration of: + /// f(D) = Aยทn^nยท(x+y)ยทD + D^(n+1)/(n^nยทxยทy) โˆ’ (Aยทn^n + 1)ยทDยฒ (for n=2) + /// + /// Simplified update rule (standard Curve formula for n=2): + /// D_{k+1} = (Aยทn^nยทSยทD_k + nยทDprod) / ((Aยทn^n+1)ยทD_k โˆ’ Dprod) + /// where S = x+y, Dprod = D_kยณ/(4ยทxยทy) + fn compute_d(x: i128, y: i128, amp: u32) -> Result { + if x <= 0 || y <= 0 { + return Err(StableSwapError::ZeroInvariant); + } + + let s = x.checked_add(y).ok_or(StableSwapError::MathOverflow)?; + // Aยทn^n (n=2, n^n=4) + let ann: i128 = (amp as i128) + .checked_mul(4) + .ok_or(StableSwapError::MathOverflow)?; + + let mut d = s; + + for _ in 0..NEWTON_ITERS { + // d_prod = D^3 / (4ยทxยทy) + let d3 = d + .checked_mul(d) + .and_then(|v| v.checked_mul(d)) + .ok_or(StableSwapError::MathOverflow)?; + let four_xy = x + .checked_mul(4) + .and_then(|v| v.checked_mul(y)) + .ok_or(StableSwapError::MathOverflow)?; + let d_prod = d3 + .checked_div(four_xy) + .ok_or(StableSwapError::MathOverflow)?; + + // numerator = (annยทS + nยทd_prod)ยทD = (annยทS + 2ยทd_prod)ยทD + let numer = ann + .checked_mul(s) + .ok_or(StableSwapError::MathOverflow)? + .checked_add( + d_prod + .checked_mul(N_COINS) + .ok_or(StableSwapError::MathOverflow)?, + ) + .ok_or(StableSwapError::MathOverflow)? + .checked_mul(d) + .ok_or(StableSwapError::MathOverflow)?; + + // denominator = (ann+1)ยทD โˆ’ d_prod + let denom = ann + .checked_add(1) + .ok_or(StableSwapError::MathOverflow)? + .checked_mul(d) + .ok_or(StableSwapError::MathOverflow)? + .checked_sub(d_prod) + .ok_or(StableSwapError::MathOverflow)?; + + let d_next = numer + .checked_div(denom) + .ok_or(StableSwapError::MathOverflow)?; + + // Converged? + if (d_next - d).abs() <= 1 { + return Ok(d_next); + } + d = d_next; + } + Ok(d) + } + + /// Given the new reserve of one token (x_new), compute the required + /// reserve of the other token (y) to maintain the invariant D. + /// + /// Solves: y^2 + (b โˆ’ D)ยทy โˆ’ D^3/(4ยทAยทn^nยทx_new) = 0 + /// where b = x_new + D/ann + fn compute_y(x_new: i128, sum: i128, amp: u32) -> Result { + if x_new <= 0 { + return Err(StableSwapError::InvalidAmount); + } + + // Recompute D from current sum (approximation: use sum as proxy for D) + // For the purpose of computing y we iteratively solve: + // y_{k+1} = (y_k^2 + D^3/(4ยทannยทx_new)) / (2ยทy_k + b โˆ’ D) + // where b = x_new + D/ann, D โ‰ˆ sum (initial guess) + let ann: i128 = (amp as i128) + .checked_mul(4) + .ok_or(StableSwapError::MathOverflow)?; + + // Approximate D as proportional to sum (conservative; accurate enough for 2-coin pools) + let d = sum; + + // b = x_new + D/ann + let b = x_new + .checked_add(d.checked_div(ann).ok_or(StableSwapError::MathOverflow)?) + .ok_or(StableSwapError::MathOverflow)?; + + // c = D^3 / (4ยทannยทx_new) + let d3 = d + .checked_mul(d) + .and_then(|v| v.checked_mul(d)) + .ok_or(StableSwapError::MathOverflow)?; + let four_ann_x = ann + .checked_mul(4) + .and_then(|v| v.checked_mul(x_new)) + .ok_or(StableSwapError::MathOverflow)?; + let c = d3 + .checked_div(four_ann_x) + .ok_or(StableSwapError::MathOverflow)?; + + let mut y = d; + for _ in 0..NEWTON_ITERS { + let y2 = y.checked_mul(y).ok_or(StableSwapError::MathOverflow)?; + let numer = y2.checked_add(c).ok_or(StableSwapError::MathOverflow)?; + let denom = y + .checked_mul(2) + .ok_or(StableSwapError::MathOverflow)? + .checked_add(b) + .ok_or(StableSwapError::MathOverflow)? + .checked_sub(d) + .ok_or(StableSwapError::MathOverflow)?; + let y_next = numer + .checked_div(denom) + .ok_or(StableSwapError::MathOverflow)?; + if (y_next - y).abs() <= 1 { + return Ok(y_next); + } + y = y_next; + } + Ok(y) + } + + /// Compute the dynamic fee (in FEE_PRECISION units) based on pool imbalance. + /// + /// imbalance_ratio = |balance0/(balance0+balance1) โˆ’ 0.5| / 0.5 + /// = |balance0 โˆ’ balance1| / (balance0 + balance1) + /// + /// dynamic_fee = base_fee + imbalance_ratio ร— fee_multiplier + fn compute_dynamic_fee( + balance0: i128, + balance1: i128, + base_fee: u32, + fee_multiplier: u32, + ) -> Result { + let total = balance0 + .checked_add(balance1) + .ok_or(StableSwapError::MathOverflow)?; + if total == 0 { + return Ok(base_fee as i128); + } + + let diff = (balance0 - balance1).abs(); + // imbalance_ratio (scaled by FEE_PRECISION) + let imbalance_fp = diff + .checked_mul(FEE_PRECISION) + .ok_or(StableSwapError::MathOverflow)? + .checked_div(total) + .ok_or(StableSwapError::MathOverflow)?; + + // additional_fee = imbalance_fp ร— fee_multiplier / FEE_PRECISION + let additional_fee = imbalance_fp + .checked_mul(fee_multiplier as i128) + .ok_or(StableSwapError::MathOverflow)? + .checked_div(FEE_PRECISION) + .ok_or(StableSwapError::MathOverflow)?; + + (base_fee as i128) + .checked_add(additional_fee) + .ok_or(StableSwapError::MathOverflow) + } + + // โ”€โ”€ Internal helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + fn assert_initialized(env: &Env) -> Result<(), StableSwapError> { + if !env.storage().instance().has(&DataKey::Initialized) { + return Err(StableSwapError::NotInitialized); + } + Ok(()) + } +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env}; + + /// Helper: deploy and initialize a pool + fn setup_pool(env: &Env, amp: u32) -> (Address, Address, Address, Address, Address) { + let admin = Address::generate(env); + let token0 = Address::generate(env); + let token1 = Address::generate(env); + let lp_token = Address::generate(env); + let contract_id = env.register(StableSwap, ()); + let client = StableSwapClient::new(env, &contract_id); + client.initialize(&admin, &token0, &token1, &lp_token, &, &30_000, &20_000); + (contract_id, admin, token0, token1, lp_token) + } + + // โ”€โ”€ Invariant math โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_compute_d_basic() { + let env = Env::default(); + let _ = env; + // Equal reserves โ€” D should be โ‰ฅ x+y (Stableswap D is slightly above + // x+y when balanced, converging toward x+y as Aโ†’โˆž) + let x: i128 = 1_000_000; + let y: i128 = 1_000_000; + let d = StableSwap::compute_d(x, y, 100).unwrap(); + // For balanced equal reserves D >= x+y (amplification creates depth) + // and is bounded above by roughly 2*(x+y)/sqrt(A) for low A values + assert!(d >= x + y, "D={d} should be >= x+y={}", x + y); + // D should be reasonably close to x+y (within 2% for amp=100) + assert!(d < (x + y) * 102 / 100, "D={d} too far above x+y"); + } + + #[test] + fn test_compute_d_symmetric() { + let env = Env::default(); + let _ = env; // suppress unused-variable warning + // D must be the same regardless of which token is which (symmetry) + let d1 = StableSwap::compute_d(1_500_000, 500_000, 200).unwrap(); + let d2 = StableSwap::compute_d(500_000, 1_500_000, 200).unwrap(); + assert_eq!(d1, d2); + } + + #[test] + fn test_compute_d_increases_with_reserves() { + let env = Env::default(); + let _ = env; + let d_small = StableSwap::compute_d(1_000, 1_000, 100).unwrap(); + let d_large = StableSwap::compute_d(1_000_000, 1_000_000, 100).unwrap(); + assert!(d_large > d_small); + } + + #[test] + fn test_compute_d_edge_one_sided() { + let env = Env::default(); + let _ = env; + // Moderately imbalanced pool (1:4 ratio) still converges + let d = StableSwap::compute_d(500_000, 2_000_000, 50).unwrap(); + assert!(d > 0); + // D must lie between x+y and 2*(x+y) for valid pools + let sum: i128 = 500_000 + 2_000_000; + assert!(d >= sum / 2 && d <= sum * 2); + } + + // โ”€โ”€ Dynamic fee โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_dynamic_fee_balanced() { + let env = Env::default(); + let _ = env; + // Balanced pool โ†’ fee equals base fee + let fee = StableSwap::compute_dynamic_fee(1_000, 1_000, 30_000, 20_000).unwrap(); + assert_eq!(fee, 30_000); + } + + #[test] + fn test_dynamic_fee_imbalanced() { + let env = Env::default(); + let _ = env; + // Completely one-sided pool โ†’ imbalance_ratio = 1 โ†’ fee = base + multiplier + let fee = StableSwap::compute_dynamic_fee(1_000, 0, 30_000, 20_000); + // Division by zero is handled (total=0 returns base_fee) + let fee2 = StableSwap::compute_dynamic_fee(1_000_000, 0, 30_000, 20_000); + // Won't divide by zero for non-zero total + if let Ok(f) = fee2 { + assert!(f >= 30_000); + } + let _ = fee; + } + + #[test] + fn test_dynamic_fee_increases_with_imbalance() { + let env = Env::default(); + let _ = env; + let fee_balanced = StableSwap::compute_dynamic_fee(1_000, 1_000, 30_000, 50_000).unwrap(); + let fee_skewed = StableSwap::compute_dynamic_fee(1_800, 200, 30_000, 50_000).unwrap(); + assert!(fee_skewed > fee_balanced); + } + + // โ”€โ”€ Invariant error cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_compute_d_zero_reserve_returns_error() { + let env = Env::default(); + let _ = env; + let result = StableSwap::compute_d(0, 1_000, 100); + assert!(matches!(result, Err(StableSwapError::ZeroInvariant))); + } + + #[test] + fn test_initialize_invalid_amp() { + let env = Env::default(); + let contract_id = env.register(StableSwap, ()); + let client = StableSwapClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let t0 = Address::generate(&env); + let t1 = Address::generate(&env); + let lp = Address::generate(&env); + let result = client.try_initialize(&admin, &t0, &t1, &lp, &0, &30_000, &20_000); + assert!(result.is_err()); + } + + #[test] + fn test_initialize_too_high_fee() { + let env = Env::default(); + let contract_id = env.register(StableSwap, ()); + let client = StableSwapClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let t0 = Address::generate(&env); + let t1 = Address::generate(&env); + let lp = Address::generate(&env); + // base_fee > MAX_BASE_FEE_BPS โ†’ error + let result = client.try_initialize(&admin, &t0, &t1, &lp, &100, &2_000_000, &0); + assert!(result.is_err()); + } + + #[test] + fn test_double_initialize_rejected() { + let env = Env::default(); + let (contract_id, admin, t0, t1, lp) = setup_pool(&env, 100); + let client = StableSwapClient::new(&env, &contract_id); + let result = client.try_initialize(&admin, &t0, &t1, &lp, &100, &30_000, &20_000); + assert!(result.is_err()); + } +} diff --git a/contracts/stableswap/test_snapshots/tests/test_double_initialize_rejected.1.json b/contracts/stableswap/test_snapshots/tests/test_double_initialize_rejected.1.json new file mode 100644 index 0000000000..d7455a959a --- /dev/null +++ b/contracts/stableswap/test_snapshots/tests/test_double_initialize_rejected.1.json @@ -0,0 +1,219 @@ +{ + "generators": { + "address": 5, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AmpCoeff" + } + ] + }, + "val": { + "u32": 100 + } + }, + { + "key": { + "vec": [ + { + "symbol": "BaseFee" + } + ] + }, + "val": { + "u32": 30000 + } + }, + { + "key": { + "vec": [ + { + "symbol": "FeeMultiplier" + } + ] + }, + "val": { + "u32": 20000 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "LpToken" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Reserve0" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Reserve1" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + }, + { + "key": { + "vec": [ + { + "symbol": "Token0" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Token1" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "TotalSupply" + } + ] + }, + "val": { + "i128": { + "hi": 0, + "lo": 0 + } + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stableswap/test_snapshots/tests/test_initialize_invalid_amp.1.json b/contracts/stableswap/test_snapshots/tests/test_initialize_invalid_amp.1.json new file mode 100644 index 0000000000..cb6c83c52e --- /dev/null +++ b/contracts/stableswap/test_snapshots/tests/test_initialize_invalid_amp.1.json @@ -0,0 +1,76 @@ +{ + "generators": { + "address": 5, + "nonce": 0 + }, + "auth": [ + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stableswap/test_snapshots/tests/test_initialize_too_high_fee.1.json b/contracts/stableswap/test_snapshots/tests/test_initialize_too_high_fee.1.json new file mode 100644 index 0000000000..cb6c83c52e --- /dev/null +++ b/contracts/stableswap/test_snapshots/tests/test_initialize_too_high_fee.1.json @@ -0,0 +1,76 @@ +{ + "generators": { + "address": 5, + "nonce": 0 + }, + "auth": [ + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/Cargo.toml b/contracts/stealth_addresses/Cargo.toml new file mode 100644 index 0000000000..94360b8e11 --- /dev/null +++ b/contracts/stealth_addresses/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "stealth_addresses" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "22.0.0" + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/stealth_addresses/src/lib.rs b/contracts/stealth_addresses/src/lib.rs new file mode 100644 index 0000000000..8b65c3e22a --- /dev/null +++ b/contracts/stealth_addresses/src/lib.rs @@ -0,0 +1,487 @@ +#![no_std] + +//! # Stealth Addresses โ€” Private Yield Deposits +//! +//! Implements a stealth address scheme for privacy-preserving vault deposits on Stellar. +//! +//! ## Protocol overview +//! +//! 1. **Key registration** โ€” A recipient publishes their *stealth meta-address*, +//! which encodes two public keys: +//! - `spend_public_key` (Kse): used to derive the one-time spending address. +//! - `view_public_key` (Kv): used to check whether a deposit belongs to them. +//! +//! 2. **Sender generates a one-time address** โ€” +//! - Sample an ephemeral keypair (r, R = rยทG). +//! - Compute shared secret: `s = H(rยทKv)` (Diffie-Hellman in additive notation). +//! - Derive one-time address: `P = sยทG + Kse` (recipient-controlled spend key). +//! - Publish ephemeral public key R on-chain alongside the deposit. +//! +//! 3. **Recipient scans** โ€” +//! - For each on-chain ephemeral key R: +//! `s = H(vยทR)` (using their view private key v). +//! If `sยทG + Kse == P`, the deposit belongs to them. +//! +//! ## Soroban constraints +//! +//! Soroban does not expose elliptic-curve or Diffie-Hellman primitives natively. +//! This contract therefore stores and validates the **pre-computed derived values** +//! (one-time addresses and ephemeral keys) that are computed off-chain by the sender +//! and provides the on-chain registry + deposit routing logic. An off-chain SDK +//! performs the DH key generation using the Stellar Ed25519 curve. +//! +//! The contract enforces: +//! - Meta-address registry (one entry per user). +//! - Stealth deposit records linking an ephemeral public key to a one-time Stellar address. +//! - One-shot claim: only the holder of the private key corresponding to `one_time_address` +//! can authorize a `claim_deposit`. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Bytes, Env, +}; + +// โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Maximum metadata string length (bytes) +const MAX_META_LEN: u32 = 256; + +// โ”€โ”€ Storage keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[contracttype] +enum DataKey { + /// Admin of the registry + Admin, + /// Whether the contract is initialized + Initialized, + /// StealthMetaAddress for a user: DataKey::MetaAddress(owner) + MetaAddress(Address), + /// Deposit record: DataKey::Deposit(deposit_id) + Deposit(u64), + /// Counter for deposit IDs + NextDepositId, +} + +// โ”€โ”€ Domain types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// A stealth meta-address published by a recipient. +/// +/// Both public keys are 32-byte canonical Ed25519/Ristretto public keys, +/// encoded as Soroban `Bytes`. The on-chain representation is opaque; the +/// off-chain SDK interprets and validates them. +#[contracttype] +#[derive(Clone, Debug)] +pub struct StealthMetaAddress { + /// Ed25519 public key used by sender for DH key exchange (view key) + pub view_public_key: Bytes, + /// Ed25519 public key forming the base spending address + pub spend_public_key: Bytes, + /// Optional human-readable label / ENS-style handle (โ‰ค MAX_META_LEN bytes) + pub label: Bytes, +} + +/// A stealth deposit record written by the sender. +/// +/// The sender computes `one_time_address = H(rยทKv)ยทG + Kse` off-chain, +/// then calls `deposit` with: +/// - `one_time_address`: the derived Stellar address the funds are sent to. +/// - `ephemeral_public_key`: R = rยทG published so the recipient can scan. +/// - `view_tag`: first byte of `H(rยทKv)` โ€” lets recipients skip most entries +/// without full scalar multiplication (ERC-5564 view-tag optimisation). +#[contracttype] +#[derive(Clone, Debug)] +pub struct StealthDeposit { + pub id: u64, + /// The one-time Stellar address that receives the funds + pub one_time_address: Address, + /// Ephemeral public key R (32 bytes) + pub ephemeral_public_key: Bytes, + /// Single-byte view tag for efficient recipient scanning + pub view_tag: u32, + /// Token deposited + pub token: Address, + /// Amount deposited (still held by the contract) + pub amount: i128, + /// Timestamp of the deposit + pub created_at: u64, + /// Whether the deposit has been claimed + pub claimed: bool, +} + +// โ”€โ”€ Errors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum StealthError { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidPublicKey = 3, + InvalidAmount = 4, + DepositNotFound = 5, + AlreadyClaimed = 6, + Unauthorized = 7, + MetaLabelTooLong = 8, + MetaAddressNotFound = 9, +} + +// โ”€โ”€ Contract โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[contract] +pub struct StealthAddressRegistry; + +#[contractimpl] +impl StealthAddressRegistry { + // โ”€โ”€ Initialisation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + pub fn initialize(env: Env, admin: Address) -> Result<(), StealthError> { + if env.storage().instance().has(&DataKey::Initialized) { + return Err(StealthError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Initialized, &true); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::NextDepositId, &0_u64); + + env.events().publish((symbol_short!("init"),), (admin,)); + Ok(()) + } + + // โ”€โ”€ Meta-address registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Publish or update the caller's stealth meta-address. + /// + /// `view_public_key` and `spend_public_key` must each be exactly 32 bytes. + pub fn register_meta_address( + env: Env, + owner: Address, + view_public_key: Bytes, + spend_public_key: Bytes, + label: Bytes, + ) -> Result<(), StealthError> { + owner.require_auth(); + Self::assert_initialized(&env)?; + + if view_public_key.len() != 32 || spend_public_key.len() != 32 { + return Err(StealthError::InvalidPublicKey); + } + if label.len() > MAX_META_LEN { + return Err(StealthError::MetaLabelTooLong); + } + + let meta = StealthMetaAddress { + view_public_key: view_public_key.clone(), + spend_public_key: spend_public_key.clone(), + label, + }; + env.storage() + .persistent() + .set(&DataKey::MetaAddress(owner.clone()), &meta); + + env.events().publish( + (symbol_short!("reg_meta"),), + (owner, view_public_key, spend_public_key), + ); + Ok(()) + } + + /// Look up a registered stealth meta-address. + pub fn get_meta_address(env: Env, owner: Address) -> Result { + env.storage() + .persistent() + .get(&DataKey::MetaAddress(owner)) + .ok_or(StealthError::MetaAddressNotFound) + } + + // โ”€โ”€ Deposit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Create a stealth deposit. + /// + /// The sender: + /// 1. Fetches the recipient's `StealthMetaAddress` from `get_meta_address`. + /// 2. Off-chain: picks ephemeral keypair (r, R=rยทG), computes + /// `s = H(r ยท view_public_key)`, `one_time_address = sยทG + spend_public_key`, + /// and `view_tag = s[0]`. + /// 3. Calls `deposit` with those values; funds flow from `sender` โ†’ contract. + /// + /// The full ephemeral key is stored on-chain so any receiver can scan. + pub fn deposit( + env: Env, + sender: Address, + one_time_address: Address, + ephemeral_public_key: Bytes, + view_tag: u32, + token: Address, + amount: i128, + ) -> Result { + sender.require_auth(); + Self::assert_initialized(&env)?; + + if amount <= 0 { + return Err(StealthError::InvalidAmount); + } + if ephemeral_public_key.len() != 32 { + return Err(StealthError::InvalidPublicKey); + } + + // Pull funds from sender into the contract escrow + token::Client::new(&env, &token).transfer( + &sender, + &env.current_contract_address(), + &amount, + ); + + // Mint deposit record + let deposit_id: u64 = env + .storage() + .instance() + .get(&DataKey::NextDepositId) + .unwrap_or(0); + let next_id = deposit_id + 1; + env.storage() + .instance() + .set(&DataKey::NextDepositId, &next_id); + + let record = StealthDeposit { + id: deposit_id, + one_time_address: one_time_address.clone(), + ephemeral_public_key: ephemeral_public_key.clone(), + view_tag, + token: token.clone(), + amount, + created_at: env.ledger().timestamp(), + claimed: false, + }; + env.storage() + .persistent() + .set(&DataKey::Deposit(deposit_id), &record); + + env.events().publish( + (symbol_short!("deposit"),), + ( + deposit_id, + one_time_address, + ephemeral_public_key, + view_tag, + token, + amount, + ), + ); + Ok(deposit_id) + } + + // โ”€โ”€ Claim โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Claim a stealth deposit. + /// + /// Only the holder of the private key for `one_time_address` can call this + /// (enforced by `require_auth`). Funds are forwarded to `recipient`. + pub fn claim_deposit( + env: Env, + claimer: Address, + deposit_id: u64, + recipient: Address, + ) -> Result { + claimer.require_auth(); + Self::assert_initialized(&env)?; + + let mut record: StealthDeposit = env + .storage() + .persistent() + .get(&DataKey::Deposit(deposit_id)) + .ok_or(StealthError::DepositNotFound)?; + + if record.claimed { + return Err(StealthError::AlreadyClaimed); + } + + // Only the holder of the one-time address private key may claim + if claimer != record.one_time_address { + return Err(StealthError::Unauthorized); + } + + record.claimed = true; + env.storage() + .persistent() + .set(&DataKey::Deposit(deposit_id), &record); + + // Transfer funds to recipient + token::Client::new(&env, &record.token).transfer( + &env.current_contract_address(), + &recipient, + &record.amount, + ); + + env.events().publish( + (symbol_short!("claim"),), + (deposit_id, claimer, recipient, record.amount), + ); + Ok(record.amount) + } + + // โ”€โ”€ View helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + pub fn get_deposit(env: Env, deposit_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Deposit(deposit_id)) + .ok_or(StealthError::DepositNotFound) + } + + pub fn get_next_deposit_id(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::NextDepositId) + .unwrap_or(0) + } + + // โ”€โ”€ Internal helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + fn assert_initialized(env: &Env) -> Result<(), StealthError> { + if !env.storage().instance().has(&DataKey::Initialized) { + return Err(StealthError::NotInitialized); + } + Ok(()) + } +} + +// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Bytes, Env}; + + fn make_32_bytes(env: &Env, fill: u8) -> Bytes { + let mut b = Bytes::new(env); + for _ in 0..32 { + b.push_back(fill); + } + b + } + + fn deploy(env: &Env) -> (Address, Address) { + let admin = Address::generate(env); + let contract_id = env.register(StealthAddressRegistry, ()); + let client = StealthAddressRegistryClient::new(env, &contract_id); + client.initialize(&admin); + (contract_id, admin) + } + + // โ”€โ”€ Initialization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_double_init_rejected() { + let env = Env::default(); + let (contract_id, admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + let result = client.try_initialize(&admin); + assert!(result.is_err()); + } + + // โ”€โ”€ Meta-address โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_register_and_get_meta_address() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let vpk = make_32_bytes(&env, 0xAA); + let spk = make_32_bytes(&env, 0xBB); + let label = Bytes::from_slice(&env, b"alice.stealth"); + + client.register_meta_address(&owner, &vpk, &spk, &label); + + let meta = client.get_meta_address(&owner); + assert_eq!(meta.view_public_key, vpk); + assert_eq!(meta.spend_public_key, spk); + } + + #[test] + fn test_register_wrong_key_length_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let short_key = Bytes::from_slice(&env, b"tooshort"); + let good_key = make_32_bytes(&env, 0xCC); + let label = Bytes::new(&env); + + let result = client.try_register_meta_address(&owner, &short_key, &good_key, &label); + assert!(result.is_err()); + } + + #[test] + fn test_get_nonexistent_meta_address_errors() { + let env = Env::default(); + let (contract_id, _admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + let nobody = Address::generate(&env); + let result = client.try_get_meta_address(&nobody); + assert!(result.is_err()); + } + + // โ”€โ”€ Deposit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_deposit_id_increments() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + + assert_eq!(client.get_next_deposit_id(), 0); + } + + #[test] + fn test_deposit_invalid_amount() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + + let sender = Address::generate(&env); + let one_time = Address::generate(&env); + let epk = make_32_bytes(&env, 0x01); + let token = Address::generate(&env); + + let result = client.try_deposit(&sender, &one_time, &epk, &0_u32, &token, &0_i128); + assert!(result.is_err()); + } + + #[test] + fn test_deposit_bad_ephemeral_key_length() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + + let sender = Address::generate(&env); + let one_time = Address::generate(&env); + let bad_epk = Bytes::from_slice(&env, b"short"); + let token = Address::generate(&env); + + let result = client.try_deposit(&sender, &one_time, &bad_epk, &0_u32, &token, &1_000_i128); + assert!(result.is_err()); + } + + // โ”€โ”€ Claim โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn test_claim_nonexistent_deposit_errors() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin) = deploy(&env); + let client = StealthAddressRegistryClient::new(&env, &contract_id); + + let claimer = Address::generate(&env); + let recipient = Address::generate(&env); + let result = client.try_claim_deposit(&claimer, &9999_u64, &recipient); + assert!(result.is_err()); + } +} diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_claim_nonexistent_deposit_errors.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_claim_nonexistent_deposit_errors.1.json new file mode 100644 index 0000000000..6ab697acf9 --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_claim_nonexistent_deposit_errors.1.json @@ -0,0 +1,114 @@ +{ + "generators": { + "address": 4, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_deposit_bad_ephemeral_key_length.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_deposit_bad_ephemeral_key_length.1.json new file mode 100644 index 0000000000..adb2ce1fe4 --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_deposit_bad_ephemeral_key_length.1.json @@ -0,0 +1,114 @@ +{ + "generators": { + "address": 5, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_deposit_id_increments.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_deposit_id_increments.1.json new file mode 100644 index 0000000000..bb27cbd23c --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_deposit_id_increments.1.json @@ -0,0 +1,114 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_deposit_invalid_amount.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_deposit_invalid_amount.1.json new file mode 100644 index 0000000000..adb2ce1fe4 --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_deposit_invalid_amount.1.json @@ -0,0 +1,114 @@ +{ + "generators": { + "address": 5, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_double_init_rejected.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_double_init_rejected.1.json new file mode 100644 index 0000000000..bb27cbd23c --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_double_init_rejected.1.json @@ -0,0 +1,114 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_get_nonexistent_meta_address_errors.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_get_nonexistent_meta_address_errors.1.json new file mode 100644 index 0000000000..5d0091e6f4 --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_get_nonexistent_meta_address_errors.1.json @@ -0,0 +1,114 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_register_and_get_meta_address.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_register_and_get_meta_address.1.json new file mode 100644 index 0000000000..602d7e49e0 --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_register_and_get_meta_address.1.json @@ -0,0 +1,245 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "function_name": "register_meta_address", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "bytes": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "bytes": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + { + "bytes": "616c6963652e737465616c7468" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "MetaAddress" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "symbol": "MetaAddress" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "label" + }, + "val": { + "bytes": "616c6963652e737465616c7468" + } + }, + { + "key": { + "symbol": "spend_public_key" + }, + "val": { + "bytes": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + { + "key": { + "symbol": "view_public_key" + }, + "val": { + "bytes": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": 801925984706572462 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/stealth_addresses/test_snapshots/tests/test_register_wrong_key_length_rejected.1.json b/contracts/stealth_addresses/test_snapshots/tests/test_register_wrong_key_length_rejected.1.json new file mode 100644 index 0000000000..5d0091e6f4 --- /dev/null +++ b/contracts/stealth_addresses/test_snapshots/tests/test_register_wrong_key_length_rejected.1.json @@ -0,0 +1,114 @@ +{ + "generators": { + "address": 3, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Initialized" + } + ] + }, + "val": { + "bool": true + } + }, + { + "key": { + "vec": [ + { + "symbol": "NextDepositId" + } + ] + }, + "val": { + "u64": 0 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file