diff --git a/.github/workflows/soroban-contract.yml b/.github/workflows/soroban-contract.yml deleted file mode 100644 index cae6993..0000000 --- a/.github/workflows/soroban-contract.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Soroban contract - -on: - pull_request: - paths: ['contract/**', '.github/workflows/soroban-contract.yml'] - push: - branches: [main] - paths: ['contract/**', '.github/workflows/soroban-contract.yml'] - -jobs: - contract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32v1-none - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - with: - workspaces: contract - - name: Format - run: cargo fmt --manifest-path contract/Cargo.toml --all -- --check - - name: Unit tests - run: cargo test --manifest-path contract/Cargo.toml --locked - - name: Clippy - run: cargo clippy --manifest-path contract/Cargo.toml --all-targets --locked -- -D warnings - - name: Build Wasm - run: cargo build --manifest-path contract/Cargo.toml --package save-savings-vault --target wasm32v1-none --release --locked - diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..6e4b657 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,109 @@ +name: Verify SAVE + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + schedule: + - cron: '17 3 * * *' + +permissions: + contents: read + +jobs: + environment: + name: Environment examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run env:validate -- --examples --skip-tools + + mobile: + name: Mobile lint and types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm ci --prefix backend + - run: npm run lint + - run: npm run typecheck + - run: npm run backend:lint + + backend: + name: Backend build and tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: backend/package-lock.json + - run: npm ci + - run: npm test + + admin: + name: Admin lint and build + runs-on: ubuntu-latest + defaults: + run: + working-directory: admin + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: admin/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run build + + contract: + name: Soroban checks and Wasm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32v1-none + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: contract + - run: cargo fmt --manifest-path contract/Cargo.toml --all -- --check + - run: cargo test --manifest-path contract/Cargo.toml --locked + - run: cargo clippy --manifest-path contract/Cargo.toml --all-targets --locked -- -D warnings + - run: cargo build --manifest-path contract/Cargo.toml --package save-savings-vault --target wasm32v1-none --release --locked + - uses: actions/upload-artifact@v4 + with: + name: save-savings-vault-wasm + path: contract/target/wasm32v1-none/release/save_savings_vault.wasm + if-no-files-found: error + + testnet-health: + name: Deployed Testnet health + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - uses: stellar/stellar-cli@v27.0.0 + - run: npm run health:testnet diff --git a/.gitignore b/.gitignore index 0b69b2a..c697b2a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.* # local env files .env*.local *.env +!**/.env.example /backend/.stellar_integrity_signer_secret # typescript diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..d5b283a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.13.1 diff --git a/README.md b/README.md index 7715880..77a9556 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,50 @@ # SAVE Finance +[![Verify SAVE](https://github.com/FWDP/save-project/actions/workflows/verify.yml/badge.svg)](https://github.com/FWDP/save-project/actions/workflows/verify.yml) + ### Personal finance and goal-based saving, with optional Stellar rails Track spending. Plan budgets. Build savings on Stellar Testnet without giving up custody of your wallet. SAVE brings everyday money management, receipt capture, savings goals, and externally signed Stellar transactions into one mobile-first workspace. A NestJS API supports the app, while a Next.js dashboard provides an operational view. -[Explore the repository](https://github.com/FWDP/save-project) · [Stellar architecture](docs/STELLAR_ARCHITECTURE.md) · [Savings vault contract](docs/CONTRACT.md) · [Test evidence](docs/DELIVERABLES_1_2_TEST_REPORT.md) +[Explore the repository](https://github.com/FWDP/save-project) · [Reviewer runbook](docs/REVIEWER_RUNBOOK.md) · [Stellar architecture](docs/STELLAR_ARCHITECTURE.md) · [Verification evidence](docs/DELIVERABLE_4_EVIDENCE.md) > [!IMPORTANT] > SAVE's blockchain features are alpha software for **Stellar Testnet only**. The project does not support Mainnet or real-value custody. SAVE never asks for or stores a wallet secret seed; an external wallet must approve every transaction. --- +## 📱 SAVE on Mobile + +A tour of SAVE's mobile experience, from everyday money management to non-custodial Stellar Testnet savings. Screenshots show demo financial data from an Android development build. + + + + + + + + + + + + + + + + + + + + + + + + +
Financial dashboardExpense tracking
SAVE financial dashboard with income, expenses, balance, and monthly spending insightsSAVE expense list with search, date, and category filters
Budget progressSavings goals
SAVE category budgets with spending progressSAVE savings goals with Stellar Testnet vault access
Non-custodial Stellar savings
SAVE Stellar Testnet savings screen with a connected Freighter Mobile signer
+ ## 🧩 Why SAVE Personal finances are often split across expense trackers, spreadsheets, receipt folders, bank apps, and crypto wallets. That fragmentation makes it difficult to connect daily spending decisions with longer-term savings goals. @@ -93,7 +124,7 @@ The browser or mobile client cannot declare a transaction successful. SAVE recon | Contract | Address | Explorer | | --- | --- | --- | -| SAVE Savings Vault | `CALFEOYNTNJYB5HTUPYHHFHMNNYLYEBOCV43Z73J54G3CQNSH5VCNP7H` | [View on Stellar Expert](https://stellar.expert/explorer/testnet/contract/CALFEOYNTNJYB5HTUPYHHFHMNNYLYEBOCV43Z73J54G3CQNSH5VCNP7H) | +| SAVE Savings Vault | `CDYPVKFWSPHKGDHZ77M2T2TZPCS3LVXDFJDH5PERL5HTUNVFYSTB7AG3` | [View on Stellar Expert](https://stellar.expert/explorer/testnet/contract/CDYPVKFWSPHKGDHZ77M2T2TZPCS3LVXDFJDH5PERL5HTUNVFYSTB7AG3) | | Native XLM SAC | `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` | [View on Stellar Expert](https://stellar.expert/explorer/testnet/contract/CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC) | These are public Testnet identifiers, not credentials. Deployments may be replaced as SAVE evolves; update the backend environment and contract documentation together after any redeployment. @@ -113,10 +144,10 @@ These are public Testnet identifiers, not credentials. Deployments may be replac ### Prerequisites -- Node.js LTS and npm. +- Node.js 22.13+ and npm 10+ (`.nvmrc` pins the reviewer version). - Docker with Docker Compose. - Android Studio or Xcode for a native development build. -- Rust and the Stellar CLI for contract development. +- Rust/Cargo stable 1.90+ and Stellar CLI 27.x for contract development. - A funded Stellar Testnet account and Freighter Mobile for the complete signing flow. - A public WalletConnect project ID. @@ -125,9 +156,9 @@ These are public Testnet identifiers, not credentials. Deployments may be replac From the repository root: ```bash -npm install -npm --prefix backend install -npm --prefix admin install +npm ci +npm --prefix backend ci +npm --prefix admin ci ``` ### 2. Configure the environments @@ -135,12 +166,19 @@ npm --prefix admin install ```bash cp .env.example .env cp backend/.env.example backend/.env +cp admin/.env.example admin/.env.local ``` Set `EXPO_PUBLIC_API_URL` to a backend URL reachable by the phone, such as `http://192.168.1.25:3000`. Add your public `EXPO_PUBLIC_WALLETCONNECT_PROJECT_ID`; values prefixed with `EXPO_PUBLIC_` are bundled into the client and must never contain secrets. Review the local MongoDB, Redis, MinIO, JWT, Stellar Testnet, callback, and contract settings in `backend/.env` before starting the API. The admin app can use `admin/.env.local` for its own API configuration. +Validate the configuration and installed tool versions before continuing: + +```bash +npm run env:validate +``` + ### 3. Start local services ```bash @@ -181,29 +219,25 @@ In the app, open **More → Stellar Testnet → Connect Freighter Mobile**. Use ## 🧪 Test and Validate -Run the application checks from the repository root: +Run the complete local verification gate from the repository root: ```bash -npm run lint -npx tsc --noEmit -npm --prefix backend test -npm run admin:build +npm run verify ``` -Run the Soroban contract suite and build its Wasm artifact: +Verify the live Testnet services, deployed contract, and exact Wasm hash: ```bash -npm run contract:test -npm run contract:build +npm run health:testnet ``` -The contract artifact is written to `contract/target/wasm32v1-none/release/save_savings_vault.wasm`. See the [test report](docs/DELIVERABLES_1_2_TEST_REPORT.md) for repeatable runtime checks and existing Testnet evidence. +After starting the API and admin app, add `--services` to verify their connectivity. The contract artifact is written to `contract/target/wasm32v1-none/release/save_savings_vault.wasm`. See the [reviewer runbook](docs/REVIEWER_RUNBOOK.md) for the clean-checkout process and the [Deliverable 4 evidence package](docs/DELIVERABLE_4_EVIDENCE.md) for expected outputs. ## 🚢 Deploy the Savings Vault Contract deployment is an explicit operator action. Use a funded Stellar CLI identity—never put its secret key in a command, environment file, application, or repository. -Read the [contract deployment guide](docs/CONTRACT.md) before deploying. It documents the build artifact, current Wasm hash, deployment command, configuration update, and pre-Mainnet security requirements. +Read the [contract deployment guide](docs/CONTRACT.md) before deploying. It documents the build artifact, immutable Native XLM SAC constructor allowlist, current Wasm hash, deployment command, and configuration update. > [!CAUTION] > A working Testnet deployment is not evidence of Mainnet readiness. Independent review, stronger invariant testing, recovery procedures, key custody, asset allowlists, operational controls, and legal review are required before real-value use. @@ -230,6 +264,8 @@ Read the [contract deployment guide](docs/CONTRACT.md) before deploying. It docu - [Stellar architecture](docs/STELLAR_ARCHITECTURE.md) — network decisions, trust boundaries, reconciliation, and non-goals. - [Savings vault contract](docs/CONTRACT.md) — interface, events, deployment, and security review. - [Deliverables 1–2 test report](docs/DELIVERABLES_1_2_TEST_REPORT.md) — acceptance evidence and repeatable verification. +- [Deliverable 4 evidence](docs/DELIVERABLE_4_EVIDENCE.md) — CI matrix, deployed contract proof, health checks, and negative-path coverage. +- [Independent reviewer runbook](docs/REVIEWER_RUNBOOK.md) — clean-checkout setup and end-to-end verification. - [Combined Stellar implementation](docs/COMBINED_SOROBAN_STELLAR_IMPLEMENTATION.md) — implementation context across the app, API, and contract. ## ⚠️ Alpha Boundaries diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 0000000..2a539bc --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,4 @@ +# Server-side backend URL used by the admin health endpoint. +SAVE_API_URL=http://localhost:3000 +# URL where the reviewer runs the admin app. +SAVE_ADMIN_URL=http://localhost:3001 diff --git a/admin/.gitignore b/admin/.gitignore index 5ef6a52..7b8da95 100644 --- a/admin/.gitignore +++ b/admin/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/admin/next.config.ts b/admin/next.config.ts index e9ffa30..fd31f7f 100644 --- a/admin/next.config.ts +++ b/admin/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + experimental: { + // The JavaScript compiler API keeps clean-checkout verification reliable + // in restricted runners while preserving full TypeScript build checks. + useTypeScriptCli: false, + }, }; export default nextConfig; diff --git a/admin/src/app/api/health/route.ts b/admin/src/app/api/health/route.ts new file mode 100644 index 0000000..aa6c8e7 --- /dev/null +++ b/admin/src/app/api/health/route.ts @@ -0,0 +1,30 @@ +const BACKEND_URL = (process.env.SAVE_API_URL ?? 'http://localhost:3000').replace(/\/+$/, ''); + +export async function GET() { + try { + const response = await fetch(`${BACKEND_URL}/health`, { + cache: 'no-store', + signal: AbortSignal.timeout(10_000), + }); + const backend = await response.json(); + return Response.json( + { + status: response.ok ? 'ok' : 'degraded', + service: 'save-admin', + backendUrl: BACKEND_URL, + backend, + }, + { status: response.ok ? 200 : 503 }, + ); + } catch (error) { + return Response.json( + { + status: 'degraded', + service: 'save-admin', + backendUrl: BACKEND_URL, + error: error instanceof Error ? error.message : 'backend health request failed', + }, + { status: 503 }, + ); + } +} diff --git a/backend/.env.example b/backend/.env.example index eb3875c..be8a04c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,8 +17,10 @@ STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org STELLAR_RPC_URL=https://soroban-testnet.stellar.org STELLAR_FRIENDBOT_URL=https://friendbot.stellar.org -STELLAR_VAULT_CONTRACT_ID=CALFEOYNTNJYB5HTUPYHHFHMNNYLYEBOCV43Z73J54G3CQNSH5VCNP7H +STELLAR_VAULT_CONTRACT_ID=CDYPVKFWSPHKGDHZ77M2T2TZPCS3LVXDFJDH5PERL5HTUNVFYSTB7AG3 STELLAR_XLM_SAC_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# SHA-256 of the Wasm installed for STELLAR_VAULT_CONTRACT_ID. +STELLAR_VAULT_WASM_HASH=0977e310e0f296e8811774ee74800367053879f809cc6ebbb49dca0816b8587f # Maximum assembled Soroban fee in stroops (1000000000 = 100 XLM). STELLAR_MAX_SOROBAN_FEE=1000000000 STELLAR_EVENT_POLL_MS=15000 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 032f779..b9494e4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { TransactionsModule } from './transactions/transactions.module'; import { UsersModule } from './users/users.module'; import { SavingsModule } from './savings/savings.module'; import { StellarModule } from './stellar/stellar.module'; +import { HealthController } from './health.controller'; @Module({ imports: [ @@ -33,7 +34,7 @@ import { StellarModule } from './stellar/stellar.module'; SavingsModule, StellarModule, ], - controllers: [], + controllers: [HealthController], providers: [], }) export class AppModule {} diff --git a/backend/src/health.controller.ts b/backend/src/health.controller.ts new file mode 100644 index 0000000..7f053dd --- /dev/null +++ b/backend/src/health.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; + +import { StellarService } from './stellar/stellar.service'; + +@Controller('health') +export class HealthController { + constructor(private readonly stellar: StellarService) {} + + @Get() + async health() { + return { + status: 'ok', + service: 'save-api', + stellar: await this.stellar.networkHealth(), + }; + } +} diff --git a/backend/src/stellar/stellar.assets.ts b/backend/src/stellar/stellar.assets.ts new file mode 100644 index 0000000..a2fae1a --- /dev/null +++ b/backend/src/stellar/stellar.assets.ts @@ -0,0 +1,13 @@ +import { BadRequestException } from '@nestjs/common'; +import { StrKey } from '@stellar/stellar-sdk'; + +export function assertAllowedVaultAsset(requestedAsset: string | undefined, allowedAsset: string): string { + const asset = requestedAsset?.trim() || allowedAsset; + if (!StrKey.isValidContract(asset)) { + throw new BadRequestException('A valid SAC assetContractId is required'); + } + if (asset !== allowedAsset) { + throw new BadRequestException('Only the configured Native XLM SAC is supported by this Testnet alpha'); + } + return asset; +} diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts index 4d83bdd..3f5836c 100644 --- a/backend/src/stellar/stellar.service.ts +++ b/backend/src/stellar/stellar.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, Injectable, OnModuleDestroy, On import { ConfigService } from '@nestjs/config'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; +import { Buffer } from 'node:buffer'; import { Address, Asset, @@ -23,6 +24,7 @@ import { LinkStellarAccountDto, PreparePaymentDto, PrepareVaultInvocationDto, St import { assertMatchingSignedTransaction, buildSep7SigningUrl, loadIntegritySigner, transactionHash } from './stellar.sep7'; import { StellarAccount, StellarAccountDocument, StellarContractEvent, StellarContractEventDocument, StellarSigningRequest, StellarSigningRequestDocument } from './stellar.schema'; import { SavingsService } from '../savings/savings.service'; +import { assertAllowedVaultAsset } from './stellar.assets'; type SigningRequest = { idempotencyKey: string; @@ -56,6 +58,7 @@ export class StellarService implements OnModuleInit, OnModuleDestroy { private readonly networkPassphrase: string; private readonly vaultContractId?: string; private readonly xlmSacId: string; + private readonly expectedVaultWasmHash?: string; private readonly maxSorobanFee: bigint; private readonly callbackBaseUrl?: string; private readonly originDomain?: string; @@ -80,6 +83,7 @@ export class StellarService implements OnModuleInit, OnModuleDestroy { this.networkPassphrase = config.get('STELLAR_NETWORK_PASSPHRASE') ?? Networks.TESTNET; this.vaultContractId = config.get('STELLAR_VAULT_CONTRACT_ID'); this.xlmSacId = config.get('STELLAR_XLM_SAC_ID') ?? Asset.native().contractId(this.networkPassphrase); + this.expectedVaultWasmHash = config.get('STELLAR_VAULT_WASM_HASH')?.trim().toLowerCase() || undefined; const configuredMaxSorobanFee = String(config.get('STELLAR_MAX_SOROBAN_FEE') ?? '1000000000').trim(); if (!/^\d+$/.test(configuredMaxSorobanFee) || BigInt(configuredMaxSorobanFee) <= 0n) { throw new Error('STELLAR_MAX_SOROBAN_FEE must be a positive integer in stroops'); @@ -116,16 +120,41 @@ export class StellarService implements OnModuleInit, OnModuleDestroy { async networkHealth() { try { - const [network, ledger] = await Promise.all([this.rpc.getNetwork(), this.rpc.getLatestLedger()]); + const contractId = this.requireVaultContract(); + const [network, ledger, horizonLedger, instance, allowedAsset] = await Promise.all([ + this.rpc.getNetwork(), + this.rpc.getLatestLedger(), + this.horizon.ledgers().order('desc').limit(1).call(), + this.rpc.getContractInstance(contractId), + this.rpc.queryContract(contractId, 'allowed_asset', {}, this.networkPassphrase), + ]); + if (network.passphrase !== Networks.TESTNET || this.networkPassphrase !== Networks.TESTNET) { + throw new Error('configured network is not Stellar Testnet'); + } + if (allowedAsset.result !== this.xlmSacId) { + throw new Error('deployed vault allowlist does not match STELLAR_XLM_SAC_ID'); + } + if (instance.executable.type !== 'contractExecutableWasm') { + throw new Error('vault contract is not backed by Wasm'); + } + const deployedWasmHash = Buffer.from(instance.executable.wasmHash.toBytes()).toString('hex'); + if (this.expectedVaultWasmHash && deployedWasmHash !== this.expectedVaultWasmHash) { + throw new Error('deployed vault Wasm hash does not match STELLAR_VAULT_WASM_HASH'); + } return { + status: 'ok', network: 'testnet', passphrase: network.passphrase, protocolVersion: network.protocolVersion, latestLedger: ledger.sequence, + horizonLatestLedger: horizonLedger.records[0]?.sequence ?? null, horizonUrl: this.horizonUrl, rpcUrl: this.rpcUrl, - vaultContractId: this.vaultContractId ?? null, + vaultContractId: contractId, + vaultWasmHash: deployedWasmHash, + expectedVaultWasmHash: this.expectedVaultWasmHash ?? null, xlmSacId: this.xlmSacId, + vaultAllowedAsset: allowedAsset.result, sep7: { callbackEnabled: Boolean(this.callbackBaseUrl), requestSigningEnabled: Boolean(this.originDomain && this.integritySigner), @@ -362,8 +391,7 @@ export class StellarService implements OnModuleInit, OnModuleDestroy { const amount = (value = dto.amount) => { if (!value || BigInt(value) <= 0n) throw new BadRequestException('A positive amount is required'); return nativeToScVal(BigInt(value), { type: 'i128' }); }; if (dto.action === 'create_goal') { const owner = dto.owner ?? dto.source; this.assertAccount(owner); - const assetContractId = dto.assetContractId || this.xlmSacId; - if (!StrKey.isValidContract(assetContractId)) throw new BadRequestException('A valid SAC assetContractId is required'); + const assetContractId = assertAllowedVaultAsset(dto.assetContractId, this.xlmSacId); return [new Address(owner).toScVal(), new Address(assetContractId).toScVal(), amount(dto.targetAmount), dto.targetDate ? nativeToScVal(BigInt(dto.targetDate), { type: 'u64' }) : nativeToScVal(null)]; } if (dto.action === 'contribute') { const contributor = dto.contributor ?? dto.source; this.assertAccount(contributor); return [id(), new Address(contributor).toScVal(), amount()]; } diff --git a/backend/test/stellar.sep7.test.cjs b/backend/test/stellar.sep7.test.cjs index ea5bb27..f4c2919 100644 --- a/backend/test/stellar.sep7.test.cjs +++ b/backend/test/stellar.sep7.test.cjs @@ -8,6 +8,7 @@ const { signSep7Uri, transactionHash, } = require('../dist/stellar/stellar.sep7'); +const { assertAllowedVaultAsset } = require('../dist/stellar/stellar.assets'); function transaction(keypair, sequence, value) { return new TransactionBuilder(new Account(keypair.publicKey(), sequence), { @@ -71,3 +72,24 @@ test('signs SEP-7 origin requests with the dedicated integrity key', () => { assert.equal(signSep7Uri(unsignedUri, integritySigner), signature.toString('base64')); assert.equal(integritySigner.verify(payload, signature), true); }); + +test('binds the prepared transaction hash to the Stellar network passphrase', () => { + const signer = Keypair.random(); + const prepared = transaction(signer, '1', 'testnet'); + assert.notEqual( + transactionHash(prepared.toXDR(), Networks.TESTNET), + transactionHash(prepared.toXDR(), Networks.PUBLIC), + ); +}); + +test('allows only the configured Native XLM SAC for vault creation', () => { + const nativeXlmSac = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; + const unsupportedSac = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + + assert.equal(assertAllowedVaultAsset(undefined, nativeXlmSac), nativeXlmSac); + assert.equal(assertAllowedVaultAsset(nativeXlmSac, nativeXlmSac), nativeXlmSac); + assert.throws( + () => assertAllowedVaultAsset(unsupportedSac, nativeXlmSac), + /Only the configured Native XLM SAC/, + ); +}); diff --git a/contract/savings-vault/src/lib.rs b/contract/savings-vault/src/lib.rs index 7b89c82..c31f616 100644 --- a/contract/savings-vault/src/lib.rs +++ b/contract/savings-vault/src/lib.rs @@ -80,6 +80,7 @@ pub struct GoalCancelled { #[contracttype] enum DataKey { Counter, + AllowedAsset, Goal(u128), OwnerGoals(Address), } @@ -96,6 +97,7 @@ pub enum VaultError { CompletionConditionsNotMet = 6, InsufficientBalance = 7, ArithmeticOverflow = 8, + UnsupportedAsset = 9, } #[contract] @@ -103,6 +105,21 @@ pub struct SavingsVault; #[contractimpl] impl SavingsVault { + pub fn __constructor(env: Env, allowed_asset: Address) { + env.storage() + .instance() + .set(&DataKey::AllowedAsset, &allowed_asset); + bump_instance(&env); + } + + pub fn allowed_asset(env: Env) -> Address { + bump_instance(&env); + env.storage() + .instance() + .get(&DataKey::AllowedAsset) + .expect("allowed asset must be configured at deployment") + } + pub fn create_goal( env: Env, owner: Address, @@ -117,6 +134,9 @@ impl SavingsVault { if target_date.is_some_and(|deadline| deadline <= env.ledger().timestamp()) { return Err(VaultError::InvalidTargetDate); } + if asset != Self::allowed_asset(env.clone()) { + return Err(VaultError::UnsupportedAsset); + } bump_instance(&env); let id = env diff --git a/contract/savings-vault/src/test.rs b/contract/savings-vault/src/test.rs index 6d98f67..8a328ac 100644 --- a/contract/savings-vault/src/test.rs +++ b/contract/savings-vault/src/test.rs @@ -2,7 +2,7 @@ extern crate std; use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger}, + testutils::{storage::Instance as _, Address as _, Ledger}, token, Address, Env, }; @@ -24,7 +24,7 @@ fn fixture() -> Fixture { let asset = env .register_stellar_asset_contract_v2(admin.clone()) .address(); - let contract = env.register(SavingsVault, ()); + let contract = env.register(SavingsVault, SavingsVaultArgs::__constructor(&asset)); let client = SavingsVaultClient::new(&env, &contract); let token = token::Client::new(&env, &asset); let token_admin = token::StellarAssetClient::new(&env, &asset); @@ -131,3 +131,87 @@ fn rejects_invalid_lifecycle_actions() { Err(Ok(VaultError::GoalNotActive)) ); } + +#[test] +fn requires_owner_authorization() { + let env = Env::default(); + env.ledger().set_timestamp(1_800_000_000); + let owner = Address::generate(&env); + let asset = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + let contract = env.register(SavingsVault, SavingsVaultArgs::__constructor(&asset)); + let client = SavingsVaultClient::new(&env, &contract); + + assert!(client + .try_create_goal(&owner, &asset, &10_000, &None) + .is_err()); +} + +#[test] +fn rejects_expired_target_dates() { + let f = fixture(); + assert_eq!( + f.client + .try_create_goal(&f.owner, &f.token.address, &10_000, &Some(1_800_000_000)), + Err(Ok(VaultError::InvalidTargetDate)) + ); +} + +#[test] +fn rejects_assets_outside_the_deployment_allowlist() { + let f = fixture(); + let unsupported = f + .env + .register_stellar_asset_contract_v2(Address::generate(&f.env)) + .address(); + + assert_eq!(f.client.allowed_asset(), f.token.address); + assert_eq!( + f.client + .try_create_goal(&f.owner, &unsupported, &10_000, &None), + Err(Ok(VaultError::UnsupportedAsset)) + ); +} + +#[test] +fn owner_index_is_isolated_and_complete() { + let f = fixture(); + let second_owner = Address::generate(&f.env); + f.client + .create_goal(&f.owner, &f.token.address, &10_000, &None); + f.client + .create_goal(&f.owner, &f.token.address, &20_000, &None); + f.client + .create_goal(&second_owner, &f.token.address, &30_000, &None); + + let first_owner_goals = f.client.list_goals(&f.owner); + let second_owner_goals = f.client.list_goals(&second_owner); + assert_eq!(first_owner_goals.len(), 2); + assert_eq!(first_owner_goals.get(0).unwrap().id, 1); + assert_eq!(first_owner_goals.get(1).unwrap().id, 2); + assert_eq!(second_owner_goals.len(), 1); + assert_eq!(second_owner_goals.get(0).unwrap().id, 3); +} + +#[test] +fn contract_activity_extends_instance_ttl() { + let f = fixture(); + let initial_ttl = f + .env + .as_contract(&f.client.address, || f.env.storage().instance().get_ttl()); + f.env + .ledger() + .set_sequence_number(initial_ttl.saturating_sub(INSTANCE_THRESHOLD) + 1); + let near_expiry_ttl = f + .env + .as_contract(&f.client.address, || f.env.storage().instance().get_ttl()); + f.client + .create_goal(&f.owner, &f.token.address, &10_000, &None); + let refreshed_ttl = f + .env + .as_contract(&f.client.address, || f.env.storage().instance().get_ttl()); + + assert!(refreshed_ttl > near_expiry_ttl); + assert!(refreshed_ttl >= INSTANCE_LIFETIME - 1); +} diff --git a/contract/savings-vault/test_snapshots/test/cancellation_refunds_balance.1.json b/contract/savings-vault/test_snapshots/test/cancellation_refunds_balance.1.json index e90a59d..d8ff352 100644 --- a/contract/savings-vault/test_snapshots/test/cancellation_refunds_balance.1.json +++ b/contract/savings-vault/test_snapshots/test/cancellation_refunds_balance.1.json @@ -405,6 +405,18 @@ "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "storage": [ + { + "key": { + "vec": [ + { + "symbol": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, { "key": { "vec": [ diff --git a/contract/savings-vault/test_snapshots/test/contract_activity_extends_instance_ttl.1.json b/contract/savings-vault/test_snapshots/test/contract_activity_extends_instance_ttl.1.json new file mode 100644 index 0000000..e471f37 --- /dev/null +++ b/contract/savings-vault/test_snapshots/test/contract_activity_extends_instance_ttl.1.json @@ -0,0 +1,509 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "i128": "100000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "function_name": "create_goal", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + }, + { + "i128": "10000" + }, + "void" + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 27, + "sequence_number": 397441, + "timestamp": 1800000000, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6709440 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "Goal" + }, + { + "u128": "1" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "asset" + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, + { + "key": { + "symbol": "balance" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u128": "1" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Active" + } + ] + } + }, + { + "key": { + "symbol": "target_amount" + }, + "val": { + "i128": "10000" + } + }, + { + "key": { + "symbol": "target_date" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 6704641 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "OwnerGoals" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [ + { + "u128": "1" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 6704641 + }, + { + "entry": { + "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": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Counter" + } + ] + }, + "val": { + "u128": "1" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 915841 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "100000" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000004" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 915841 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contract/savings-vault/test_snapshots/test/contribution_transfers_tokens_and_reaches_target.1.json b/contract/savings-vault/test_snapshots/test/contribution_transfers_tokens_and_reaches_target.1.json index b7206be..2548294 100644 --- a/contract/savings-vault/test_snapshots/test/contribution_transfers_tokens_and_reaches_target.1.json +++ b/contract/savings-vault/test_snapshots/test/contribution_transfers_tokens_and_reaches_target.1.json @@ -368,6 +368,18 @@ "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "storage": [ + { + "key": { + "vec": [ + { + "symbol": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, { "key": { "vec": [ diff --git a/contract/savings-vault/test_snapshots/test/create_and_list_goal.1.json b/contract/savings-vault/test_snapshots/test/create_and_list_goal.1.json index 2bd933f..1f5721f 100644 --- a/contract/savings-vault/test_snapshots/test/create_and_list_goal.1.json +++ b/contract/savings-vault/test_snapshots/test/create_and_list_goal.1.json @@ -304,6 +304,18 @@ "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "storage": [ + { + "key": { + "vec": [ + { + "symbol": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, { "key": { "vec": [ diff --git a/contract/savings-vault/test_snapshots/test/deadline_can_complete_and_owner_can_withdraw.1.json b/contract/savings-vault/test_snapshots/test/deadline_can_complete_and_owner_can_withdraw.1.json index 9c0053f..02ccecc 100644 --- a/contract/savings-vault/test_snapshots/test/deadline_can_complete_and_owner_can_withdraw.1.json +++ b/contract/savings-vault/test_snapshots/test/deadline_can_complete_and_owner_can_withdraw.1.json @@ -412,6 +412,18 @@ "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "storage": [ + { + "key": { + "vec": [ + { + "symbol": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, { "key": { "vec": [ diff --git a/contract/savings-vault/test_snapshots/test/new_owner_has_an_empty_goal_list.1.json b/contract/savings-vault/test_snapshots/test/new_owner_has_an_empty_goal_list.1.json index f680362..5fe758d 100644 --- a/contract/savings-vault/test_snapshots/test/new_owner_has_an_empty_goal_list.1.json +++ b/contract/savings-vault/test_snapshots/test/new_owner_has_an_empty_goal_list.1.json @@ -135,14 +135,27 @@ "executable": { "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - "storage": null + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + } + ] } } } }, "ext": "v0" }, - "live_until": 4095 + "live_until": 518400 }, { "entry": { @@ -311,7 +324,7 @@ }, "ext": "v0" }, - "live_until": 4095 + "live_until": 518400 } ] }, diff --git a/contract/savings-vault/test_snapshots/test/owner_index_is_isolated_and_complete.1.json b/contract/savings-vault/test_snapshots/test/owner_index_is_isolated_and_complete.1.json new file mode 100644 index 0000000..9cd8590 --- /dev/null +++ b/contract/savings-vault/test_snapshots/test/owner_index_is_isolated_and_complete.1.json @@ -0,0 +1,806 @@ +{ + "generators": { + "address": 6, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "i128": "100000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "function_name": "create_goal", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + }, + { + "i128": "10000" + }, + "void" + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "function_name": "create_goal", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + }, + { + "i128": "20000" + }, + "void" + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "function_name": "create_goal", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + }, + { + "i128": "30000" + }, + "void" + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 27, + "sequence_number": 0, + "timestamp": 1800000000, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "Goal" + }, + { + "u128": "1" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "asset" + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, + { + "key": { + "symbol": "balance" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u128": "1" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Active" + } + ] + } + }, + { + "key": { + "symbol": "target_amount" + }, + "val": { + "i128": "10000" + } + }, + { + "key": { + "symbol": "target_date" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 6307200 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "Goal" + }, + { + "u128": "2" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "asset" + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, + { + "key": { + "symbol": "balance" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u128": "2" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Active" + } + ] + } + }, + { + "key": { + "symbol": "target_amount" + }, + "val": { + "i128": "20000" + } + }, + { + "key": { + "symbol": "target_date" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 6307200 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "Goal" + }, + { + "u128": "3" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "asset" + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, + { + "key": { + "symbol": "balance" + }, + "val": { + "i128": "0" + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u128": "3" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "vec": [ + { + "symbol": "Active" + } + ] + } + }, + { + "key": { + "symbol": "target_amount" + }, + "val": { + "i128": "30000" + } + }, + { + "key": { + "symbol": "target_date" + }, + "val": "void" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 6307200 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "OwnerGoals" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [ + { + "u128": "1" + }, + { + "u128": "2" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 6307200 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "OwnerGoals" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [ + { + "u128": "3" + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 6307200 + }, + { + "entry": { + "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": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, + { + "key": { + "vec": [ + { + "symbol": "Counter" + } + ] + }, + "val": { + "u128": "3" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + "key": { + "ledger_key_nonce": { + "nonce": "2032731177588607455" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "100000" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000004" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 518400 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contract/savings-vault/test_snapshots/test/rejects_assets_outside_the_deployment_allowlist.1.json b/contract/savings-vault/test_snapshots/test/rejects_assets_outside_the_deployment_allowlist.1.json new file mode 100644 index 0000000..fb68c39 --- /dev/null +++ b/contract/savings-vault/test_snapshots/test/rejects_assets_outside_the_deployment_allowlist.1.json @@ -0,0 +1,497 @@ +{ + "generators": { + "address": 7, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "i128": "100000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPP4V", + { + "function": { + "contract_fn": { + "contract_address": "CDS3FDGQ4JA2V3F26Y4BMWWJEC5TT26RJBN7KIQKUMVO2MAOCMDTSZ7A", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 27, + "sequence_number": 0, + "timestamp": 1800000000, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPP4V", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPP4V", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "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": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "100000" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000004" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CDS3FDGQ4JA2V3F26Y4BMWWJEC5TT26RJBN7KIQKUMVO2MAOCMDTSZ7A", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPP4V" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000007" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 518400 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contract/savings-vault/test_snapshots/test/rejects_expired_target_dates.1.json b/contract/savings-vault/test_snapshots/test/rejects_expired_target_dates.1.json new file mode 100644 index 0000000..5fe758d --- /dev/null +++ b/contract/savings-vault/test_snapshots/test/rejects_expired_target_dates.1.json @@ -0,0 +1,332 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "function_name": "mint", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "i128": "100000" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 27, + "sequence_number": 0, + "timestamp": 1800000000, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "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": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": "100000" + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJXFF" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000004" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 518400 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contract/savings-vault/test_snapshots/test/rejects_invalid_lifecycle_actions.1.json b/contract/savings-vault/test_snapshots/test/rejects_invalid_lifecycle_actions.1.json index a8341da..5664f45 100644 --- a/contract/savings-vault/test_snapshots/test/rejects_invalid_lifecycle_actions.1.json +++ b/contract/savings-vault/test_snapshots/test/rejects_invalid_lifecycle_actions.1.json @@ -341,6 +341,18 @@ "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "storage": [ + { + "key": { + "vec": [ + { + "symbol": "AllowedAsset" + } + ] + }, + "val": { + "address": "CCABDO7UZXYE4W6GVSEGSNNZTKSLFQGKXXQTH6OX7M7GKZ4Z6CUJNGZN" + } + }, { "key": { "vec": [ diff --git a/contract/savings-vault/test_snapshots/test/requires_owner_authorization.1.json b/contract/savings-vault/test_snapshots/test/requires_owner_authorization.1.json new file mode 100644 index 0000000..410de48 --- /dev/null +++ b/contract/savings-vault/test_snapshots/test/requires_owner_authorization.1.json @@ -0,0 +1,238 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGO6V", + { + "function": { + "contract_fn": { + "contract_address": "CBUSYNQKASUYFWYC3M2GUEDMX4AIVWPALDBYJPNK6554BREHTGZ2IUNF", + "function_name": "set_admin", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [] + ], + "ledger": { + "protocol_version": 27, + "sequence_number": 0, + "timestamp": 1800000000, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "account": { + "account_id": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGO6V", + "balance": "0", + "seq_num": "0", + "num_sub_entries": 0, + "inflation_dest": null, + "flags": 0, + "home_domain": "", + "thresholds": "01010101", + "signers": [], + "ext": "v0" + } + }, + "ext": "v0" + }, + "live_until": null + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGO6V", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "AllowedAsset" + } + ] + }, + "val": { + "address": "CBUSYNQKASUYFWYC3M2GUEDMX4AIVWPALDBYJPNK6554BREHTGZ2IUNF" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBUSYNQKASUYFWYC3M2GUEDMX4AIVWPALDBYJPNK6554BREHTGZ2IUNF", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": "stellar_asset", + "storage": [ + { + "key": { + "symbol": "METADATA" + }, + "val": { + "map": [ + { + "key": { + "symbol": "decimal" + }, + "val": { + "u32": 7 + } + }, + { + "key": { + "symbol": "name" + }, + "val": { + "string": "aaa:GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGO6V" + } + }, + { + "key": { + "symbol": "symbol" + }, + "val": { + "string": "aaa" + } + } + ] + } + }, + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "AssetInfo" + } + ] + }, + "val": { + "vec": [ + { + "symbol": "AlphaNum4" + }, + { + "map": [ + { + "key": { + "symbol": "asset_code" + }, + "val": { + "string": "aaa\\0" + } + }, + { + "key": { + "symbol": "issuer" + }, + "val": { + "bytes": "0000000000000000000000000000000000000000000000000000000000000003" + } + } + ] + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 120960 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 518400 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index d7f6830..9a922d7 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -8,6 +8,8 @@ | Function | Authorization | Result | |---|---|---| +| `__constructor(allowed_asset)` | deployer | Sets the immutable SAC allowlist for this deployment. | +| `allowed_asset()` | none | Reads the configured SAC allowlist. | | `create_goal(owner, asset, target_amount, target_date)` | owner | Creates an active goal and returns its `u128` ID. | | `contribute(goal_id, contributor, amount)` | contributor | Transfers SAC tokens into the vault and updates exact balance. | | `complete_goal(goal_id)` | permissionless | Completes when target is reached or optional Unix deadline has passed. | @@ -37,14 +39,15 @@ The project currently uses this dedicated Testnet deployment: | Setting | Value | |---|---| -| Vault contract | `CALFEOYNTNJYB5HTUPYHHFHMNNYLYEBOCV43Z73J54G3CQNSH5VCNP7H` | +| Vault contract | `CDYPVKFWSPHKGDHZ77M2T2TZPCS3LVXDFJDH5PERL5HTUNVFYSTB7AG3` | | Native XLM SAC | `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` | -| Wasm SHA-256 | `3c6be5dbf17042fcf1041b7a66c7a1f90a4f95233139246ab12cd7eb44bc40ef` | -| Deployed | 2026-08-31 | +| Wasm SHA-256 | `0977e310e0f296e8811774ee74800367053879f809cc6ebbb49dca0816b8587f` | +| Wasm upload transaction | `abc82c9d19b4d187a8faae2e8651eeed0fbb26484444911886e8ff849153046b` | +| Deployment transaction | `85a94fe0360cc955bcedfbcd496fb0c26e2a21799582a3f92e26d8e7f1715587` | -This deployment fixes first-time-owner reads so `list_goals` returns an empty list instead of -attempting to extend a nonexistent storage key. The prior Testnet deployment remains available -for its historical Deliverables 1–2 evidence and sample goal. +This deployment preserves the reviewed lifecycle, fixes first-time-owner reads, and configures +the Native XLM SAC as an immutable constructor allowlist. The prior Testnet deployments remain +available for their historical Deliverables 1–2 transactions. Both IDs are public network identifiers, not credentials. They are configured in `backend/.env.example`; production and Mainnet must use separately reviewed deployments. @@ -59,14 +62,24 @@ stellar keys generate save-deployer --network testnet --fund stellar contract deploy \ --wasm contract/target/wasm32v1-none/release/save_savings_vault.wasm \ --source save-deployer \ - --network testnet + --network testnet \ + -- --allowed-asset CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC ``` -Record the returned `C…` address as `STELLAR_VAULT_CONTRACT_ID` in the backend environment. Do not commit the deployer seed or local Stellar CLI identity directory. Verify the deployed Wasm hash and source before allowing material Testnet balances. +Record the returned `C…` address as `STELLAR_VAULT_CONTRACT_ID` and the release artifact SHA-256 as `STELLAR_VAULT_WASM_HASH`. Do not commit the deployer seed or local Stellar CLI identity directory. Run `npm run health:testnet` to verify the deployed code and address. + +## Withdrawal and cancellation rules + +- Only the stored goal owner can withdraw. +- Partial withdrawals are supported only while the goal is `Completed` and cannot exceed its remaining balance. +- Active and cancelled goals cannot be withdrawn. +- Cancelling an active goal atomically refunds its entire balance to the owner, zeros the balance, and marks the goal `Cancelled`. +- A completed or cancelled goal cannot be cancelled again or receive additional contributions. ## Security review - Every token debit from a user is preceded by `require_auth`. +- Goal creation rejects every asset except the immutable deployment allowlist. - Withdrawal and cancellation require the stored owner. - Checked arithmetic prevents contribution overflow. - Status transitions prevent contribution after completion/cancellation and withdrawal before completion. diff --git a/docs/DELIVERABLES_1_2_TEST_REPORT.md b/docs/DELIVERABLES_1_2_TEST_REPORT.md index 82d47d5..f42963f 100644 --- a/docs/DELIVERABLES_1_2_TEST_REPORT.md +++ b/docs/DELIVERABLES_1_2_TEST_REPORT.md @@ -7,10 +7,12 @@ Evidence deployment: `CAJOBHJQORFRFFWN4X5LKLQIURQJNQFABQG6L452SWCFPAPDIWLRILYG` Native XLM SAC: `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` Verified Wasm hash: `774d426c6165e362bc14930abd04152fbaf3bd9e13a1d8522d6942c99e1bf1a6` -Current app deployment: `CALFEOYNTNJYB5HTUPYHHFHMNNYLYEBOCV43Z73J54G3CQNSH5VCNP7H` -Current Wasm hash: `3c6be5dbf17042fcf1041b7a66c7a1f90a4f95233139246ab12cd7eb44bc40ef`. -It preserves the reviewed interface and lifecycle behavior while fixing empty `list_goals` -queries for first-time owners; the regression suite now contains six contract tests. +Current app deployment: `CDYPVKFWSPHKGDHZ77M2T2TZPCS3LVXDFJDH5PERL5HTUNVFYSTB7AG3` + +Current Wasm hash: `0977e310e0f296e8811774ee74800367053879f809cc6ebbb49dca0816b8587f`. +It preserves the reviewed lifecycle, fixes empty first-owner reads, and immutably allowlists the +Native XLM SAC; the regression suite now contains eleven contract tests. The transactions below +remain historical evidence from the earlier compatible Testnet deployment. ## Deliverable 1 — Stellar Testnet Savings Funding Flow @@ -46,7 +48,7 @@ The last transaction was prepared by `POST /stellar/vault/prepare`, signed exter | Invalid/duplicate transitions | Invalid amounts, premature completion/withdrawal, contribution after cancellation, duplicate completion, and duplicate cancellation are rejected. | | Events | Typed `GoalCreated`, `Contribution`, `GoalCompleted`, `Withdrawal`, and `GoalCancelled` events are indexed by the API and linked to explorer proof. | | Privacy boundary | Only minimum goal and token state is on-chain. Receipts, merchants, income, TIN, budgets, and PII remain off-chain. | -| Tests and deployment | Five Rust contract tests pass; the deployed Testnet Wasm hash matches the local release build. | +| Tests and deployment | Eleven Rust contract tests pass; the deployed Testnet Wasm hash matches the local release build. | Sample goal completion: [f99595…df29](https://stellar.expert/explorer/testnet/tx/f99595d542fb4cb98ee67c00a560494687073a938d696f697aaabea23e58df29). The goal completed with `3,100,000` atomic units before the callback-path withdrawal test. diff --git a/docs/DELIVERABLE_4_EVIDENCE.md b/docs/DELIVERABLE_4_EVIDENCE.md new file mode 100644 index 0000000..25d621a --- /dev/null +++ b/docs/DELIVERABLE_4_EVIDENCE.md @@ -0,0 +1,77 @@ +# Deliverable 4 — Toolchain, CI, and Reviewer Verification + +Scope: reproducible verification for the SAVE Stellar Testnet alpha across the Expo mobile app, NestJS API, Next.js admin app, and Soroban vault. + +## Deployment evidence + +| Item | Evidence | +|---|---| +| Network | Stellar Testnet — `Test SDF Network ; September 2015` | +| Vault contract | [`CDYPVK…7AG3`](https://stellar.expert/explorer/testnet/contract/CDYPVKFWSPHKGDHZ77M2T2TZPCS3LVXDFJDH5PERL5HTUNVFYSTB7AG3) | +| Native XLM SAC allowlist | [`CDLZFC…CYSC`](https://stellar.expert/explorer/testnet/contract/CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC) | +| Wasm SHA-256 | `0977e310e0f296e8811774ee74800367053879f809cc6ebbb49dca0816b8587f` | +| Wasm upload | [`abc82c…046b`](https://stellar.expert/explorer/testnet/tx/abc82c9d19b4d187a8faae2e8651eeed0fbb26484444911886e8ff849153046b) | +| Contract deployment | [`85a94f…5587`](https://stellar.expert/explorer/testnet/tx/85a94fe0360cc955bcedfbcd496fb0c26e2a21799582a3f92e26d8e7f1715587) | + +The immutable constructor allowlist is readable through `allowed_asset`. Goal creation fails with `VaultError::UnsupportedAsset` when the requested SAC differs. + +## Automated verification + +The GitHub Actions workflow at `.github/workflows/verify.yml` runs these independent jobs: + +| Job | Commands and artifacts | Failure condition | +|---|---|---| +| Environment | `npm run env:validate -- --examples --skip-tools` | Unsafe/missing example configuration or wrong Testnet constants | +| Mobile | `npm run lint`, `npm run typecheck`, `npm run backend:lint` | Any lint error or TypeScript error | +| Backend | `npm --prefix backend test` | Build failure or Node test failure | +| Admin | `npm --prefix admin run lint`, `npm --prefix admin run build` | Lint, type, or production build failure | +| Contract | Rust format, test, Clippy, and release build | Formatting, warning, test, or Wasm build failure | +| Contract artifact | `save_savings_vault.wasm` | Artifact missing after build | +| Scheduled/manual health | `npm run health:testnet` | Horizon/RPC failure, contract unavailable, or Wasm mismatch | + +CI run history becomes available at `https://github.com/FWDP/save-project/actions/workflows/verify.yml` after the workflow is pushed to GitHub. Local verification uses the same commands and is documented in `REVIEWER_RUNBOOK.md`. + +## Verified local results + +- Mobile lint: passed. +- Mobile TypeScript validation: passed. +- Backend lint/build: passed. +- Backend tests: five security, signed-XDR, network-binding, and asset-allowlist tests passed. +- Admin production build: passed. +- Soroban contract: eleven tests passed. +- Optimized Wasm build: passed with the hash above. +- Live Testnet health: Horizon, RPC, contract fetch, and deployed Wasm consistency passed. + +## Contract test coverage + +The eleven-test suite covers: + +- authenticated creation and contribution; +- authorization failure; +- invalid amounts and lifecycle transitions; +- cancellation and atomic full-balance refund; +- completed-goal partial withdrawal by the owner; +- target-date validation and deadline completion; +- isolated owner indexing; +- instance TTL extension; +- unsupported-token rejection; +- empty first-time owner reads; +- contribution balance and token transfer accounting. + +Duplicate prepared API requests are deduplicated by idempotency key. A key cannot be reused for another source or action. Signed-XDR substitution is rejected by comparing the signed transaction body hash with the prepared request, while Stellar account sequence rules reject replayed transaction envelopes. + +## Health and trust-boundary evidence + +- `scripts/validate-env.mjs` verifies supported tools, required configuration, the canonical Testnet passphrase, public contract identifiers, and the absence of Stellar secret seeds. +- `scripts/health-check.mjs` verifies Horizon, RPC, contract reachability, and exact deployed Wasm consistency; `--services` adds backend and admin connectivity. +- `GET /health` reports backend and deployed Stellar status. +- `GET /api/health` in the admin app confirms it can reach the backend health endpoint. +- The backend may prepare, simulate, submit signed XDR, and reconcile ledger state. It cannot sign user transactions, does not possess private keys, rejects empty signatures, rejects signed-body substitution, and binds transaction hashes to the Testnet passphrase. + +## Withdrawal semantics + +Only the stored goal owner may withdraw. Partial withdrawals are supported, but only after the goal is `Completed`. The owner may make multiple withdrawals up to the remaining balance. Active and cancelled goals cannot be withdrawn. Cancelling an active goal atomically sends its entire balance to the owner, sets the balance to zero, and changes the status to `Cancelled`; a cancelled goal cannot be cancelled or funded again. + +## Reviewer procedure + +Follow [`REVIEWER_RUNBOOK.md`](./REVIEWER_RUNBOOK.md) for the clean-checkout sequence, exact commands, expected outputs, live wallet flow, and negative-path verification. diff --git a/docs/REVIEWER_RUNBOOK.md b/docs/REVIEWER_RUNBOOK.md new file mode 100644 index 0000000..1621171 --- /dev/null +++ b/docs/REVIEWER_RUNBOOK.md @@ -0,0 +1,142 @@ +# Independent Reviewer Runbook + +This runbook verifies the SAVE Stellar Testnet alpha from a clean checkout. Every command is intended to be run from the repository root unless a step says otherwise. + +## Supported toolchain + +| Tool | Supported version | +|---|---| +| Node.js | `22.13.1` or a newer compatible 22+ release | +| npm | `10.x` or newer | +| Rust and Cargo | stable `1.90+` | +| Stellar CLI | `27.x` | +| Docker | Current Docker Engine with Compose v2 | +| Mobile build | Expo SDK 57 / React Native 0.86 native development build | + +Run `npm run env:validate` to check the installed command-line tools and local configuration before building. + +## 1. Clone and install + +```bash +git clone https://github.com/FWDP/save-project.git +cd save-project +npm ci +npm --prefix backend ci +npm --prefix admin ci +``` + +## 2. Configure without sharing secrets + +```bash +cp .env.example .env +cp backend/.env.example backend/.env +cp admin/.env.example admin/.env.local +``` + +Set the public mobile API URL and a public WalletConnect project ID in `.env`. For local review, replace the backend's placeholder `JWT_SECRET` with a random development-only value. Do not place a Stellar secret seed, wallet recovery phrase, production credential, or deployer identity in any repository environment file. + +The contract ID, Native XLM SAC ID, network passphrase, RPC URL, Horizon URL, and Wasm hash are public Testnet verification values. + +```bash +npm run env:validate +``` + +Expected result: every required value and tool prints `ok`, followed by `Environment validation passed`. + +## 3. Run the automated gate + +```bash +npm run verify +``` + +This command fails immediately if any of these checks fail: + +- environment and toolchain validation; +- mobile lint and TypeScript validation; +- backend lint, build, and Node tests; +- admin lint and production build; +- Soroban format, tests, and optimized Wasm build. + +The contract build must produce: + +```text +contract/target/wasm32v1-none/release/save_savings_vault.wasm +SHA-256: 0977e310e0f296e8811774ee74800367053879f809cc6ebbb49dca0816b8587f +``` + +## 4. Verify the deployed Testnet contract + +```bash +npm run health:testnet +``` + +Expected result: Horizon, RPC, contract reachability, and deployed Wasm hash all print `ok`. A hash mismatch is a failure, even if the contract address exists. + +## 5. Start the local services + +```bash +docker compose up -d +``` + +Start each application in a separate terminal: + +```bash +npm --prefix backend run start:dev +npm --prefix admin run dev -- --port 3001 +npm start +``` + +With the API and admin running, verify service-to-service connectivity: + +```bash +npm run health:testnet -- --services +``` + +You may also inspect the JSON directly: + +```bash +curl http://localhost:3000/health +curl http://localhost:3001/api/health +``` + +The backend response must report the canonical Testnet passphrase, recent RPC and Horizon ledgers, contract ID, deployed Wasm hash, and the Native XLM SAC as the contract allowlist. + +## 6. Verify an externally signed vault action + +1. Fund a dedicated Testnet-only Freighter Mobile account. +2. Open **More → Stellar Testnet** in the native SAVE development build. +3. Connect the wallet; SAVE stores only its public address. +4. Create or select a tracked savings goal and prepare a vault goal. +5. Review and approve the unsigned transaction in Freighter Mobile. +6. Wait until SAVE reconciles the request to `success`. +7. Copy the transaction hash and open its Stellar Expert Testnet link. +8. Confirm the contract event and final vault state through the app or `GET /stellar/vault/events`. + +The API may prepare, simulate, submit, and reconcile a transaction. It cannot sign for the user and never receives a wallet secret. + +## 7. Run a negative path + +The fastest deterministic negative test is already automated: + +```bash +npm run contract:test +npm --prefix backend test +``` + +Confirm that the output includes contract rejection coverage for an unsupported SAC and backend rejection of any `assetContractId` other than the configured Native XLM SAC. The contract must also reject a zero contribution, a premature withdrawal, a duplicate terminal transition, and an action without required authorization. No rejected operation may change vault state. + +For a wallet-visible retry path, prepare a transaction and reject it in Freighter. Allow the request to expire, confirm SAVE reports failure rather than ledger success, then prepare a fresh request with a new idempotency key. + +## 8. Evidence checklist + +- Workflow: `.github/workflows/verify.yml` +- Wasm artifact and hash from `npm run contract:build` +- Eleven passing contract tests +- Five passing backend security/asset tests +- Successful admin production build +- Successful `npm run health:testnet` +- Contract ID and deployment/upload transaction links in `docs/DELIVERABLE_4_EVIDENCE.md` +- Wallet-approved transaction hash, event, reconciled API state, and UI screenshot for the reviewer-run action +- Negative-path command output and proof that ledger state did not change + +Screenshots supplement this evidence; they do not replace ledger references, test output, CI logs, or build artifacts. diff --git a/docs/STELLAR_ARCHITECTURE.md b/docs/STELLAR_ARCHITECTURE.md index 7e2d2c9..50576ea 100644 --- a/docs/STELLAR_ARCHITECTURE.md +++ b/docs/STELLAR_ARCHITECTURE.md @@ -4,7 +4,7 @@ - Network: Stellar Testnet only. Every signature is bound to `Test SDF Network ; September 2015`. - Wallet model: watch-only account plus external-wallet approval through SEP-7. SAVE does not generate, import, transmit, log, or store secret seeds. -- Asset allowlist: native XLM through its Testnet Stellar Asset Contract. Additional SAC assets require explicit backend configuration and product review. +- Asset allowlist: native XLM through its Testnet Stellar Asset Contract. Both the backend and the vault constructor enforce the same exact SAC ID; other assets are rejected before simulation and by the contract. - Data sources: Horizon for classic accounts/payments; Stellar RPC for Soroban simulation, submission, transaction status, contract reads, and events. - Amount representation: classic payment inputs are validated decimal strings with at most seven fractional digits; Soroban values are atomic-unit decimal strings converted to `bigint` server-side. - Private data: receipt content, merchant/category labels, user profile data, and goal names remain off-chain. The vault contains only owner, asset, target amount/date, balance, status, and ID. @@ -23,7 +23,7 @@ Mobile app ── public address / unsigned intent ──> SAVE API External wallet ── explicit user approval/signature ──> Stellar Testnet ``` -The API treats addresses, callbacks, XDR, RPC data, and wallet responses as untrusted. Validation pipes reject unknown DTO fields, signed XDR is decoded against the Testnet passphrase, and an empty signature set is rejected. +The API treats addresses, callbacks, XDR, RPC data, and wallet responses as untrusted. Validation pipes reject unknown DTO fields, signed XDR is decoded against the Testnet passphrase, and an empty signature set is rejected. The API can prepare, simulate, submit, and reconcile transactions, but it cannot sign for a user. Signed transaction body hashes must match the stored prepared request; Stellar sequence rules reject replayed envelopes. ## Persistence and reconciliation diff --git a/package-lock.json b/package-lock.json index 0c9ac9d..955c9f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3797,9 +3797,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3814,9 +3811,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3831,9 +3825,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3848,9 +3839,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3865,9 +3853,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3882,9 +3867,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3899,9 +3881,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3916,9 +3895,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3933,9 +3909,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3950,9 +3923,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4062,6 +4032,20 @@ "node": ">=18.20.8" } }, + "node_modules/@walletconnect/core/node_modules/@react-native-async-storage/async-storage": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz", + "integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.60 <1.0" + } + }, "node_modules/@walletconnect/core/node_modules/@walletconnect/keyvaluestorage": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", @@ -4392,6 +4376,20 @@ "events": "3.3.0" } }, + "node_modules/@walletconnect/types/node_modules/@react-native-async-storage/async-storage": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz", + "integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.60 <1.0" + } + }, "node_modules/@walletconnect/types/node_modules/@walletconnect/keyvaluestorage": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", @@ -4570,6 +4568,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@walletconnect/utils/node_modules/@react-native-async-storage/async-storage": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz", + "integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.60 <1.0" + } + }, "node_modules/@walletconnect/utils/node_modules/@walletconnect/keyvaluestorage": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz", @@ -9534,9 +9546,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9557,9 +9566,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9580,9 +9586,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9603,9 +9606,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index a39280b..ee868a2 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,19 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", + "typecheck": "tsc --noEmit", + "backend:lint": "eslint backend/src --ext .ts --max-warnings=0", "lint": "expo lint", "admin": "npm --prefix admin run dev", "admin:build": "npm --prefix admin run build", + "admin:lint": "npm --prefix admin run lint", "admin:start": "npm --prefix admin run start", "contract:test": "cargo +stable test --manifest-path contract/Cargo.toml --locked", - "contract:build": "stellar contract build --manifest-path contract/Cargo.toml --locked" + "contract:build": "stellar contract build --manifest-path contract/Cargo.toml --locked", + "contract:check": "cargo fmt --manifest-path contract/Cargo.toml --all -- --check && cargo clippy --manifest-path contract/Cargo.toml --all-targets --locked -- -D warnings && npm run contract:test && npm run contract:build", + "env:validate": "node scripts/validate-env.mjs", + "health:testnet": "node scripts/health-check.mjs", + "verify": "npm run env:validate && npm run lint && npm run typecheck && npm run backend:lint && npm --prefix backend test && npm run admin:lint && npm run admin:build && npm run contract:check" }, "resolutions": { "uuid": "11.1.1", diff --git a/screenshots/01-dashboard.png b/screenshots/01-dashboard.png new file mode 100644 index 0000000..a50edfa Binary files /dev/null and b/screenshots/01-dashboard.png differ diff --git a/screenshots/02-expenses.png b/screenshots/02-expenses.png new file mode 100644 index 0000000..6141761 Binary files /dev/null and b/screenshots/02-expenses.png differ diff --git a/screenshots/03-budgets.png b/screenshots/03-budgets.png new file mode 100644 index 0000000..f307690 Binary files /dev/null and b/screenshots/03-budgets.png differ diff --git a/screenshots/04-savings.png b/screenshots/04-savings.png new file mode 100644 index 0000000..f892172 Binary files /dev/null and b/screenshots/04-savings.png differ diff --git a/screenshots/05-stellar.png b/screenshots/05-stellar.png new file mode 100644 index 0000000..f8eb875 Binary files /dev/null and b/screenshots/05-stellar.png differ diff --git a/scripts/health-check.mjs b/scripts/health-check.mjs new file mode 100644 index 0000000..3630033 --- /dev/null +++ b/scripts/health-check.mjs @@ -0,0 +1,84 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015'; +const TESTNET_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org'; +const includeServices = process.argv.includes('--services'); + +function parseEnv(path) { + const values = {}; + for (const rawLine of readFileSync(path, 'utf8').split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const separator = line.indexOf('='); + if (separator > 0) values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim(); + } + return values; +} + +async function checkedJson(name, url, options) { + const response = await fetch(url, { ...options, signal: AbortSignal.timeout(15_000) }); + const body = await response.json(); + if (!response.ok) throw new Error(`${name} returned HTTP ${response.status}`); + console.log(`ok ${name}`); + return body; +} + +function localServiceOrigin(value, fallback) { + const url = new URL(value ?? fallback); + if (url.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new Error('Service health checks only accept local HTTP endpoints'); + } + return url.origin; +} + +const envPath = existsSync(resolve('backend/.env')) ? resolve('backend/.env') : resolve('backend/.env.example'); +const env = parseEnv(envPath); +if (env.STELLAR_NETWORK !== 'TESTNET' || env.STELLAR_NETWORK_PASSPHRASE !== TESTNET_PASSPHRASE) { + throw new Error('Health checks are locked to the canonical Stellar Testnet configuration'); +} +if (env.STELLAR_HORIZON_URL !== TESTNET_HORIZON_URL || env.STELLAR_RPC_URL !== TESTNET_RPC_URL) { + throw new Error('Health checks require the canonical Stellar Testnet Horizon and RPC endpoints'); +} + +const rpcCall = async (method) => { + const response = await checkedJson(`RPC ${method}`, TESTNET_RPC_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: method, method }), + }); + if (response.error) throw new Error(`RPC ${method}: ${response.error.message}`); + return response.result; +}; + +await checkedJson('Horizon Testnet', TESTNET_HORIZON_URL); +const network = await rpcCall('getNetwork'); +await rpcCall('getLatestLedger'); +if ((network.passphrase ?? network.networkPassphrase) !== TESTNET_PASSPHRASE) { + throw new Error('RPC returned an unexpected network passphrase'); +} + +const deployedWasm = execFileSync('stellar', [ + 'contract', 'fetch', + '--id', env.STELLAR_VAULT_CONTRACT_ID, + '--rpc-url', TESTNET_RPC_URL, + '--network-passphrase', TESTNET_PASSPHRASE, +]); +const deployedHash = createHash('sha256').update(deployedWasm).digest('hex'); +if (deployedHash !== env.STELLAR_VAULT_WASM_HASH) { + throw new Error(`Deployed Wasm hash mismatch: expected ${env.STELLAR_VAULT_WASM_HASH}, received ${deployedHash}`); +} +console.log(`ok Vault contract reachable: ${env.STELLAR_VAULT_CONTRACT_ID}`); +console.log(`ok Deployed Wasm hash: ${deployedHash}`); + +if (includeServices) { + const backendUrl = localServiceOrigin(process.env.SAVE_API_URL, 'http://localhost:3000'); + const adminUrl = localServiceOrigin(process.env.SAVE_ADMIN_URL, 'http://localhost:3001'); + await checkedJson('SAVE API health', `${backendUrl}/health`); + await checkedJson('SAVE admin health', `${adminUrl}/api/health`); +} + +console.log('ok Stellar Testnet health verification passed'); diff --git a/scripts/validate-env.mjs b/scripts/validate-env.mjs new file mode 100644 index 0000000..7fe2eb0 --- /dev/null +++ b/scripts/validate-env.mjs @@ -0,0 +1,116 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015'; +const TESTNET_XLM_SAC = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'; +const args = new Set(process.argv.slice(2)); +const examplesOnly = args.has('--examples'); +const skipTools = args.has('--skip-tools'); +const failures = []; +const warnings = []; + +function parseEnv(path) { + const values = {}; + for (const rawLine of readFileSync(path, 'utf8').split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const separator = line.indexOf('='); + if (separator < 1) continue; + values[line.slice(0, separator).trim()] = line.slice(separator + 1).trim(); + } + return values; +} + +function selectedPath(localPath, examplePath) { + if (!examplesOnly && existsSync(localPath)) return localPath; + return examplePath; +} + +function requireValue(values, key, source) { + if (!values[key]) failures.push(`${source}: ${key} is required`); +} + +function checkUrl(values, key, source, { allowBlank = false } = {}) { + const value = values[key]; + if (!value && allowBlank) return; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('unsupported protocol'); + } catch { + failures.push(`${source}: ${key} must be an HTTP(S) URL`); + } +} + +function commandVersion(command, commandArgs, pattern, label) { + try { + const value = execFileSync(command, commandArgs, { encoding: 'utf8' }).trim(); + if (!pattern.test(value)) failures.push(`${label}: unsupported version (${value})`); + else console.log(`ok ${label}: ${value.split('\n')[0]}`); + } catch (error) { + if (error?.code === 'EPERM') warnings.push(`${label}: version probe blocked by the current sandbox`); + else failures.push(`${label}: command not found`); + } +} + +const rootPath = selectedPath(resolve('.env'), resolve('.env.example')); +const backendPath = selectedPath(resolve('backend/.env'), resolve('backend/.env.example')); +const adminPath = selectedPath(resolve('admin/.env.local'), resolve('admin/.env.example')); + +for (const path of [rootPath, backendPath, adminPath]) { + if (!existsSync(path)) failures.push(`${path}: file not found`); +} + +if (!failures.length) { + const mobile = parseEnv(rootPath); + const backend = parseEnv(backendPath); + const admin = parseEnv(adminPath); + + checkUrl(mobile, 'EXPO_PUBLIC_API_URL', rootPath, { allowBlank: examplesOnly }); + if (!examplesOnly) requireValue(mobile, 'EXPO_PUBLIC_WALLETCONNECT_PROJECT_ID', rootPath); + checkUrl(admin, 'SAVE_API_URL', adminPath); + + requireValue(backend, 'MONGODB_URI', backendPath); + requireValue(backend, 'REDIS_URI', backendPath); + checkUrl(backend, 'STELLAR_HORIZON_URL', backendPath); + checkUrl(backend, 'STELLAR_RPC_URL', backendPath); + checkUrl(backend, 'STELLAR_CALLBACK_BASE_URL', backendPath); + + if (backend.STELLAR_NETWORK !== 'TESTNET') failures.push(`${backendPath}: STELLAR_NETWORK must be TESTNET`); + if (backend.STELLAR_NETWORK_PASSPHRASE !== TESTNET_PASSPHRASE) { + failures.push(`${backendPath}: STELLAR_NETWORK_PASSPHRASE must be the canonical Testnet passphrase`); + } + if (backend.STELLAR_XLM_SAC_ID !== TESTNET_XLM_SAC) { + failures.push(`${backendPath}: STELLAR_XLM_SAC_ID must be the Native XLM Testnet SAC`); + } + if (!/^C[A-Z2-7]{55}$/.test(backend.STELLAR_VAULT_CONTRACT_ID ?? '')) { + failures.push(`${backendPath}: STELLAR_VAULT_CONTRACT_ID must be a contract strkey`); + } + if (!/^[a-f0-9]{64}$/i.test(backend.STELLAR_VAULT_WASM_HASH ?? '')) { + failures.push(`${backendPath}: STELLAR_VAULT_WASM_HASH must be a 64-character SHA-256 hash`); + } + if ([rootPath, backendPath, adminPath].some((path) => /(^|[^A-Z2-7])S[A-Z2-7]{55}([^A-Z2-7]|$)/m.test(readFileSync(path, 'utf8')))) { + failures.push('Environment files must never contain a Stellar secret seed'); + } + if ((backend.JWT_SECRET ?? '').includes('replace_with') && !examplesOnly) { + warnings.push(`${backendPath}: replace JWT_SECRET before any shared or production-like deployment`); + } +} + +if (!skipTools) { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major < 22 || (major === 22 && minor < 13)) failures.push(`Node.js 22.13+ is required (found ${process.version})`); + else console.log(`ok Node.js: ${process.version}`); + commandVersion('npm', ['--version'], /^(1[0-9]|[2-9]\d)\./, 'npm 10+'); + commandVersion('rustc', ['--version'], /^rustc 1\.(9[0-9]|[1-9]\d{2})\./, 'Rust stable 1.90+'); + commandVersion('cargo', ['--version'], /^cargo 1\.(9[0-9]|[1-9]\d{2})\./, 'Cargo 1.90+'); + commandVersion('stellar', ['--version'], /^stellar 27\./, 'Stellar CLI 27.x'); +} + +for (const warning of warnings) console.warn(`warn ${warning}`); +if (failures.length) { + for (const failure of failures) console.error(`fail ${failure}`); + process.exitCode = 1; +} else { + console.log(`ok Environment validation passed (${examplesOnly ? 'examples' : 'local configuration'})`); +} diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 0000000..eae8af4 --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Readonly>; + export default classes; +} + +declare module '*.css';