diff --git a/apps/smart-contracts/Cargo.lock b/apps/smart-contracts/Cargo.lock index 5ce3229..9c15d42 100644 --- a/apps/smart-contracts/Cargo.lock +++ b/apps/smart-contracts/Cargo.lock @@ -44,6 +44,17 @@ dependencies = [ "ark-std", ] +[[package]] +name = "ark-bn254" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + [[package]] name = "ark-ec" version = "0.4.2" @@ -189,6 +200,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes-lit" version = "0.0.5" @@ -413,6 +430,13 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "deployer" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "der" version = "0.7.10" @@ -645,6 +669,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -666,6 +699,16 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -901,11 +944,10 @@ dependencies = [ [[package]] name = "participation-token" -version = "0.1.0" +version = "0.0.6" dependencies = [ - "escrow", "soroban-sdk", - "soroban-token-contract", + "soroban-token-sdk", ] [[package]] @@ -1231,9 +1273,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "soroban-builtin-sdk-macros" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85" +checksum = "7192e3a5551a7aeee90d2110b11b615798e81951fd8c8293c87ea7f88b0168f5" dependencies = [ "itertools", "proc-macro2", @@ -1243,9 +1285,9 @@ dependencies = [ [[package]] name = "soroban-env-common" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3" +checksum = "bfc49a80a68fc1005847308e63b9fce39874de731940b1807b721d472de3ff01" dependencies = [ "arbitrary", "crate-git-revision", @@ -1262,9 +1304,9 @@ dependencies = [ [[package]] name = "soroban-env-guest" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6" +checksum = "ea2334ba1cfe0a170ab744d96db0b4ca86934de9ff68187ceebc09dc342def55" dependencies = [ "soroban-env-common", "static_assertions", @@ -1272,11 +1314,12 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725" +checksum = "43af5d53c57bc2f546e122adc0b1cca6f93942c718977379aa19ddd04f06fcec" dependencies = [ "ark-bls12-381", + "ark-bn254", "ark-ec", "ark-ff", "ark-serialize", @@ -1302,15 +1345,15 @@ dependencies = [ "soroban-env-common", "soroban-wasmi", "static_assertions", - "stellar-strkey", + "stellar-strkey 0.0.13", "wasmparser", ] [[package]] name = "soroban-env-macros" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c" +checksum = "a989167512e3592d455b1e204d703cfe578a36672a77ed2f9e6f7e1bbfd9cc5c" dependencies = [ "itertools", "proc-macro2", @@ -1323,9 +1366,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "23.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "218c6676e75fea1fd2775b8af15935afd7f7675a89de6832913ad5c4212eecac" +checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5" dependencies = [ "serde", "serde_json", @@ -1337,9 +1380,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "23.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1889a7ad00d2c286e8950a6f43c9405f7fd1958bcfde9ad3b453500c723c989" +checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae" dependencies = [ "arbitrary", "bytes-lit", @@ -1355,14 +1398,15 @@ dependencies = [ "soroban-env-host", "soroban-ledger-snapshot", "soroban-sdk-macros", - "stellar-strkey", + "stellar-strkey 0.0.16", + "visibility", ] [[package]] name = "soroban-sdk-macros" -version = "23.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f015b41e04281e6af5d9e8028a5d9d54c0a4981e87a94a30767761a264181cf" +checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1" dependencies = [ "darling 0.20.11", "heck", @@ -1380,11 +1424,12 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "23.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce472309fb81b1d4dd8951b3b1fd868c9d6ff3dbde9b9befc38ef6bede54a051" +checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed" dependencies = [ "base64", + "sha2", "stellar-xdr", "thiserror", "wasmparser", @@ -1392,9 +1437,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "23.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5ad73078046ab4acd0f9b777b47f76fc8fe158f0a02dac178ba86694fd98e94" +checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256" dependencies = [ "prettyplease", "proc-macro2", @@ -1406,19 +1451,11 @@ dependencies = [ "thiserror", ] -[[package]] -name = "soroban-token-contract" -version = "0.0.6" -dependencies = [ - "soroban-sdk", - "soroban-token-sdk", -] - [[package]] name = "soroban-token-sdk" -version = "23.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd6166a3365a3fb7ad9dedf9a6d12e14e73c38b8f7d4aa817f868172fe96b47" +checksum = "7257d888e42da01d031cfc388d4c9c10e4e13388a4e04deabf812fa7d33f0af3" dependencies = [ "soroban-sdk", ] @@ -1452,6 +1489,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1468,11 +1511,22 @@ dependencies = [ "data-encoding", ] +[[package]] +name = "stellar-strkey" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" +dependencies = [ + "crate-git-revision", + "data-encoding", + "heapless", +] + [[package]] name = "stellar-xdr" -version = "23.0.0" +version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" +checksum = "10d20dafed80076b227d4b17c0c508a4bbc4d5e4c3d4c1de7cd42242df4b1eaf" dependencies = [ "arbitrary", "base64", @@ -1484,7 +1538,7 @@ dependencies = [ "serde", "serde_with", "sha2", - "stellar-strkey", + "stellar-strkey 0.0.13", ] [[package]] @@ -1572,6 +1626,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "token-sale" +version = "0.1.0" +dependencies = [ + "escrow", + "participation-token", + "soroban-sdk", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1588,8 +1651,8 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" name = "vault-contract" version = "0.1.0" dependencies = [ + "participation-token", "soroban-sdk", - "soroban-token-contract", ] [[package]] @@ -1598,6 +1661,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/apps/smart-contracts/Cargo.toml b/apps/smart-contracts/Cargo.toml index 9277a07..1b93657 100644 --- a/apps/smart-contracts/Cargo.toml +++ b/apps/smart-contracts/Cargo.toml @@ -5,8 +5,8 @@ members = [ ] [workspace.dependencies] -soroban-sdk = "23.1.1" -soroban-token-sdk = { version = "23.1.1" } +soroban-sdk = "25.1.0" +soroban-token-sdk = { version = "25.1.0" } [profile.release] opt-level = "z" diff --git a/apps/smart-contracts/contracts/deployer/Cargo.toml b/apps/smart-contracts/contracts/deployer/Cargo.toml new file mode 100644 index 0000000..f5830c5 --- /dev/null +++ b/apps/smart-contracts/contracts/deployer/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "deployer" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/apps/smart-contracts/contracts/deployer/DEPLOY_ALL.md b/apps/smart-contracts/contracts/deployer/DEPLOY_ALL.md new file mode 100644 index 0000000..0aada48 --- /dev/null +++ b/apps/smart-contracts/contracts/deployer/DEPLOY_ALL.md @@ -0,0 +1,350 @@ +# Deployer Contract — `deploy_all` + +Guía para el equipo backend sobre cómo invocar `deploy_all` desde la API (NestJS + Stellar SDK) para deployar los tres contratos de una campaña en una sola transacción. + +--- + +## ¿Qué hace `deploy_all`? + +Despliega los tres contratos de una campaña en el orden correcto y los conecta entre sí: + +| Paso | Acción | +|------|--------| +| 1 | Deploya **token-sale** con el deployer como admin temporal | +| 2 | Deploya **participation-token** con el token-sale como `mint_authority` directo | +| 3 | Llama `set_token` en token-sale → le informa la dirección del participation-token | +| 4 | Llama `set_admin` en token-sale → transfiere el admin al `token_sale_admin` real | +| 5 | Deploya **vault-contract** apuntando al participation-token | + +Al finalizar, los tres contratos están completamente configurados y listos para operar. No se requiere ninguna llamada adicional post-deploy. + +--- + +## Prerrequisitos + +El contrato deployer debe estar ya instanciado con los WASM hashes de los tres contratos. Estos hashes se configuran una sola vez al deployar el deployer: + +``` +DEPLOYER_CONTRACT_ID=C... # ID del contrato deployer en la red +DEPLOYER_ADMIN_SECRET=S... # Clave secreta del admin del deployer +PARTICIPATION_TOKEN_WASM_HASH=... # hex 64 chars +TOKEN_SALE_WASM_HASH=... # hex 64 chars +VAULT_CONTRACT_WASM_HASH=... # hex 64 chars +``` + +--- + +## Parámetros de `deploy_all` + +La función recibe un único argumento `params` de tipo `DeployAllParams`: + +``` +DeployAllParams { + // ── Salts (determinan las direcciones resultantes) ────────────────────── + token_sale_salt: BytesN<32> // Salt único para el token-sale + participation_salt: BytesN<32> // Salt único para el participation-token + vault_salt: BytesN<32> // Salt único para el vault-contract + + // ── Participation Token (token fungible de la campaña) ─────────────────── + token_name: String // Nombre del token (ej. "Real Estate Fund A") + token_symbol: String // Símbolo del token (ej. "REFA") + escrow_id: String // ID del escrow en Trustless Work (ej. "eng_001") + decimal: u32 // Decimales del token (estándar: 7, máx: 18) + + // ── Token Sale ─────────────────────────────────────────────────────────── + escrow_contract: Address // Dirección del contrato escrow que recibe los USDC + token_sale_admin: Address // Admin del token-sale (puede actualizar caps, set_token) + hard_cap: i128 // Máximo total de tokens a vender (0 = sin límite) + max_per_investor: i128 // Máximo por inversor (0 = sin límite) + + // ── Vault Contract ─────────────────────────────────────────────────────── + vault_admin: Address // Admin del vault (habilita/deshabilita el claim) + vault_enabled: bool // Estado inicial del vault (true = claim habilitado) + roi_percentage: i128 // ROI en porcentaje entero (ej. 5 = 5%) + usdc: Address // Dirección del contrato USDC en la red +} +``` + +> **Sobre los salts:** Son valores de 32 bytes que determinan la dirección resultante de cada contrato de forma determinística. Deben ser **únicos por campaña**. Se recomienda derivarlos del ID de campaña para reproducibilidad: +> +> ```ts +> import { createHash } from 'crypto'; +> +> function saltFromCampaignId(campaignId: string, suffix: string): Buffer { +> return createHash('sha256') +> .update(`${campaignId}:${suffix}`) +> .digest(); +> } +> +> const tokenSaleSalt = saltFromCampaignId(campaign.id, 'token-sale'); +> const participationSalt = saltFromCampaignId(campaign.id, 'participation-token'); +> const vaultSalt = saltFromCampaignId(campaign.id, 'vault'); +> ``` + +--- + +## Respuesta + +``` +DeployedContracts { + participation_token: Address // Dirección del token fungible (ERC-20 equivalente) + token_sale: Address // Dirección del contrato de venta + vault_contract: Address // Dirección del vault para claims de ROI +} +``` + +Las tres direcciones deben guardarse en la base de datos asociadas a la campaña (columnas `tokenFactoryId`, `tokenSaleId`, `vaultId` del modelo `Campaign`). + +--- + +## Implementación en el backend + +### 1. Variables de entorno requeridas + +Agregar a `apps/core/.env`: + +```env +DEPLOYER_CONTRACT_ID=C... +DEPLOYER_ADMIN_SECRET=S... +USDC_CONTRACT_ID=C... +``` + +### 2. Servicio de deploy (NestJS) + +```typescript +// apps/core/src/deploy/deploy.service.ts +import { + Address, + Contract, + Keypair, + Networks, + nativeToScVal, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { SorobanRpc } from '@stellar/stellar-sdk'; + +@Injectable() +export class DeployService { + private readonly rpc: SorobanRpc.Server; + private readonly adminKeypair: Keypair; + private readonly deployerContractId: string; + + constructor() { + this.rpc = new SorobanRpc.Server(process.env.SOROBAN_RPC_URL); + this.adminKeypair = Keypair.fromSecret(process.env.DEPLOYER_ADMIN_SECRET); + this.deployerContractId = process.env.DEPLOYER_CONTRACT_ID; + } + + async deployAll(dto: DeployAllDto): Promise { + const { campaignId, tokenName, tokenSymbol, escrowId, escrowContractId, + tokenSaleAdmin, hardCap, maxPerInvestor, + vaultAdmin, vaultEnabled, roiPercentage } = dto; + + // 1. Derivar salts únicos a partir del campaignId + const tokenSaleSalt = this.saltFromId(campaignId, 'token-sale'); + const participationSalt = this.saltFromId(campaignId, 'participation-token'); + const vaultSalt = this.saltFromId(campaignId, 'vault'); + + // 2. Construir el argumento params como ScVal (struct XDR) + const params = xdr.ScVal.scvMap([ + this.entry('token_sale_salt', nativeToScVal(tokenSaleSalt, { type: 'bytes' })), + this.entry('participation_salt', nativeToScVal(participationSalt, { type: 'bytes' })), + this.entry('vault_salt', nativeToScVal(vaultSalt, { type: 'bytes' })), + this.entry('token_name', nativeToScVal(tokenName, { type: 'string' })), + this.entry('token_symbol', nativeToScVal(tokenSymbol, { type: 'string' })), + this.entry('escrow_id', nativeToScVal(escrowId, { type: 'string' })), + this.entry('decimal', nativeToScVal(7, { type: 'u32' })), + this.entry('escrow_contract', new Address(escrowContractId).toScVal()), + this.entry('token_sale_admin', new Address(tokenSaleAdmin).toScVal()), + this.entry('hard_cap', nativeToScVal(BigInt(hardCap), { type: 'i128' })), + this.entry('max_per_investor', nativeToScVal(BigInt(maxPerInvestor), { type: 'i128' })), + this.entry('vault_admin', new Address(vaultAdmin).toScVal()), + this.entry('vault_enabled', nativeToScVal(vaultEnabled, { type: 'bool' })), + this.entry('roi_percentage', nativeToScVal(BigInt(roiPercentage), { type: 'i128' })), + this.entry('usdc', new Address(process.env.USDC_CONTRACT_ID).toScVal()), + ]); + + // 3. Construir la transacción + const account = await this.rpc.getAccount(this.adminKeypair.publicKey()); + const contract = new Contract(this.deployerContractId); + + let tx = new TransactionBuilder(account, { + fee: '1000000', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call('deploy_all', params)) + .setTimeout(300) + .build(); + + // 4. Simular para obtener el footprint de auth y recursos + const simResult = await this.rpc.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); + } + + tx = SorobanRpc.assembleTransaction(tx, simResult).build(); + + // 5. Firmar y enviar + tx.sign(this.adminKeypair); + const sendResult = await this.rpc.sendTransaction(tx); + + if (sendResult.status === 'ERROR') { + throw new Error(`Send failed: ${sendResult.errorResult}`); + } + + // 6. Esperar confirmación + const confirmed = await this.pollTransaction(sendResult.hash); + + // 7. Parsear el resultado + const returnVal = confirmed.returnValue; + const resultMap = scValToNative(returnVal) as Record; + + return { + participationToken: resultMap['participation_token'], + tokenSale: resultMap['token_sale'], + vaultContract: resultMap['vault_contract'], + }; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private saltFromId(id: string, suffix: string): Buffer { + return createHash('sha256').update(`${id}:${suffix}`).digest(); + } + + private entry(key: string, val: xdr.ScVal): xdr.ScMapEntry { + return new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol(key), + val, + }); + } + + private async pollTransaction(hash: string): Promise { + const MAX_ATTEMPTS = 40; + const DELAY_MS = 3000; + + for (let i = 0; i < MAX_ATTEMPTS; i++) { + await new Promise(r => setTimeout(r, DELAY_MS)); + const result = await this.rpc.getTransaction(hash); + + if (result.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return result as SorobanRpc.Api.GetSuccessfulTransactionResponse; + } + if (result.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error(`Transaction failed: ${JSON.stringify(result)}`); + } + } + throw new Error(`Transaction ${hash} timed out after ${MAX_ATTEMPTS} attempts`); + } +} +``` + +### 3. DTO + +```typescript +// apps/core/src/deploy/dto/deploy-all.dto.ts +import { IsString, IsBoolean, IsNumber, IsOptional, Min } from 'class-validator'; + +export class DeployAllDto { + @IsString() + campaignId: string; // UUID de la campaña en la DB + + @IsString() + tokenName: string; // Nombre del token + + @IsString() + tokenSymbol: string; // Símbolo del token + + @IsString() + escrowId: string; // engagement_id del escrow en Trustless Work + + @IsString() + escrowContractId: string; // Dirección C... del contrato escrow + + @IsString() + tokenSaleAdmin: string; // Dirección G/C del admin del token-sale + + @IsNumber() + @Min(0) + hardCap: number; // 0 = sin límite + + @IsNumber() + @Min(0) + maxPerInvestor: number; // 0 = sin límite + + @IsString() + vaultAdmin: string; // Dirección G/C del admin del vault + + @IsBoolean() + vaultEnabled: boolean; // Estado inicial del vault + + @IsNumber() + @Min(0) + roiPercentage: number; // Entero: 5 = 5%, 10 = 10% +} +``` + +### 4. Guardar resultados en la base de datos + +```typescript +// Dentro del CampaignsService, tras llamar deployAll: +const deployed = await this.deployService.deployAll(dto); + +await this.prisma.campaign.update({ + where: { id: dto.campaignId }, + data: { + tokenFactoryId: deployed.participationToken, + tokenSaleId: deployed.tokenSale, + vaultId: deployed.vaultContract, + status: CampaignStatus.ACTIVE, + }, +}); +``` + +--- + +## Autorización + +`deploy_all` llama internamente a `admin.require_auth()`. Esto significa que: + +- La cuenta que firma la transacción **debe ser el admin del deployer** (el `DEPLOYER_ADMIN_SECRET`). +- El backend firma la transacción directamente en el servidor (no se devuelve XDR sin firmar al frontend). +- El `token_sale_admin` y el `vault_admin` son direcciones de control operacional post-deploy, **no** necesitan firmar esta transacción. + +--- + +## Flujo completo desde el frontend + +``` +Frontend (backoffice) Backend (core) Stellar / Soroban +───────────────────── ────────────── ───────────────── +POST /campaigns/deploy ──────► deployAll(dto) + │ + ├─ buildTx(deploy_all, params) + ├─ simulate(tx) + ├─ assemble(tx) + ├─ sign(tx, DEPLOYER_ADMIN_SECRET) + ├─ sendTransaction(tx) ────────────────────► + │ │ + │ [5 pasos internos en 1 tx] + │ │ + ◄─ pollTransaction(hash) ◄────────────────── + │ + ├─ parse(returnValue) + ├─ campaign.update({ tokenFactoryId, tokenSaleId, vaultId }) + │ +◄─────────────────────────────── { participationToken, tokenSale, vaultContract } +``` + +--- + +## Errores comunes + +| Error | Causa | Solución | +|-------|-------|----------| +| `Auth error` | La cuenta firmante no es el admin del deployer | Verificar `DEPLOYER_ADMIN_SECRET` | +| `MismatchingParameterLen` | El WASM almacenado en el deployer es de una versión antigua | Re-deployar el deployer con los nuevos WASMs o llamar `update_wasm` | +| `CONTRACT_ALREADY_EXISTS` | Se están reutilizando salts de una campaña ya deployada | Usar un `campaignId` distinto o sufijo diferente en la función de salt | +| `InvalidRoiPercentage` | `roiPercentage > 1000` | El ROI máximo permitido es 1000% | +| `Simulation failed` | Fondos insuficientes en la cuenta admin para fees | Cargar XLM en la cuenta `DEPLOYER_ADMIN_SECRET` | diff --git a/apps/smart-contracts/contracts/deployer/src/deployer.rs b/apps/smart-contracts/contracts/deployer/src/deployer.rs new file mode 100644 index 0000000..f529e67 --- /dev/null +++ b/apps/smart-contracts/contracts/deployer/src/deployer.rs @@ -0,0 +1,306 @@ +use soroban_sdk::{ + contract, contractimpl, contracttype, Address, BytesN, Env, IntoVal, String, Symbol, Val, Vec, + vec, +}; + +use crate::storage_types::DataKey; + +/// Result of deploying the full contract suite. +#[derive(Clone, Debug)] +#[contracttype] +pub struct DeployedContracts { + pub participation_token: Address, + pub token_sale: Address, + pub vault_contract: Address, +} + +/// Parameters for deploying the full contract suite. +#[derive(Clone, Debug)] +#[contracttype] +pub struct DeployAllParams { + pub participation_salt: BytesN<32>, + pub token_sale_salt: BytesN<32>, + pub vault_salt: BytesN<32>, + pub token_name: String, + pub token_symbol: String, + pub escrow_id: String, + pub decimal: u32, + pub escrow_contract: Address, + pub vault_admin: Address, + pub vault_enabled: bool, + pub roi_percentage: i128, + pub usdc: Address, + pub token_sale_admin: Address, + pub hard_cap: i128, + pub max_per_investor: i128, +} + +#[contract] +pub struct DeployerContract; + +#[contractimpl] +impl DeployerContract { + /// Initializes the deployer factory with the admin and WASM hashes + /// of the contracts it will deploy. + /// + /// # Arguments + /// * `admin` - The deployer admin address + /// * `participation_token_wasm` - WASM hash for the participation-token contract + /// * `token_sale_wasm` - WASM hash for the token-sale contract + /// * `vault_contract_wasm` - WASM hash for the vault-contract contract + pub fn __constructor( + env: Env, + admin: Address, + participation_token_wasm: BytesN<32>, + token_sale_wasm: BytesN<32>, + vault_contract_wasm: BytesN<32>, + ) { + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::ParticipationTokenWasm, &participation_token_wasm); + env.storage() + .instance() + .set(&DataKey::TokenSaleWasm, &token_sale_wasm); + env.storage() + .instance() + .set(&DataKey::VaultContractWasm, &vault_contract_wasm); + } + + // ============ Individual Deploy Functions ============ + + /// Deploys a new participation-token (fungible token) instance. + /// + /// # Arguments + /// * `salt` - Unique salt for deterministic address derivation + /// * `name` - Token name + /// * `symbol` - Token symbol + /// * `escrow_id` - Escrow contract ID (immutable after init) + /// * `decimal` - Token decimals (max 18) + /// * `mint_authority` - Address authorized to mint tokens + pub fn deploy_participation_token( + env: Env, + salt: BytesN<32>, + name: String, + symbol: String, + escrow_id: String, + decimal: u32, + mint_authority: Address, + ) -> Address { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let wasm_hash: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::ParticipationTokenWasm) + .unwrap(); + + let constructor_args: Vec = ( + name, + symbol, + escrow_id, + decimal, + mint_authority, + ) + .into_val(&env); + + env.deployer() + .with_current_contract(salt) + .deploy_v2(wasm_hash, constructor_args) + } + + /// Deploys a new token-sale contract instance. + /// + /// # Arguments + /// * `salt` - Unique salt for deterministic address derivation + /// * `escrow_contract` - The escrow contract address to receive USDC + /// * `token_sale_admin` - The admin address for the token-sale contract + /// * `hard_cap` - Maximum total tokens that can be sold (0 = no limit) + /// * `max_per_investor` - Maximum tokens per investor (0 = no limit) + /// + /// Note: the participation-token address must be set after deployment + /// by calling `set_token` from the token-sale admin. + pub fn deploy_token_sale( + env: Env, + salt: BytesN<32>, + escrow_contract: Address, + token_sale_admin: Address, + hard_cap: i128, + max_per_investor: i128, + ) -> Address { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let wasm_hash: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::TokenSaleWasm) + .unwrap(); + + let constructor_args: Vec = ( + escrow_contract, + token_sale_admin, + hard_cap, + max_per_investor, + ).into_val(&env); + + env.deployer() + .with_current_contract(salt) + .deploy_v2(wasm_hash, constructor_args) + } + + /// Deploys a new vault-contract instance. + /// + /// # Arguments + /// * `salt` - Unique salt for deterministic address derivation + /// * `vault_admin` - The admin address for the vault + /// * `enabled` - Initial enabled state for claiming + /// * `roi_percentage` - ROI percentage (e.g., 5 for 5%) + /// * `token` - The participation token address + /// * `usdc` - The USDC stablecoin contract address + pub fn deploy_vault_contract( + env: Env, + salt: BytesN<32>, + vault_admin: Address, + enabled: bool, + roi_percentage: i128, + token: Address, + usdc: Address, + ) -> Address { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let wasm_hash: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::VaultContractWasm) + .unwrap(); + + let constructor_args: Vec = ( + vault_admin, + enabled, + roi_percentage, + token, + usdc, + ) + .into_val(&env); + + env.deployer() + .with_current_contract(salt) + .deploy_v2(wasm_hash, constructor_args) + } + + // ============ Full Suite Deploy ============ + + /// Deploys all three contracts in the correct order. + /// + /// Strategy: + /// 1. Deploy token-sale (no participation-token needed in constructor) + /// 2. Deploy participation-token with token-sale as direct mint_authority + /// 3. Wire token-sale → participation-token via `set_token` (deployer is temp admin) + /// 4. Transfer token-sale admin to params.token_sale_admin via `set_admin` + /// 5. Deploy vault-contract pointing to the participation-token + pub fn deploy_all(env: Env, params: DeployAllParams) -> DeployedContracts { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let participation_token_wasm: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::ParticipationTokenWasm) + .unwrap(); + let token_sale_wasm: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::TokenSaleWasm) + .unwrap(); + let vault_contract_wasm: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::VaultContractWasm) + .unwrap(); + + let deployer_addr = env.current_contract_address(); + + // Step 1: Deploy token-sale with deployer as temporary admin + // (allows deployer to call set_token and set_admin in steps 3-4) + let token_sale_args: Vec = ( + params.escrow_contract, + deployer_addr.clone(), + params.hard_cap, + params.max_per_investor, + ).into_val(&env); + + let token_sale_addr = env + .deployer() + .with_current_contract(params.token_sale_salt) + .deploy_v2(token_sale_wasm, token_sale_args); + + // Step 2: Deploy participation-token with token-sale as direct mint_authority + let participation_token_args: Vec = ( + params.token_name, + params.token_symbol, + params.escrow_id, + params.decimal, + token_sale_addr.clone(), + ) + .into_val(&env); + + let participation_token_addr = env + .deployer() + .with_current_contract(params.participation_salt) + .deploy_v2(participation_token_wasm, participation_token_args); + + // Step 3: Wire token-sale to its participation-token + let set_token_args = vec![&env, participation_token_addr.clone().into_val(&env)]; + env.invoke_contract::<()>( + &token_sale_addr, + &Symbol::new(&env, "set_token"), + set_token_args, + ); + + // Step 4: Transfer token-sale admin to the intended admin + let set_admin_args = vec![&env, params.token_sale_admin.into_val(&env)]; + env.invoke_contract::<()>( + &token_sale_addr, + &Symbol::new(&env, "set_admin"), + set_admin_args, + ); + + // Step 5: Deploy vault-contract pointing to the participation-token + let vault_args: Vec = ( + params.vault_admin, + params.vault_enabled, + params.roi_percentage, + participation_token_addr.clone(), + params.usdc, + ) + .into_val(&env); + + let vault_addr = env + .deployer() + .with_current_contract(params.vault_salt) + .deploy_v2(vault_contract_wasm, vault_args); + + DeployedContracts { + participation_token: participation_token_addr, + token_sale: token_sale_addr, + vault_contract: vault_addr, + } + } + + // ============ Admin Functions ============ + + /// Updates a WASM hash for a contract type. + pub fn update_wasm(env: Env, key: DataKey, new_wasm_hash: BytesN<32>) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + env.storage().instance().set(&key, &new_wasm_hash); + } + + /// Returns the stored admin address. + pub fn get_admin(env: Env) -> Address { + env.storage().instance().get(&DataKey::Admin).unwrap() + } +} diff --git a/apps/smart-contracts/contracts/deployer/src/lib.rs b/apps/smart-contracts/contracts/deployer/src/lib.rs new file mode 100644 index 0000000..9b5cab7 --- /dev/null +++ b/apps/smart-contracts/contracts/deployer/src/lib.rs @@ -0,0 +1,9 @@ +#![no_std] + +mod deployer; +mod storage_types; + +#[cfg(test)] +mod test; + +pub use crate::deployer::DeployerContract; diff --git a/apps/smart-contracts/contracts/deployer/src/storage_types.rs b/apps/smart-contracts/contracts/deployer/src/storage_types.rs new file mode 100644 index 0000000..665eeb6 --- /dev/null +++ b/apps/smart-contracts/contracts/deployer/src/storage_types.rs @@ -0,0 +1,10 @@ +use soroban_sdk::contracttype; + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + Admin, + ParticipationTokenWasm, + TokenSaleWasm, + VaultContractWasm, +} diff --git a/apps/smart-contracts/contracts/deployer/src/test.rs b/apps/smart-contracts/contracts/deployer/src/test.rs new file mode 100644 index 0000000..da8ce0e --- /dev/null +++ b/apps/smart-contracts/contracts/deployer/src/test.rs @@ -0,0 +1,169 @@ +#![cfg(test)] +extern crate std; + +use crate::deployer::{DeployAllParams, DeployerContract, DeployerContractClient}; +use crate::storage_types::DataKey; +use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String}; + +mod participation_token_wasm { + soroban_sdk::contractimport!( + file = "../../target/wasm32v1-none/release/participation_token.wasm" + ); +} + +mod token_sale_wasm { + soroban_sdk::contractimport!( + file = "../../target/wasm32v1-none/release/token_sale.wasm" + ); +} + +mod vault_contract_wasm { + soroban_sdk::contractimport!( + file = "../../target/wasm32v1-none/release/vault_contract.wasm" + ); +} + +fn setup_deployer<'a>(env: &Env, admin: &Address) -> DeployerContractClient<'a> { + let participation_token_hash = env.deployer().upload_contract_wasm(participation_token_wasm::WASM); + let token_sale_hash = env.deployer().upload_contract_wasm(token_sale_wasm::WASM); + let vault_contract_hash = env.deployer().upload_contract_wasm(vault_contract_wasm::WASM); + + let contract_id = env.register( + DeployerContract, + ( + admin.clone(), + participation_token_hash, + token_sale_hash, + vault_contract_hash, + ), + ); + + DeployerContractClient::new(env, &contract_id) +} + +#[test] +fn test_deploy_participation_token() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let mint_authority = Address::generate(&env); + let deployer = setup_deployer(&env, &admin); + + let salt = BytesN::from_array(&env, &[1u8; 32]); + + let token_addr = deployer.deploy_participation_token( + &salt, + &String::from_str(&env, "TestToken"), + &String::from_str(&env, "TST"), + &String::from_str(&env, "escrow-001"), + &7u32, + &mint_authority, + ); + + assert_ne!(token_addr, admin); +} + +#[test] +fn test_deploy_token_sale() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let escrow_contract = Address::generate(&env); + let deployer = setup_deployer(&env, &admin); + + let salt = BytesN::from_array(&env, &[2u8; 32]); + + let token_sale_admin = Address::generate(&env); + let token_sale_addr = deployer.deploy_token_sale( + &salt, + &escrow_contract, + &token_sale_admin, + &1_000_000i128, + &10_000i128, + ); + + assert_ne!(token_sale_addr, admin); +} + +#[test] +fn test_deploy_vault_contract() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let vault_admin = Address::generate(&env); + let token = Address::generate(&env); + let usdc = Address::generate(&env); + let deployer = setup_deployer(&env, &admin); + + let salt = BytesN::from_array(&env, &[3u8; 32]); + + let vault_addr = + deployer.deploy_vault_contract(&salt, &vault_admin, &true, &5i128, &token, &usdc); + + assert_ne!(vault_addr, admin); +} + +#[test] +fn test_deploy_all() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let escrow_contract = Address::generate(&env); + let vault_admin = Address::generate(&env); + let token_sale_admin = Address::generate(&env); + let usdc = Address::generate(&env); + let deployer = setup_deployer(&env, &admin); + + let token_sale_salt = BytesN::from_array(&env, &[10u8; 32]); + let participation_salt = BytesN::from_array(&env, &[11u8; 32]); + let vault_salt = BytesN::from_array(&env, &[12u8; 32]); + + let result = deployer.deploy_all(&DeployAllParams { + participation_salt, + token_sale_salt, + vault_salt, + token_name: String::from_str(&env, "CampaignToken"), + token_symbol: String::from_str(&env, "CAMP"), + escrow_id: String::from_str(&env, "escrow-001"), + decimal: 7u32, + escrow_contract, + vault_admin, + vault_enabled: true, + roi_percentage: 5i128, + usdc, + token_sale_admin, + hard_cap: 1_000_000i128, + max_per_investor: 10_000i128, + }); + + // All three deployed addresses should be distinct + assert_ne!(result.participation_token, result.token_sale); + assert_ne!(result.participation_token, result.vault_contract); + assert_ne!(result.token_sale, result.vault_contract); +} + +#[test] +fn test_update_wasm() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let deployer = setup_deployer(&env, &admin); + + let new_hash = BytesN::from_array(&env, &[99u8; 32]); + deployer.update_wasm(&DataKey::ParticipationTokenWasm, &new_hash); +} + +#[test] +fn test_get_admin() { + let env = Env::default(); + + let admin = Address::generate(&env); + let deployer = setup_deployer(&env, &admin); + + assert_eq!(deployer.get_admin(), admin); +} diff --git a/apps/smart-contracts/contracts/participation-token/Cargo.toml b/apps/smart-contracts/contracts/participation-token/Cargo.toml index c37e697..0d26fe1 100644 --- a/apps/smart-contracts/contracts/participation-token/Cargo.toml +++ b/apps/smart-contracts/contracts/participation-token/Cargo.toml @@ -1,16 +1,35 @@ + [package] name = "participation-token" -version = "0.1.0" +description = "Soroban standard token contract" +version = "0.0.6" edition = "2021" +rust-version = "1.86.0" + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[features] +testutils = ["soroban-sdk/testutils"] [dependencies] -soroban-sdk = { workspace = true } +soroban-sdk = { version = "25.1.0" } +soroban-token-sdk = { version = "25.1.0" } [dev-dependencies] -soroban-sdk = { workspace = true, features = ["testutils"] } -escrow = { path = "../escrow", features = ["testutils"] } -soroban-token-contract = { path = "../token-factory", features = ["testutils"] } +soroban-sdk = { version = "25.1.0", features = ["testutils"] } -[lib] -crate-type = ["cdylib", "rlib"] -doctest = false +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true diff --git a/apps/smart-contracts/contracts/token-factory/src/allowance.rs b/apps/smart-contracts/contracts/participation-token/src/allowance.rs similarity index 83% rename from apps/smart-contracts/contracts/token-factory/src/allowance.rs rename to apps/smart-contracts/contracts/participation-token/src/allowance.rs index 83a9314..27b64d3 100644 --- a/apps/smart-contracts/contracts/token-factory/src/allowance.rs +++ b/apps/smart-contracts/contracts/participation-token/src/allowance.rs @@ -42,9 +42,11 @@ pub fn write_allowance( if amount > 0 { let live_for = expiration_ledger .checked_sub(e.ledger().sequence()) - .unwrap(); + .expect("expiration_ledger must be >= current ledger sequence"); - e.storage().temporary().extend_ttl(&key, live_for, live_for) + e.storage() + .temporary() + .extend_ttl(&key, live_for, live_for) } } @@ -54,11 +56,15 @@ pub fn spend_allowance(e: &Env, from: Address, spender: Address, amount: i128) { panic!("insufficient allowance"); } if amount > 0 { + let new_allowance = allowance + .amount + .checked_sub(amount) + .expect("allowance underflow"); write_allowance( e, from, spender, - allowance.amount - amount, + new_allowance, allowance.expiration_ledger, ); } diff --git a/apps/smart-contracts/contracts/token-factory/src/balance.rs b/apps/smart-contracts/contracts/participation-token/src/balance.rs similarity index 78% rename from apps/smart-contracts/contracts/token-factory/src/balance.rs rename to apps/smart-contracts/contracts/participation-token/src/balance.rs index 66184d4..968184a 100644 --- a/apps/smart-contracts/contracts/token-factory/src/balance.rs +++ b/apps/smart-contracts/contracts/participation-token/src/balance.rs @@ -23,7 +23,10 @@ fn write_balance(e: &Env, addr: Address, amount: i128) { pub fn receive_balance(e: &Env, addr: Address, amount: i128) { let balance = read_balance(e, addr.clone()); - write_balance(e, addr, balance + amount); + let new_balance = balance + .checked_add(amount) + .expect("balance overflow"); + write_balance(e, addr, new_balance); } pub fn spend_balance(e: &Env, addr: Address, amount: i128) { @@ -31,5 +34,8 @@ pub fn spend_balance(e: &Env, addr: Address, amount: i128) { if balance < amount { panic!("insufficient balance"); } - write_balance(e, addr, balance - amount); + let new_balance = balance + .checked_sub(amount) + .expect("balance underflow"); + write_balance(e, addr, new_balance); } \ No newline at end of file diff --git a/apps/smart-contracts/contracts/token-factory/src/contract.rs b/apps/smart-contracts/contracts/participation-token/src/contract.rs similarity index 100% rename from apps/smart-contracts/contracts/token-factory/src/contract.rs rename to apps/smart-contracts/contracts/participation-token/src/contract.rs diff --git a/apps/smart-contracts/contracts/participation-token/src/lib.rs b/apps/smart-contracts/contracts/participation-token/src/lib.rs index e1fec63..544cb58 100644 --- a/apps/smart-contracts/contracts/participation-token/src/lib.rs +++ b/apps/smart-contracts/contracts/participation-token/src/lib.rs @@ -1,8 +1,10 @@ #![no_std] -mod sale; - -pub use crate::sale::ParticipationTokenContract; - -#[cfg(test)] +mod allowance; +mod balance; +mod contract; +mod metadata; +mod storage_types; mod test; + +pub use crate::contract::{Token, TokenClient}; \ No newline at end of file diff --git a/apps/smart-contracts/contracts/token-factory/src/metadata.rs b/apps/smart-contracts/contracts/participation-token/src/metadata.rs similarity index 100% rename from apps/smart-contracts/contracts/token-factory/src/metadata.rs rename to apps/smart-contracts/contracts/participation-token/src/metadata.rs diff --git a/apps/smart-contracts/contracts/participation-token/src/sale.rs b/apps/smart-contracts/contracts/participation-token/src/sale.rs deleted file mode 100644 index 0c52387..0000000 --- a/apps/smart-contracts/contracts/participation-token/src/sale.rs +++ /dev/null @@ -1,60 +0,0 @@ -use soroban_sdk::{Address, Env, IntoVal, Symbol, Val, contract, contractimpl, token, vec}; -use token::Client as TokenClient; - -#[contract] -pub struct ParticipationTokenContract; - -#[derive(Clone)] -pub struct Config { - pub escrow_contract: Address, - pub participation_token: Address, -} - -fn read_config(e: &Env) -> Config { - let escrow_key: Val = "escrow".into_val(e); - let token_key: Val = "token".into_val(e); - - let escrow_contract: Address = e - .storage() - .instance() - .get(&escrow_key) - .unwrap(); - let participation_token: Address = e - .storage() - .instance() - .get(&token_key) - .unwrap(); - Config { - escrow_contract, - participation_token, - } -} - -fn write_config(e: &Env, escrow_contract: &Address, participation_token: &Address) { - let escrow_key: Val = "escrow".into_val(e); - let token_key: Val = "token".into_val(e); - - e.storage().instance().set(&escrow_key, escrow_contract); - e.storage().instance().set(&token_key, participation_token); -} - -#[contractimpl] -impl ParticipationTokenContract { - pub fn __constructor(env: Env, escrow_contract: Address, participation_token: Address) { - write_config(&env, &escrow_contract, &participation_token); - } - - pub fn buy(env: Env, usdc: Address, payer: Address, beneficiary: Address, amount: i128) { - payer.require_auth(); - - let cfg = read_config(&env); - - let usdc_client = TokenClient::new(&env, &usdc); - usdc_client.transfer(&payer, &cfg.escrow_contract, &amount); - - let mint_sym = Symbol::new(&env, "mint"); - let args_vec = vec![&env, beneficiary.into_val(&env), amount.into_val(&env)]; - - let _: () = env.invoke_contract(&cfg.participation_token, &mint_sym, args_vec); - } -} diff --git a/apps/smart-contracts/contracts/token-factory/src/storage_types.rs b/apps/smart-contracts/contracts/participation-token/src/storage_types.rs similarity index 100% rename from apps/smart-contracts/contracts/token-factory/src/storage_types.rs rename to apps/smart-contracts/contracts/participation-token/src/storage_types.rs diff --git a/apps/smart-contracts/contracts/participation-token/src/test.rs b/apps/smart-contracts/contracts/participation-token/src/test.rs index a80eb6e..adbcf60 100644 --- a/apps/smart-contracts/contracts/participation-token/src/test.rs +++ b/apps/smart-contracts/contracts/participation-token/src/test.rs @@ -1,127 +1,494 @@ #![cfg(test)] extern crate std; -use crate::sale::{ParticipationTokenContract, ParticipationTokenContractClient}; -use escrow::{Escrow, EscrowContract, EscrowContractClient, Flags, Milestone, Roles, Trustline}; -use soroban_sdk::{testutils::Address as _, token, vec, Address, Env, String}; -use token::Client as TokenClient; -use token::StellarAssetClient as TokenAdminClient; -use soroban_token_contract::{Token as FactoryToken, TokenClient as FactoryTokenClient}; +use crate::{contract::Token, TokenClient}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, + Address, Env, FromVal, IntoVal, String, Symbol, Vec, +}; -fn create_usdc_token<'a>(e: &Env, admin: &Address) -> (TokenClient<'a>, TokenAdminClient<'a>) { - let sac = e.register_stellar_asset_contract_v2(admin.clone()); - ( - TokenClient::new(e, &sac.address()), - TokenAdminClient::new(e, &sac.address()), - ) +fn create_token<'a>(e: &Env, mint_authority: &Address, escrow_id: &str) -> TokenClient<'a> { + let token_contract = e.register( + Token, + ( + String::from_val(e, &"TestToken"), + String::from_val(e, &"TST"), + String::from_val(e, &escrow_id), + 7_u32, + mint_authority, + ), + ); + TokenClient::new(e, &token_contract) +} + +#[test] +fn test() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let user3 = Address::generate(&e); + let escrow_id = "test_escrow_123"; + let token = create_token(&e, &mint_authority, escrow_id); + + token.mint(&user1, &1000); + assert_eq!( + e.auths(), + std::vec![( + mint_authority.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + token.address.clone(), + symbol_short!("mint"), + (&user1, 1000_i128).into_val(&e), + )), + sub_invocations: std::vec![] + } + )] + ); + assert_eq!(token.balance(&user1), 1000); + + token.approve(&user2, &user3, &500, &200); + assert_eq!( + e.auths(), + std::vec![( + user2.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + token.address.clone(), + symbol_short!("approve"), + (&user2, &user3, 500_i128, 200_u32).into_val(&e), + )), + sub_invocations: std::vec![] + } + )] + ); + assert_eq!(token.allowance(&user2, &user3), 500); + + token.transfer(&user1, &user2, &600); + assert_eq!( + e.auths(), + std::vec![( + user1.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + token.address.clone(), + symbol_short!("transfer"), + (&user1, &user2, 600_i128).into_val(&e), + )), + sub_invocations: std::vec![] + } + )] + ); + assert_eq!(token.balance(&user1), 400); + assert_eq!(token.balance(&user2), 600); + + token.transfer_from(&user3, &user2, &user1, &400); + assert_eq!( + e.auths(), + std::vec![( + user3.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + token.address.clone(), + Symbol::new(&e, "transfer_from"), + (&user3, &user2, &user1, 400_i128).into_val(&e), + )), + sub_invocations: std::vec![] + } + )] + ); + assert_eq!(token.balance(&user1), 800); + assert_eq!(token.balance(&user2), 200); + + token.transfer(&user1, &user3, &300); + assert_eq!(token.balance(&user1), 500); + assert_eq!(token.balance(&user3), 300); + + // Increase to 500 + token.approve(&user2, &user3, &500, &200); + assert_eq!(token.allowance(&user2, &user3), 500); + token.approve(&user2, &user3, &0, &200); + assert_eq!( + e.auths(), + std::vec![( + user2.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + token.address.clone(), + symbol_short!("approve"), + (&user2, &user3, 0_i128, 200_u32).into_val(&e), + )), + sub_invocations: std::vec![] + } + )] + ); + assert_eq!(token.allowance(&user2, &user3), 0); +} + +#[test] +fn test_burn() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let escrow_id = "test_escrow_456"; + let token = create_token(&e, &mint_authority, escrow_id); + + token.mint(&user1, &1000); + assert_eq!(token.balance(&user1), 1000); + + token.approve(&user1, &user2, &500, &200); + assert_eq!(token.allowance(&user1, &user2), 500); + + token.burn_from(&user2, &user1, &500); + assert_eq!( + e.auths(), + std::vec![( + user2.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + token.address.clone(), + symbol_short!("burn_from"), + (&user2, &user1, 500_i128).into_val(&e), + )), + sub_invocations: std::vec![] + } + )] + ); + + assert_eq!(token.allowance(&user1, &user2), 0); + assert_eq!(token.balance(&user1), 500); + assert_eq!(token.balance(&user2), 0); + + token.burn(&user1, &500); + assert_eq!( + e.auths(), + std::vec![( + user1.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + token.address.clone(), + symbol_short!("burn"), + (&user1, 500_i128).into_val(&e), + )), + sub_invocations: std::vec![] + } + )] + ); + + assert_eq!(token.balance(&user1), 0); + assert_eq!(token.balance(&user2), 0); } -fn create_escrow_contract<'a>(env: &Env) -> EscrowContractClient<'a> { - EscrowContractClient::new(env, &env.register(EscrowContract {}, ())) +#[test] +#[should_panic(expected = "insufficient balance")] +fn transfer_insufficient_balance() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let escrow_id = "test_escrow_789"; + let token = create_token(&e, &mint_authority, escrow_id); + + token.mint(&user1, &1000); + assert_eq!(token.balance(&user1), 1000); + + token.transfer(&user1, &user2, &1001); } -fn create_token_factory<'a>(e: &Env, mint_authority: &Address) -> FactoryTokenClient<'a> { +#[test] +#[should_panic(expected = "insufficient allowance")] +fn transfer_from_insufficient_allowance() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let user3 = Address::generate(&e); + let escrow_id = "test_escrow_101112"; + let token = create_token(&e, &mint_authority, escrow_id); + + token.mint(&user1, &1000); + assert_eq!(token.balance(&user1), 1000); + + token.approve(&user1, &user3, &100, &200); + assert_eq!(token.allowance(&user1, &user3), 100); + + token.transfer_from(&user3, &user1, &user2, &101); +} + +#[test] +#[should_panic(expected = "Decimal must not be greater than 18")] +fn decimal_is_over_eighteen() { + let e = Env::default(); + let mint_authority = Address::generate(&e); + let _ = TokenClient::new( + &e, + &e.register( + Token, + ( + String::from_val(&e, &"name"), + String::from_val(&e, &"symbol"), + String::from_val(&e, &"escrow_123"), + 19_u32, + mint_authority, + ), + ), + ); +} + +// New tests for T-REX alignment + +#[test] +fn test_metadata_getters() { + let e = Env::default(); + let mint_authority = Address::generate(&e); + let escrow_id = "test_escrow_metadata"; + let token = create_token(&e, &mint_authority, escrow_id); + + // Test standard metadata getters + assert_eq!(token.name(), String::from_val(&e, &"TestToken")); + assert_eq!(token.symbol(), String::from_val(&e, &"TST")); + assert_eq!(token.decimals(), 7); + + // Test escrow_id getter + let token_contract = token.address.clone(); + let escrow_id_result: String = e + .invoke_contract(&token_contract, &symbol_short!("escrow_id"), Vec::new(&e)); + assert_eq!(escrow_id_result, String::from_val(&e, &escrow_id)); +} + +#[test] +fn test_mint_authority_can_mint() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let user = Address::generate(&e); + let escrow_id = "test_escrow_mint"; + let token = create_token(&e, &mint_authority, escrow_id); + + // Mint authority should be able to mint + // With mock_all_auths(), the mint_authority's auth is automatically provided + token.mint(&user, &1000); + assert_eq!(token.balance(&user), 1000); + + // Verify balance increased correctly + token.mint(&user, &500); + assert_eq!(token.balance(&user), 1500); +} + +#[test] +#[should_panic] +fn test_deployer_cannot_mint() { + let e = Env::default(); + // Don't mock all auths - we want to test that only mint_authority can mint let token_contract = e.register( - FactoryToken, + Token, ( - String::from_str(e, "SaleToken"), - String::from_str(e, "SALE"), - String::from_str(e, "eng_1"), + String::from_val(&e, &"TestToken"), + String::from_val(&e, &"TST"), + String::from_val(&e, &"test_escrow_deployer"), 7_u32, - mint_authority, + Address::generate(&e), // mint_authority ), ); - FactoryTokenClient::new(e, &token_contract) + + e.as_contract(&token_contract, || { + let user = Address::generate(&e); + + // Try to mint as deployer (should fail because deployer != mint_authority) + // Without mint_authority's auth, this should panic + // This will fail because we're not providing mint_authority's auth + let mut args = Vec::new(&e); + args.push_back(user.to_val()); + args.push_back(1000_i128.into_val(&e)); + let _: () = e + .invoke_contract( + &token_contract, + &symbol_short!("mint"), + args, + ); + }); } -fn create_participation_token<'a>(e: &Env, escrow_addr: &Address, sale_token_addr: &Address) -> ParticipationTokenContractClient<'a> { - let contract_id = e.register(ParticipationTokenContract, (escrow_addr.clone(), sale_token_addr.clone())); - ParticipationTokenContractClient::new(e, &contract_id) +#[test] +#[should_panic(expected = "Escrow ID already set")] +fn test_metadata_immutability() { + let e = Env::default(); + let mint_authority = Address::generate(&e); + let escrow_id = "test_escrow_immutable"; + + // Create token (initializes metadata via __constructor) + let token_contract = e.register( + Token, + ( + String::from_val(&e, &"TestToken"), + String::from_val(&e, &"TST"), + String::from_val(&e, &escrow_id), + 7_u32, + mint_authority.clone(), + ), + ); + + // Try to write escrow_id again (should panic - immutability enforced) + // We need to wrap in as_contract to access storage + e.as_contract(&token_contract, || { + use crate::metadata::write_escrow_id; + let new_escrow_id = String::from_val(&e, &"new_escrow"); + write_escrow_id(&e, &new_escrow_id); // This should panic + }); } +// ============ Edge Case Tests ============ + #[test] -fn test_buy_transfers_usdc_and_mints_sale_token() { - let env = Env::default(); - env.mock_all_auths_allowing_non_root_auth(); +fn test_mint_zero_tokens() { + let e = Env::default(); + e.mock_all_auths(); - let admin = Address::generate(&env); - let payer = Address::generate(&env); - let beneficiary = Address::generate(&env); + let mint_authority = Address::generate(&e); + let user = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_zero"); - // 1) Create USDC - let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + // Minting zero should succeed (no-op) + token.mint(&user, &0); + assert_eq!(token.balance(&user), 0); +} - // 2) Create Escrow contract - let escrow_client = create_escrow_contract(&env); - let engagement_id = String::from_str(&env, "eng_1"); +#[test] +fn test_transfer_zero_amount() { + let e = Env::default(); + e.mock_all_auths(); - let roles = Roles { - approver: payer.clone(), - service_provider: beneficiary.clone(), - platform_address: admin.clone(), - release_signer: payer.clone(), - dispute_resolver: admin.clone(), - }; + let mint_authority = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_zero_transfer"); - let flags = Flags { - disputed: false, - released: false, - resolved: false, - approved: false, - }; + token.mint(&user1, &100); - let trustline = Trustline { - address: usdc_client.address.clone(), - }; + // Transfer zero should succeed + token.transfer(&user1, &user2, &0); + assert_eq!(token.balance(&user1), 100); + assert_eq!(token.balance(&user2), 0); +} - let amount: i128 = 100; +#[test] +#[should_panic(expected = "insufficient balance")] +fn test_burn_more_than_balance() { + let e = Env::default(); + e.mock_all_auths(); - let milestones = vec![ - &env, - Milestone { - description: String::from_str(&env, "m1"), - status: String::from_str(&env, "Pending"), - evidence: String::from_str(&env, ""), - amount, - flags: flags.clone(), - receiver: beneficiary.clone(), - }, - ]; + let mint_authority = Address::generate(&e); + let user = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_burn_excess"); - let escrow_properties = Escrow { - engagement_id, - title: String::from_str(&env, "Test Escrow"), - description: String::from_str(&env, "Test Escrow Description"), - roles, - platform_fee: 0, - milestones, - trustline, - receiver_memo: 0, - }; + token.mint(&user, &100); + token.burn(&user, &101); // should panic +} - escrow_client.initialize_escrow(&escrow_properties); +#[test] +fn test_burn_entire_balance() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let user = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_burn_all"); + + token.mint(&user, &500); + token.burn(&user, &500); + assert_eq!(token.balance(&user), 0); +} + +#[test] +fn test_transfer_to_self() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let user = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_self_transfer"); + + token.mint(&user, &100); + + // Self-transfer should work and balance remains the same + token.transfer(&user, &user, &50); + assert_eq!(token.balance(&user), 100); +} + +#[test] +fn test_multiple_mints_accumulate() { + let e = Env::default(); + e.mock_all_auths(); - // 3) Create token-factory with a temporary admin as mint_authority - let temp_admin = Address::generate(&env); - let sale_token = create_token_factory(&env, &temp_admin); + let mint_authority = Address::generate(&e); + let user = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_multi_mint"); - // 4) Create ParticipationToken passing the escrow and token-factory addresses - let participation_token_client = create_participation_token(&env, &escrow_client.address, &sale_token.address); + token.mint(&user, &100); + token.mint(&user, &200); + token.mint(&user, &300); - // 5) Transfer mint authority of token-factory to the ParticipationToken contract - sale_token.set_admin(&participation_token_client.address); + assert_eq!(token.balance(&user), 600); +} + +#[test] +#[should_panic(expected = "negative amount is not allowed")] +fn test_mint_negative_amount() { + let e = Env::default(); + e.mock_all_auths(); - // 6) Fund USDC to the payer so they can buy - usdc_admin.mint(&payer, &amount); + let mint_authority = Address::generate(&e); + let user = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_neg_mint"); - // 7) Execute buy - participation_token_client.buy(&usdc_client.address, &payer, &beneficiary, &amount); + token.mint(&user, &(-100)); +} + +#[test] +#[should_panic(expected = "negative amount is not allowed")] +fn test_transfer_negative_amount() { + let e = Env::default(); + e.mock_all_auths(); - // 8) Verify that the escrow received the USDC - let escrow_balance = usdc_client.balance(&escrow_client.address); - assert_eq!(escrow_balance, amount); + let mint_authority = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_neg_transfer"); - // 9) Verify that the beneficiary received the minted sale tokens - let sale_token_balance = sale_token.balance(&beneficiary); - assert_eq!(sale_token_balance, amount); + token.mint(&user1, &100); + token.transfer(&user1, &user2, &(-50)); } + +#[test] +fn test_set_admin_transfers_authority() { + let e = Env::default(); + e.mock_all_auths(); + + let mint_authority = Address::generate(&e); + let new_authority = Address::generate(&e); + let user = Address::generate(&e); + let token = create_token(&e, &mint_authority, "escrow_admin_transfer"); + + // Original authority mints + token.mint(&user, &100); + assert_eq!(token.balance(&user), 100); + + // Transfer authority + token.set_admin(&new_authority); + + // New authority should be able to mint + token.mint(&user, &200); + assert_eq!(token.balance(&user), 300); +} \ No newline at end of file diff --git a/apps/smart-contracts/contracts/token-factory/Cargo.toml b/apps/smart-contracts/contracts/token-factory/Cargo.toml deleted file mode 100644 index 93521ba..0000000 --- a/apps/smart-contracts/contracts/token-factory/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ - -[package] -name = "soroban-token-contract" -description = "Soroban standard token contract" -version = "0.0.6" -edition = "2021" -rust-version = "1.86.0" - -[lib] -crate-type = ["cdylib", "rlib"] -doctest = false - -[features] -testutils = ["soroban-sdk/testutils"] - -[dependencies] -soroban-sdk = { version = "23.1.1" } -soroban-token-sdk = { version = "23.1.1" } - -[dev-dependencies] -soroban-sdk = { version = "23.1.1", features = ["testutils"] } - -[profile.release] -opt-level = "z" -overflow-checks = true -debug = 0 -strip = "symbols" -debug-assertions = false -panic = "abort" -codegen-units = 1 -lto = true - -[profile.release-with-logs] -inherits = "release" -debug-assertions = true diff --git a/apps/smart-contracts/contracts/token-factory/src/lib.rs b/apps/smart-contracts/contracts/token-factory/src/lib.rs deleted file mode 100644 index 544cb58..0000000 --- a/apps/smart-contracts/contracts/token-factory/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -#![no_std] - -mod allowance; -mod balance; -mod contract; -mod metadata; -mod storage_types; -mod test; - -pub use crate::contract::{Token, TokenClient}; \ No newline at end of file diff --git a/apps/smart-contracts/contracts/token-factory/src/test.rs b/apps/smart-contracts/contracts/token-factory/src/test.rs deleted file mode 100644 index 8f1133f..0000000 --- a/apps/smart-contracts/contracts/token-factory/src/test.rs +++ /dev/null @@ -1,350 +0,0 @@ -#![cfg(test)] -extern crate std; - -use crate::{contract::Token, TokenClient}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, - Address, Env, FromVal, IntoVal, String, Symbol, Vec, -}; - -fn create_token<'a>(e: &Env, mint_authority: &Address, escrow_id: &str) -> TokenClient<'a> { - let token_contract = e.register( - Token, - ( - String::from_val(e, &"TestToken"), - String::from_val(e, &"TST"), - String::from_val(e, &escrow_id), - 7_u32, - mint_authority, - ), - ); - TokenClient::new(e, &token_contract) -} - -#[test] -fn test() { - let e = Env::default(); - e.mock_all_auths(); - - let mint_authority = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let user3 = Address::generate(&e); - let escrow_id = "test_escrow_123"; - let token = create_token(&e, &mint_authority, escrow_id); - - token.mint(&user1, &1000); - assert_eq!( - e.auths(), - std::vec![( - mint_authority.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - symbol_short!("mint"), - (&user1, 1000_i128).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - assert_eq!(token.balance(&user1), 1000); - - token.approve(&user2, &user3, &500, &200); - assert_eq!( - e.auths(), - std::vec![( - user2.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - symbol_short!("approve"), - (&user2, &user3, 500_i128, 200_u32).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - assert_eq!(token.allowance(&user2, &user3), 500); - - token.transfer(&user1, &user2, &600); - assert_eq!( - e.auths(), - std::vec![( - user1.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - symbol_short!("transfer"), - (&user1, &user2, 600_i128).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - assert_eq!(token.balance(&user1), 400); - assert_eq!(token.balance(&user2), 600); - - token.transfer_from(&user3, &user2, &user1, &400); - assert_eq!( - e.auths(), - std::vec![( - user3.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - Symbol::new(&e, "transfer_from"), - (&user3, &user2, &user1, 400_i128).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - assert_eq!(token.balance(&user1), 800); - assert_eq!(token.balance(&user2), 200); - - token.transfer(&user1, &user3, &300); - assert_eq!(token.balance(&user1), 500); - assert_eq!(token.balance(&user3), 300); - - // Increase to 500 - token.approve(&user2, &user3, &500, &200); - assert_eq!(token.allowance(&user2, &user3), 500); - token.approve(&user2, &user3, &0, &200); - assert_eq!( - e.auths(), - std::vec![( - user2.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - symbol_short!("approve"), - (&user2, &user3, 0_i128, 200_u32).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - assert_eq!(token.allowance(&user2, &user3), 0); -} - -#[test] -fn test_burn() { - let e = Env::default(); - e.mock_all_auths(); - - let mint_authority = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let escrow_id = "test_escrow_456"; - let token = create_token(&e, &mint_authority, escrow_id); - - token.mint(&user1, &1000); - assert_eq!(token.balance(&user1), 1000); - - token.approve(&user1, &user2, &500, &200); - assert_eq!(token.allowance(&user1, &user2), 500); - - token.burn_from(&user2, &user1, &500); - assert_eq!( - e.auths(), - std::vec![( - user2.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - symbol_short!("burn_from"), - (&user2, &user1, 500_i128).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - - assert_eq!(token.allowance(&user1, &user2), 0); - assert_eq!(token.balance(&user1), 500); - assert_eq!(token.balance(&user2), 0); - - token.burn(&user1, &500); - assert_eq!( - e.auths(), - std::vec![( - user1.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - symbol_short!("burn"), - (&user1, 500_i128).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - - assert_eq!(token.balance(&user1), 0); - assert_eq!(token.balance(&user2), 0); -} - -#[test] -#[should_panic(expected = "insufficient balance")] -fn transfer_insufficient_balance() { - let e = Env::default(); - e.mock_all_auths(); - - let mint_authority = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let escrow_id = "test_escrow_789"; - let token = create_token(&e, &mint_authority, escrow_id); - - token.mint(&user1, &1000); - assert_eq!(token.balance(&user1), 1000); - - token.transfer(&user1, &user2, &1001); -} - -#[test] -#[should_panic(expected = "insufficient allowance")] -fn transfer_from_insufficient_allowance() { - let e = Env::default(); - e.mock_all_auths(); - - let mint_authority = Address::generate(&e); - let user1 = Address::generate(&e); - let user2 = Address::generate(&e); - let user3 = Address::generate(&e); - let escrow_id = "test_escrow_101112"; - let token = create_token(&e, &mint_authority, escrow_id); - - token.mint(&user1, &1000); - assert_eq!(token.balance(&user1), 1000); - - token.approve(&user1, &user3, &100, &200); - assert_eq!(token.allowance(&user1, &user3), 100); - - token.transfer_from(&user3, &user1, &user2, &101); -} - -#[test] -#[should_panic(expected = "Decimal must not be greater than 18")] -fn decimal_is_over_eighteen() { - let e = Env::default(); - let mint_authority = Address::generate(&e); - let _ = TokenClient::new( - &e, - &e.register( - Token, - ( - String::from_val(&e, &"name"), - String::from_val(&e, &"symbol"), - String::from_val(&e, &"escrow_123"), - 19_u32, - mint_authority, - ), - ), - ); -} - -// New tests for T-REX alignment - -#[test] -fn test_metadata_getters() { - let e = Env::default(); - let mint_authority = Address::generate(&e); - let escrow_id = "test_escrow_metadata"; - let token = create_token(&e, &mint_authority, escrow_id); - - // Test standard metadata getters - assert_eq!(token.name(), String::from_val(&e, &"TestToken")); - assert_eq!(token.symbol(), String::from_val(&e, &"TST")); - assert_eq!(token.decimals(), 7); - - // Test escrow_id getter - let token_contract = token.address.clone(); - let escrow_id_result: String = e - .invoke_contract(&token_contract, &symbol_short!("escrow_id"), Vec::new(&e)); - assert_eq!(escrow_id_result, String::from_val(&e, &escrow_id)); -} - -#[test] -fn test_mint_authority_can_mint() { - let e = Env::default(); - e.mock_all_auths(); - - let mint_authority = Address::generate(&e); - let user = Address::generate(&e); - let escrow_id = "test_escrow_mint"; - let token = create_token(&e, &mint_authority, escrow_id); - - // Mint authority should be able to mint - // With mock_all_auths(), the mint_authority's auth is automatically provided - token.mint(&user, &1000); - assert_eq!(token.balance(&user), 1000); - - // Verify balance increased correctly - token.mint(&user, &500); - assert_eq!(token.balance(&user), 1500); -} - -#[test] -#[should_panic] -fn test_deployer_cannot_mint() { - let e = Env::default(); - // Don't mock all auths - we want to test that only mint_authority can mint - let token_contract = e.register( - Token, - ( - String::from_val(&e, &"TestToken"), - String::from_val(&e, &"TST"), - String::from_val(&e, &"test_escrow_deployer"), - 7_u32, - Address::generate(&e), // mint_authority - ), - ); - - e.as_contract(&token_contract, || { - let user = Address::generate(&e); - - // Try to mint as deployer (should fail because deployer != mint_authority) - // Without mint_authority's auth, this should panic - // This will fail because we're not providing mint_authority's auth - let mut args = Vec::new(&e); - args.push_back(user.to_val()); - args.push_back(1000_i128.into_val(&e)); - let _: () = e - .invoke_contract( - &token_contract, - &symbol_short!("mint"), - args, - ); - }); -} - -#[test] -#[should_panic(expected = "Escrow ID already set")] -fn test_metadata_immutability() { - let e = Env::default(); - let mint_authority = Address::generate(&e); - let escrow_id = "test_escrow_immutable"; - - // Create token (initializes metadata via __constructor) - let token_contract = e.register( - Token, - ( - String::from_val(&e, &"TestToken"), - String::from_val(&e, &"TST"), - String::from_val(&e, &escrow_id), - 7_u32, - mint_authority.clone(), - ), - ); - - // Try to write escrow_id again (should panic - immutability enforced) - // We need to wrap in as_contract to access storage - e.as_contract(&token_contract, || { - use crate::metadata::write_escrow_id; - let new_escrow_id = String::from_val(&e, &"new_escrow"); - write_escrow_id(&e, &new_escrow_id); // This should panic - }); -} \ No newline at end of file diff --git a/apps/smart-contracts/contracts/token-sale/Cargo.toml b/apps/smart-contracts/contracts/token-sale/Cargo.toml new file mode 100644 index 0000000..97368ad --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "token-sale" +version = "0.1.0" +edition = "2021" + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +escrow = { path = "../escrow", features = ["testutils"] } +participation-token = { path = "../participation-token", features = ["testutils"] } + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false diff --git a/apps/smart-contracts/contracts/token-sale/security-audit-report-v1.0.0.md b/apps/smart-contracts/contracts/token-sale/security-audit-report-v1.0.0.md new file mode 100644 index 0000000..4fc25a6 --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/security-audit-report-v1.0.0.md @@ -0,0 +1,251 @@ +--- +title: Security Audit Report — participation-token Contract +author: Rodion Romanovich +status: Active +created: 2026-03-11 +updated: 2026-03-11 +version: 1.0.0 +--- + +# Security Audit Report: `participation-token` Contract + +**Contract:** `ParticipationTokenContract` +**Path:** `contracts/participation-token/src/` +**SDK:** soroban-sdk 23.1.1 +**Auditor:** Rodion Romanovich +**Issue:** #5 + +--- + +## Findings Summary + +| # | Severity | Title | Status | +|---|----------|-------|--------| +| F-01 | Critical | USDC address accepted as runtime parameter in `buy()` | **FIXED** | +| F-02 | High | Storage keys used string literals instead of typed enum | **FIXED** | +| F-03 | High | No TTL extension — storage could expire and brick the contract | **FIXED** | +| F-04 | High | `unwrap()`/`expect()` in storage reads could cause unrecoverable panics | **FIXED** | +| F-05 | Medium | No input validation on `amount` parameter | **FIXED** | +| F-06 | Medium | No typed error enum — all errors were raw panics | **FIXED** | +| F-07 | Low | Events used deprecated `env.events().publish()` API | **FIXED** | +| F-08 | Informational | `mint` invocation uses dynamic `invoke_contract` instead of typed client | **ACKNOWLEDGED** | +| F-09 | Informational | Constructor cannot be re-invoked (secure by design) | **N/A** | + +--- + +## Detailed Findings + +### F-01: USDC address accepted as runtime parameter (Critical) + +**Description:** The original `buy()` function accepted `usdc: Address` as a parameter, allowing any caller to pass an arbitrary token contract address. An attacker could deploy a malicious contract that mimics the USDC `transfer` interface but does nothing (or transfers worthless tokens), then call `buy()` with that address to receive participation tokens without paying real USDC. + +**Impact:** Complete loss of funds. Attacker receives participation tokens for free, diluting real investors' positions. + +**Proof of Concept:** Call `buy(payer, beneficiary, amount, malicious_contract_address)` where `malicious_contract_address.transfer()` is a no-op. + +**Remediation:** Moved USDC address to the constructor. It is now stored in instance storage at deployment time and read internally by `buy()`. The `buy()` signature no longer accepts a USDC address parameter. + +**Related Issues:** None found. + +--- + +### F-02: String-literal storage keys (High) + +**Description:** The original contract used raw string literals (e.g., `"escrow_contract"`, `"sale_token"`) as storage keys via `Symbol::new()`. A typo in any key would silently write/read from different storage slots, potentially causing the contract to operate with incorrect data. + +**Impact:** Silent data corruption. A single typo could cause the contract to read uninitialized storage, leading to panics or incorrect behavior. + +**Remediation:** Replaced all string-based keys with a typed `DataKey` enum using `#[contracttype]`: +```rust +#[contracttype] +pub enum DataKey { + EscrowContract, + ParticipationToken, + UsdcAddress, +} +``` +Compile-time type safety prevents typos entirely. + +**Related Issues:** None found. + +--- + +### F-03: No TTL extension (High) + +**Description:** The contract never called `extend_ttl()` on its instance storage. On Stellar, all storage entries have a TTL (Time To Live) and are archived after expiration. If the contract's storage expired, all configuration (escrow address, token address, USDC address) would become inaccessible, effectively bricking the contract. + +**Impact:** Permanent contract failure after TTL expiration. All stored configuration lost. + +**Remediation:** Added TTL extension at the beginning of `buy()`: +```rust +env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); +``` +Constants: `INSTANCE_BUMP_AMOUNT = 7 * 17280 ledgers` (~7 days), `INSTANCE_LIFETIME_THRESHOLD = 6 * 17280 ledgers` (~6 days). + +**Related Issues:** None found. + +--- + +### F-04: Unguarded `unwrap()` in storage reads (High) + +**Description:** The original `read_config()` function used `.unwrap()` when reading from instance storage. If any key was missing (e.g., due to TTL expiration or an uninitialized contract), the contract would panic with an unhelpful error message, providing no diagnostic information to the caller. + +**Impact:** Unrecoverable runtime panic with no meaningful error code. Difficult to debug in production. + +**Remediation:** Replaced all `.unwrap()` calls with `.ok_or(ContractError::NotInitialized)?`, propagating a typed error to the caller: +```rust +let escrow_contract: Address = e + .storage() + .instance() + .get(&DataKey::EscrowContract) + .ok_or(ContractError::NotInitialized)?; +``` + +**Related Issues:** None found. + +--- + +### F-05: No input validation on `amount` (Medium) + +**Description:** The original `buy()` function accepted any `i128` value for `amount`, including zero and negative numbers. Passing `amount = 0` would execute a no-op USDC transfer and mint zero participation tokens, wasting gas. Passing a negative amount would cause undefined behavior in the token contracts. + +**Impact:** Gas waste (amount = 0) or potential undefined behavior (negative amounts). + +**Remediation:** Added explicit validation: +```rust +if amount <= 0 { + return Err(ContractError::AmountMustBePositive); +} +``` + +**Tests:** +- `test_buy_rejects_zero_amount` — verifies `amount = 0` returns `ContractError::AmountMustBePositive` +- `test_buy_rejects_negative_amount` — verifies `amount = -50` returns `ContractError::AmountMustBePositive` + +**Related Issues:** None found. + +--- + +### F-06: No typed error enum (Medium) + +**Description:** The original contract had no `ContractError` type. All error conditions resulted in raw panics or unhelpful `unwrap()` failures, making it impossible for callers (other contracts, SDKs, frontends) to programmatically distinguish between different failure modes. + +**Impact:** Poor developer experience. No programmatic error handling possible for callers. + +**Remediation:** Created `error.rs` with a typed error enum: +```rust +#[contracterror] +pub enum ContractError { + NotInitialized = 1, + AmountMustBePositive = 2, +} +``` +`buy()` now returns `Result<(), ContractError>`. + +**Related Issues:** None found. + +--- + +### F-07: Events used deprecated API (Low) + +**Description:** Events were emitted using `env.events().publish((symbol_short!("buy"),), event)`, which is deprecated in soroban-sdk v23. The recommended approach is the `#[contractevent]` macro with `.publish(&env)`. + +**Impact:** Future SDK versions may remove the deprecated API, causing compilation failures. Inconsistent with the rest of the codebase (escrow contract uses `#[contractevent]`). + +**Remediation:** Migrated to `#[contractevent]`: +```rust +#[contractevent(topics = ["pt_buy"], data_format = "vec")] +#[derive(Clone, Debug)] +pub struct BuyEvent { + pub payer: Address, + pub beneficiary: Address, + pub amount: i128, +} +``` +Published via: `BuyEvent { payer, beneficiary, amount }.publish(&env);` + +**Related Issues:** None found. + +--- + +### F-08: Dynamic invocation for `mint` (Informational) + +**Description:** The `mint_participation_tokens()` helper uses `env.invoke_contract()` with a dynamically constructed `Symbol` to call the token-factory's `mint()` function. This is less type-safe than using a generated client. + +**Impact:** Minimal. The function name is a compile-time constant (`"mint"`), and the token-factory contract validates the call. Using `invoke_contract` is necessary because `mint()` is not part of the standard `TokenInterface`, so `token::Client` cannot be used. + +**Remediation:** Documented with a clear comment explaining why `invoke_contract` is required. No code change needed. + +**Related Issues:** None found. + +--- + +### F-09: Constructor re-invocation (Informational) + +**Description:** Verified that `__constructor` uses the Soroban constructor pattern, which is automatically invoked only at contract deployment. It cannot be called again by any external actor. + +**Impact:** None. Secure by design. + +**Related Issues:** None found. + +--- + +## Automated Analysis + +### CoinFabrik Scout Audit (cargo-scout-audit v0.3.16) + +``` ++---------------------+----------+----------+--------+-------+-------------+ +| Crate | Status | Critical | Medium | Minor | Enhancement | ++---------------------+----------+----------+--------+-------+-------------+ +| participation_token | Analyzed | 0 | 0 | 0 | 1 | ++---------------------+----------+----------+--------+-------+-------------+ +``` + +The only finding is an enhancement suggesting upgrading from soroban-sdk `23.1.1` to `25.3.0`. This applies to the entire workspace and is outside the scope of this audit. + +--- + +## Test Coverage + +### Original Tests (updated for new API) +- `test_buy_transfers_usdc_and_mints_sale_token` — happy path +- `test_buy_rejects_zero_amount` — input validation (amount = 0) +- `test_buy_rejects_negative_amount` — input validation (amount < 0) +- `test_buy_payer_different_from_beneficiary` — payer != beneficiary + +### New Edge Case Tests +- `test_buy_payer_is_beneficiary` — self-buy (payer == beneficiary) +- `test_multiple_sequential_buys_accumulate` — 3 sequential buys accumulate correctly +- `test_multiple_payers_same_beneficiary` — different payers, same beneficiary +- `test_buy_fails_insufficient_usdc` — USDC transfer fails when payer has insufficient balance +- `test_buy_minimum_amount` — boundary: amount = 1 + +### Test Results +``` +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +--- + +## Files Modified + +| File | Change | +|------|--------| +| `src/sale.rs` | Rewrote: typed storage, USDC in constructor, input validation, TTL, error propagation, new event API | +| `src/error.rs` | **New:** Typed `ContractError` enum with `#[contracterror]` | +| `src/storage_types.rs` | **New:** Typed `DataKey` enum + TTL constants | +| `src/events.rs` | **New:** `BuyEvent` with `#[contractevent]` macro | +| `src/lib.rs` | Updated module declarations and public exports | +| `src/test.rs` | Rewritten for new API + 5 new edge case tests | + +--- + +## Additional Improvements (outside scope, applied to related contracts) + +During the audit, the following improvements were also applied to `vault-contract` and `token-factory` for consistency: + +- **vault-contract:** All arithmetic uses `checked_*` operations. `unwrap()`/`expect()` replaced with `Result`. Added `ArithmeticOverflow` and `NotInitialized` error variants. 10 new edge case tests. +- **token-factory:** `receive_balance` and `spend_balance` use `checked_add`/`checked_sub`. 9 new edge case tests. diff --git a/apps/smart-contracts/contracts/token-sale/src/contract.rs b/apps/smart-contracts/contracts/token-sale/src/contract.rs new file mode 100644 index 0000000..4a740d8 --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/src/contract.rs @@ -0,0 +1,185 @@ +use soroban_sdk::{Address, Env, IntoVal, Symbol, contract, contractimpl, token, vec}; +use token::Client as TokenClient; + +use crate::error::ContractError; +use crate::events::{emit_buy, emit_caps_updated, BuyEvent, CapsUpdatedEvent}; +use crate::storage_types::DataKey; + +#[contract] +pub struct TokenSaleContract; + +fn read_escrow(e: &Env) -> Result { + e.storage() + .instance() + .get(&DataKey::EscrowContract) + .ok_or(ContractError::EscrowContractNotFound) +} + +fn read_participation_token(e: &Env) -> Result { + e.storage() + .instance() + .get(&DataKey::ParticipationToken) + .ok_or(ContractError::ParticipationTokenNotFound) +} + +fn read_admin(e: &Env) -> Result { + e.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ContractError::AdminNotFound) +} + +fn write_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +#[contractimpl] +impl TokenSaleContract { + pub fn __constructor( + env: Env, + escrow_contract: Address, + admin: Address, + hard_cap: i128, + max_per_investor: i128, + ) { + env.storage() + .instance() + .set(&DataKey::EscrowContract, &escrow_contract); + write_admin(&env, &admin); + env.storage().instance().set(&DataKey::HardCap, &hard_cap); + env.storage() + .instance() + .set(&DataKey::MaxPerInvestor, &max_per_investor); + env.storage() + .instance() + .set(&DataKey::TotalMinted, &0_i128); + } + + pub fn buy( + env: Env, + usdc: Address, + payer: Address, + beneficiary: Address, + amount: i128, + ) -> Result<(), ContractError> { + if amount <= 0 { + return Err(ContractError::AmountMustBePositive); + } + payer.require_auth(); + + let escrow_contract = read_escrow(&env)?; + let participation_token = read_participation_token(&env)?; + + // Read caps and current state + let hard_cap: i128 = env + .storage() + .instance() + .get(&DataKey::HardCap) + .unwrap_or(0); + let max_per_investor: i128 = env + .storage() + .instance() + .get(&DataKey::MaxPerInvestor) + .unwrap_or(0); + let total_minted: i128 = env + .storage() + .instance() + .get(&DataKey::TotalMinted) + .unwrap_or(0); + let investor_balance: i128 = env + .storage() + .persistent() + .get(&DataKey::InvestorBalance(beneficiary.clone())) + .unwrap_or(0); + + // Validate hard cap + if hard_cap > 0 && total_minted + amount > hard_cap { + return Err(ContractError::HardCapExceeded); + } + + // Validate per-investor cap (0 means no limit) + if max_per_investor > 0 && investor_balance + amount > max_per_investor { + return Err(ContractError::InvestorCapExceeded); + } + + // Transfer USDC to escrow + let usdc_client = TokenClient::new(&env, &usdc); + usdc_client.transfer(&payer, &escrow_contract, &amount); + + // Mint participation tokens + let mint_sym = Symbol::new(&env, "mint"); + let args_vec = vec![&env, beneficiary.into_val(&env), amount.into_val(&env)]; + let _: () = env.invoke_contract(&participation_token, &mint_sym, args_vec); + + // Update counters after successful mint + env.storage() + .instance() + .set(&DataKey::TotalMinted, &(total_minted + amount)); + env.storage() + .persistent() + .set(&DataKey::InvestorBalance(beneficiary.clone()), &(investor_balance + amount)); + + emit_buy( + &env, + BuyEvent { + payer, + beneficiary, + amount, + usdc, + }, + ); + + Ok(()) + } + + pub fn get_admin(env: Env) -> Result { + read_admin(&env) + } + + pub fn update_caps( + env: Env, + new_hard_cap: i128, + new_max_per_investor: i128, + ) -> Result<(), ContractError> { + let admin = read_admin(&env)?; + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::HardCap, &new_hard_cap); + env.storage() + .instance() + .set(&DataKey::MaxPerInvestor, &new_max_per_investor); + + emit_caps_updated( + &env, + CapsUpdatedEvent { + admin, + new_hard_cap, + new_max_per_investor, + }, + ); + + Ok(()) + } + + pub fn set_token(env: Env, new_token: Address) -> Result<(), ContractError> { + let admin = read_admin(&env)?; + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::ParticipationToken, &new_token); + + Ok(()) + } + + pub fn set_admin(env: Env, new_admin: Address) -> Result<(), ContractError> { + let admin = read_admin(&env)?; + admin.require_auth(); + + write_admin(&env, &new_admin); + + Ok(()) + } +} diff --git a/apps/smart-contracts/contracts/token-sale/src/error.rs b/apps/smart-contracts/contracts/token-sale/src/error.rs new file mode 100644 index 0000000..67eabb5 --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/src/error.rs @@ -0,0 +1,14 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + EscrowContractNotFound = 1, + ParticipationTokenNotFound = 2, + AdminNotFound = 3, + OnlyAdminCanSetToken = 4, + HardCapExceeded = 5, + InvestorCapExceeded = 6, + AmountMustBePositive = 7, +} diff --git a/apps/smart-contracts/contracts/token-sale/src/events.rs b/apps/smart-contracts/contracts/token-sale/src/events.rs new file mode 100644 index 0000000..dd32412 --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/src/events.rs @@ -0,0 +1,26 @@ +use soroban_sdk::{contractevent, Address, Env}; + +#[contractevent(topics = ["pt_buy"], data_format = "vec")] +#[derive(Clone, Debug)] +pub struct BuyEvent { + pub payer: Address, + pub beneficiary: Address, + pub amount: i128, + pub usdc: Address, +} + +pub fn emit_buy(env: &Env, event: BuyEvent) { + event.publish(env); +} + +#[contractevent(topics = ["caps_updated"], data_format = "vec")] +#[derive(Clone, Debug)] +pub struct CapsUpdatedEvent { + pub admin: Address, + pub new_hard_cap: i128, + pub new_max_per_investor: i128, +} + +pub fn emit_caps_updated(env: &Env, event: CapsUpdatedEvent) { + event.publish(env); +} diff --git a/apps/smart-contracts/contracts/token-sale/src/lib.rs b/apps/smart-contracts/contracts/token-sale/src/lib.rs new file mode 100644 index 0000000..197e9e1 --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/src/lib.rs @@ -0,0 +1,14 @@ +#![no_std] + +mod contract; +mod error; +mod events; +mod storage_types; + +pub use crate::contract::TokenSaleContract; +pub use crate::error::ContractError; +pub use crate::events::BuyEvent; +pub use crate::storage_types::DataKey; + +#[cfg(test)] +mod test; diff --git a/apps/smart-contracts/contracts/token-sale/src/storage_types.rs b/apps/smart-contracts/contracts/token-sale/src/storage_types.rs new file mode 100644 index 0000000..efddc0c --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/src/storage_types.rs @@ -0,0 +1,12 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +pub enum DataKey { + EscrowContract, + ParticipationToken, + Admin, + HardCap, + MaxPerInvestor, + TotalMinted, + InvestorBalance(Address), +} diff --git a/apps/smart-contracts/contracts/token-sale/src/test.rs b/apps/smart-contracts/contracts/token-sale/src/test.rs new file mode 100644 index 0000000..f53ba70 --- /dev/null +++ b/apps/smart-contracts/contracts/token-sale/src/test.rs @@ -0,0 +1,374 @@ +#![cfg(test)] +extern crate std; + +use crate::contract::{TokenSaleContract, TokenSaleContractClient}; +use crate::error::ContractError; +use escrow::{Escrow, EscrowContract, EscrowContractClient, Flags, Milestone, Roles, Trustline}; +use soroban_sdk::{testutils::Address as _, token, vec, Address, Env, String}; +use token::Client as TokenClient; +use token::StellarAssetClient as TokenAdminClient; +use participation_token::{Token as FactoryToken, TokenClient as FactoryTokenClient}; + +fn create_usdc_token<'a>(e: &Env, admin: &Address) -> (TokenClient<'a>, TokenAdminClient<'a>) { + let sac = e.register_stellar_asset_contract_v2(admin.clone()); + ( + TokenClient::new(e, &sac.address()), + TokenAdminClient::new(e, &sac.address()), + ) +} + +fn create_escrow_contract<'a>(env: &Env) -> EscrowContractClient<'a> { + EscrowContractClient::new(env, &env.register(EscrowContract {}, ())) +} + +fn create_token_factory<'a>(e: &Env, mint_authority: &Address) -> FactoryTokenClient<'a> { + let token_contract = e.register( + FactoryToken, + ( + String::from_str(e, "SaleToken"), + String::from_str(e, "SALE"), + String::from_str(e, "eng_1"), + 7_u32, + mint_authority, + ), + ); + FactoryTokenClient::new(e, &token_contract) +} + +fn create_token_sale<'a>( + e: &Env, + escrow_addr: &Address, + admin: &Address, + hard_cap: i128, + max_per_investor: i128, +) -> TokenSaleContractClient<'a> { + let contract_id = e.register( + TokenSaleContract, + ( + escrow_addr.clone(), + admin.clone(), + hard_cap, + max_per_investor, + ), + ); + TokenSaleContractClient::new(e, &contract_id) +} + +struct TestSetup<'a> { + env: Env, + #[allow(dead_code)] + admin: Address, + payer: Address, + beneficiary: Address, + usdc_client: TokenClient<'a>, + usdc_admin: TokenAdminClient<'a>, + sale_token: FactoryTokenClient<'a>, + token_sale_client: TokenSaleContractClient<'a>, +} + +fn setup_test(hard_cap: i128, max_per_investor: i128) -> TestSetup<'static> { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let admin = Address::generate(&env); + let payer = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + + let escrow_client = create_escrow_contract(&env); + let engagement_id = String::from_str(&env, "eng_1"); + + let roles = Roles { + approver: payer.clone(), + service_provider: beneficiary.clone(), + platform_address: admin.clone(), + release_signer: payer.clone(), + dispute_resolver: admin.clone(), + }; + + let flags = Flags { + disputed: false, + released: false, + resolved: false, + approved: false, + }; + + let trustline = Trustline { + address: usdc_client.address.clone(), + }; + + let milestones = vec![ + &env, + Milestone { + description: String::from_str(&env, "m1"), + status: String::from_str(&env, "Pending"), + evidence: String::from_str(&env, ""), + amount: hard_cap, + flags: flags.clone(), + receiver: beneficiary.clone(), + }, + ]; + + let escrow_properties = Escrow { + engagement_id, + title: String::from_str(&env, "Test Escrow"), + description: String::from_str(&env, "Test Escrow Description"), + roles, + platform_fee: 0, + milestones, + trustline, + receiver_memo: 0, + }; + + escrow_client.initialize_escrow(&escrow_properties); + + let temp_admin = Address::generate(&env); + let sale_token = create_token_factory(&env, &temp_admin); + + let token_sale_client = create_token_sale( + &env, + &escrow_client.address, + &admin, + hard_cap, + max_per_investor, + ); + + // Wire up: set participation token via set_token, then transfer mint authority + token_sale_client.set_token(&sale_token.address); + sale_token.set_admin(&token_sale_client.address); + + TestSetup { + env, + admin, + payer, + beneficiary, + usdc_client, + usdc_admin, + sale_token, + token_sale_client, + } +} + +#[test] +fn test_buy_transfers_usdc_and_mints_sale_token() { + let amount: i128 = 100; + let t = setup_test(1_000, 0); // hard_cap=1000, no per-investor limit + + t.usdc_admin.mint(&t.payer, &amount); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &amount); + + // Verify beneficiary got sale tokens + let sale_token_balance = t.sale_token.balance(&t.beneficiary); + assert_eq!(sale_token_balance, amount); +} + +// ─── Security audit: AmountMustBePositive ──────────────────────────────────── + +#[test] +fn test_buy_rejects_zero_amount() { + let t = setup_test(1_000, 0); + t.usdc_admin.mint(&t.payer, &100); + + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &t.payer, + &t.beneficiary, + &0, + ); + assert_eq!(result, Err(Ok(ContractError::AmountMustBePositive))); +} + +#[test] +fn test_buy_rejects_negative_amount() { + let t = setup_test(1_000, 0); + t.usdc_admin.mint(&t.payer, &100); + + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &t.payer, + &t.beneficiary, + &(-50), + ); + assert_eq!(result, Err(Ok(ContractError::AmountMustBePositive))); +} + +// ─── Hard cap tests ───────────────────────────────────────────────────────── + +#[test] +fn test_buy_exact_hard_cap() { + let hard_cap: i128 = 500; + let t = setup_test(hard_cap, 0); + + t.usdc_admin.mint(&t.payer, &hard_cap); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &hard_cap); + + let sale_token_balance = t.sale_token.balance(&t.beneficiary); + assert_eq!(sale_token_balance, hard_cap); +} + +#[test] +fn test_buy_exceeds_hard_cap() { + let hard_cap: i128 = 500; + let t = setup_test(hard_cap, 0); + + let over_amount = hard_cap + 1; + t.usdc_admin.mint(&t.payer, &over_amount); + + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &t.payer, + &t.beneficiary, + &over_amount, + ); + + assert_eq!(result, Err(Ok(ContractError::HardCapExceeded))); +} + +#[test] +fn test_buy_exceeds_hard_cap_across_buyers() { + let hard_cap: i128 = 500; + let t = setup_test(hard_cap, 0); + + let buyer2 = Address::generate(&t.env); + + // First buyer takes 400 + t.usdc_admin.mint(&t.payer, &400); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &400); + + // Second buyer tries to take 200 (total would be 600 > 500) + t.usdc_admin.mint(&buyer2, &200); + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &buyer2, + &buyer2, + &200, + ); + + assert_eq!(result, Err(Ok(ContractError::HardCapExceeded))); + + // But 100 should still work (total = 500 = hard_cap) + t.usdc_admin.mint(&buyer2, &100); + t.token_sale_client.buy(&t.usdc_client.address, &buyer2, &buyer2, &100); + + assert_eq!(t.sale_token.balance(&t.beneficiary), 400); + assert_eq!(t.sale_token.balance(&buyer2), 100); +} + +// ─── Per-investor cap tests ───────────────────────────────────────────────── + +#[test] +fn test_buy_exceeds_per_investor_cap() { + let hard_cap: i128 = 1_000; + let max_per_investor: i128 = 200; + let t = setup_test(hard_cap, max_per_investor); + + let over_amount = max_per_investor + 1; + t.usdc_admin.mint(&t.payer, &over_amount); + + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &t.payer, + &t.beneficiary, + &over_amount, + ); + + assert_eq!(result, Err(Ok(ContractError::InvestorCapExceeded))); +} + +#[test] +fn test_buy_exact_per_investor_cap() { + let hard_cap: i128 = 1_000; + let max_per_investor: i128 = 200; + let t = setup_test(hard_cap, max_per_investor); + + // First buy: 150 + t.usdc_admin.mint(&t.payer, &150); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &150); + + // Second buy: 50 (total = 200 = max_per_investor) — should work + t.usdc_admin.mint(&t.payer, &50); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &50); + + assert_eq!(t.sale_token.balance(&t.beneficiary), 200); + + // Third buy: 1 more — should fail + t.usdc_admin.mint(&t.payer, &1); + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &t.payer, + &t.beneficiary, + &1, + ); + + assert_eq!(result, Err(Ok(ContractError::InvestorCapExceeded))); +} + +#[test] +fn test_buy_no_per_investor_cap() { + let hard_cap: i128 = 1_000; + let t = setup_test(hard_cap, 0); // max_per_investor = 0 means no limit + + // One investor can buy the entire hard_cap + t.usdc_admin.mint(&t.payer, &hard_cap); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &hard_cap); + + assert_eq!(t.sale_token.balance(&t.beneficiary), hard_cap); +} + +// ─── get_admin tests ───────────────────────────────────────────────────────── + +#[test] +fn test_get_admin_returns_stored_admin() { + let t = setup_test(1_000, 0); + let admin = t.token_sale_client.get_admin(); + assert_eq!(admin, t.admin); +} + +// ─── update_caps tests ─────────────────────────────────────────────────────── + +#[test] +fn test_update_caps_by_admin_changes_hard_cap() { + let t = setup_test(1_000, 0); + + // Reduce hard cap to 200 + t.token_sale_client.update_caps(&200_i128, &0_i128); + + // Buying 201 should now fail + t.usdc_admin.mint(&t.payer, &201); + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &t.payer, + &t.beneficiary, + &201, + ); + assert_eq!(result, Err(Ok(ContractError::HardCapExceeded))); + + // Buying exactly 200 should succeed + t.usdc_admin.mint(&t.payer, &200); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &200); + assert_eq!(t.sale_token.balance(&t.beneficiary), 200); +} + +#[test] +fn test_update_caps_by_admin_changes_max_per_investor() { + let t = setup_test(1_000, 0); // initially no per-investor limit + + // Set per-investor cap to 100 + t.token_sale_client.update_caps(&1_000_i128, &100_i128); + + // Buying 101 should now fail + t.usdc_admin.mint(&t.payer, &101); + let result = t.token_sale_client.try_buy( + &t.usdc_client.address, + &t.payer, + &t.beneficiary, + &101, + ); + assert_eq!(result, Err(Ok(ContractError::InvestorCapExceeded))); + + // Buying exactly 100 should succeed + t.usdc_admin.mint(&t.payer, &100); + t.token_sale_client.buy(&t.usdc_client.address, &t.payer, &t.beneficiary, &100); + assert_eq!(t.sale_token.balance(&t.beneficiary), 100); +} diff --git a/apps/smart-contracts/contracts/vault-contract/Cargo.toml b/apps/smart-contracts/contracts/vault-contract/Cargo.toml index 32ee055..90da6db 100644 --- a/apps/smart-contracts/contracts/vault-contract/Cargo.toml +++ b/apps/smart-contracts/contracts/vault-contract/Cargo.toml @@ -8,7 +8,7 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } -soroban-token-contract = { path = "../token-factory" } +participation-token = { path = "../participation-token" } [lib] crate-type = ["cdylib", "rlib"] diff --git a/apps/smart-contracts/contracts/vault-contract/src/vault.rs b/apps/smart-contracts/contracts/vault-contract/src/contract.rs similarity index 50% rename from apps/smart-contracts/contracts/vault-contract/src/vault.rs rename to apps/smart-contracts/contracts/vault-contract/src/contract.rs index e44d2f7..6fe5da1 100644 --- a/apps/smart-contracts/contracts/vault-contract/src/vault.rs +++ b/apps/smart-contracts/contracts/vault-contract/src/contract.rs @@ -1,62 +1,71 @@ -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; +use soroban_sdk::{contract, contractimpl, panic_with_error, token, Address, Env}; use token::Client as TokenClient; use crate::error::ContractError; -use crate::events::{events, AvailabilityChangedEvent, ClaimEvent}; -use crate::storage_types::DataKey; - -/// A complete snapshot of the vault's current state. -/// Useful for dashboards, analytics, and indexer integrations. -#[derive(Clone, Debug)] -#[contracttype] -pub struct VaultOverview { - /// The admin address that controls the vault - pub admin: Address, - /// Whether claiming is currently enabled - pub enabled: bool, - /// The ROI percentage (e.g., 5 = 5% return on investment) - pub roi_percentage: i128, - /// The participation token contract address - pub token_address: Address, - /// The USDC stablecoin contract address - pub usdc_address: Address, - /// Current USDC balance available in the vault - pub vault_usdc_balance: i128, - /// Total participation tokens that have been redeemed - pub total_tokens_redeemed: i128, -} - -/// Information about a beneficiary's claimable ROI. -#[derive(Clone, Debug)] -#[contracttype] -pub struct ClaimPreview { - /// The beneficiary's current token balance - pub token_balance: i128, - /// The amount of USDC the beneficiary would receive - pub usdc_amount: i128, - /// The ROI portion of the USDC amount (profit) - pub roi_amount: i128, - /// Whether the vault has enough USDC to fulfill this claim - pub vault_has_sufficient_balance: bool, - /// Whether claiming is currently enabled - pub claim_enabled: bool, -} +use crate::events::{AvailabilityChangedEvent, ClaimEvent}; +use crate::storage_types::{DataKey, INSTANCE_BUMP_AMOUNT, INSTANCE_LIFETIME_THRESHOLD}; +use crate::types::{ClaimPreview, VaultOverview}; #[contract] pub struct VaultContract; +/// Calculates USDC payout: token_balance * (100 + roi_percentage) / 100 +/// Returns Err on overflow. +fn calculate_usdc_amount( + token_balance: i128, + roi_percentage: i128, +) -> Result { + let rate = 100_i128 + .checked_add(roi_percentage) + .ok_or(ContractError::ArithmeticOverflow)?; + let numerator = token_balance + .checked_mul(rate) + .ok_or(ContractError::ArithmeticOverflow)?; + numerator + .checked_div(100) + .ok_or(ContractError::ArithmeticOverflow) +} + +/// Helper to read a required value from instance storage. +fn get_required>( + env: &Env, + key: &DataKey, +) -> Result { + env.storage() + .instance() + .get(key) + .ok_or(ContractError::NotInitialized) +} + #[contractimpl] impl VaultContract { // ============ Constructor ============ /// Initializes the vault contract with the given parameters. /// + /// # Trust Assumptions + /// The deployer is responsible for providing correct and trusted addresses. + /// These addresses are **immutable** after deployment — there are no setters. + /// + /// * `token` must be the participation token contract deployed by the token factory. + /// * `usdc` must be the canonical USDC Stellar Asset Contract on the target network. + /// * `admin` must be a secure, controlled address (ideally a multisig). + /// + /// Providing incorrect addresses will render the vault permanently non-functional. + /// See `docs/VAULT_SECURITY.md` for the full deployment checklist. + /// /// # Arguments /// * `admin` - The address that will control vault availability /// * `enabled` - Initial state of whether claiming is enabled - /// * `roi_percentage` - The ROI percentage (e.g., 5 for 5% return) - /// * `token` - The token factory address - /// * `usdc` - The USDC stablecoin contract address + /// * `roi_percentage` - The ROI percentage (e.g., 5 for 5% return). Must be 0..=1000. + /// * `token` - The participation token contract address (must be trusted) + /// * `usdc` - The USDC stablecoin contract address (must be trusted) + /// + /// # Panics + /// * `AlreadyInitialized` - If the contract has already been initialized + /// * `InvalidRoiPercentage` - If roi_percentage is negative or > 1000 + /// * `TokenAndUsdcCannotBeSame` - If token and USDC addresses are identical + /// * `InvalidAddressConfiguration` - If admin equals token or USDC address pub fn __constructor( env: Env, admin: Address, @@ -65,6 +74,29 @@ impl VaultContract { token: Address, usdc: Address, ) { + const ROI_MAX: i128 = 1000; + let already_initialized: bool = env + .storage() + .instance() + .get(&DataKey::Initialized) + .unwrap_or(false); + if already_initialized { + panic_with_error!(&env, ContractError::AlreadyInitialized); + } + + if roi_percentage < 0 || roi_percentage > ROI_MAX { + panic_with_error!(&env, ContractError::InvalidRoiPercentage); + } + if token == usdc { + panic_with_error!(&env, ContractError::TokenAndUsdcCannotBeSame); + } + if admin == token || admin == usdc { + panic_with_error!(&env, ContractError::InvalidAddressConfiguration); + } + + env.storage() + .instance() + .set(&DataKey::Initialized, &true); env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Enabled, &enabled); env.storage() @@ -77,47 +109,34 @@ impl VaultContract { env.storage() .instance() .set(&DataKey::TotalTokensRedeemed, &0_i128); + + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); } // ============ Admin Functions ============ - /// Enables or disables the vault for ROI claiming. - /// Only the admin can call this function. - /// - /// # Arguments - /// * `admin` - Must be the contract admin address - /// * `enabled` - The new availability state - /// - /// # Errors - /// * `AdminNotFound` - If admin is not set in storage - /// * `OnlyAdminCanChangeAvailability` - If caller is not the admin pub fn availability_for_exchange( env: Env, - admin: Address, enabled: bool, ) -> Result<(), ContractError> { - admin.require_auth(); - - let stored_admin: Address = env - .storage() + env.storage() .instance() - .get(&DataKey::Admin) - .ok_or(ContractError::AdminNotFound)?; + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); - if admin != stored_admin { - return Err(ContractError::OnlyAdminCanChangeAvailability); - } + let stored_admin: Address = get_required(&env, &DataKey::Admin)?; + + stored_admin.require_auth(); env.storage().instance().set(&DataKey::Enabled, &enabled); // Emit availability changed event - events::emit_availability_changed( - &env, - AvailabilityChangedEvent { - admin: admin.clone(), - enabled, - }, - ); + AvailabilityChangedEvent { + admin: stored_admin.clone(), + enabled, + } + .publish(&env); Ok(()) } @@ -133,17 +152,26 @@ impl VaultContract { /// * `beneficiary` - The address claiming their ROI (must have tokens) /// /// # Errors + /// * `EnabledNotFound` - If enabled flag is not set in storage /// * `ExchangeIsCurrentlyDisabled` - If vault is disabled + /// * `RoiPercentageNotFound` - If ROI percentage is not set in storage + /// * `TokenAddressNotFound` - If token address is not set in storage /// * `BeneficiaryHasNoTokensToClaim` - If beneficiary has zero tokens + /// * `UsdcAddressNotFound` - If USDC address is not set in storage /// * `VaultDoesNotHaveEnoughUSDC` - If vault cannot cover the claim + /// * `ArithmeticOverflow` - If total tokens redeemed overflows pub fn claim(env: Env, beneficiary: Address) -> Result<(), ContractError> { beneficiary.require_auth(); + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + let enabled: bool = env .storage() .instance() .get(&DataKey::Enabled) - .expect("Enabled flag not found"); + .ok_or(ContractError::EnabledFlagNotFound)?; if !enabled { return Err(ContractError::ExchangeIsCurrentlyDisabled); @@ -153,13 +181,13 @@ impl VaultContract { .storage() .instance() .get(&DataKey::RoiPercentage) - .expect("ROI percentage not found"); + .ok_or(ContractError::RoiPercentageNotFound)?; let token_address: Address = env .storage() .instance() .get(&DataKey::TokenAddress) - .expect("Token address not found"); + .ok_or(ContractError::TokenAddressNotFound)?; let token_client = TokenClient::new(&env, &token_address); let token_balance = token_client.balance(&beneficiary); @@ -168,13 +196,13 @@ impl VaultContract { return Err(ContractError::BeneficiaryHasNoTokensToClaim); } - let usdc_amount = (token_balance * (100 + roi_percentage)) / 100; + let usdc_amount = calculate_usdc_amount(token_balance, roi_percentage)?; let usdc_address: Address = env .storage() .instance() .get(&DataKey::UsdcAddress) - .expect("USDC address not found"); + .ok_or(ContractError::UsdcAddressNotFound)?; let usdc_client = TokenClient::new(&env, &usdc_address); let vault_usdc_balance = usdc_client.balance(&env.current_contract_address()); @@ -187,26 +215,27 @@ impl VaultContract { token_client.burn(&beneficiary, &token_balance); usdc_client.transfer(&env.current_contract_address(), &beneficiary, &usdc_amount); - // Update total tokens redeemed + // Update total tokens redeemed (checked arithmetic — #26) let total_redeemed: i128 = env .storage() .instance() .get(&DataKey::TotalTokensRedeemed) .unwrap_or(0); + let new_total = total_redeemed + .checked_add(token_balance) + .ok_or(ContractError::ArithmeticOverflow)?; env.storage() .instance() - .set(&DataKey::TotalTokensRedeemed, &(total_redeemed + token_balance)); + .set(&DataKey::TotalTokensRedeemed, &new_total); // Emit claim event for indexers and explorers - events::emit_claim( - &env, - ClaimEvent { - beneficiary: beneficiary.clone(), - tokens_redeemed: token_balance, - usdc_received: usdc_amount, - roi_percentage, - }, - ); + ClaimEvent { + beneficiary: beneficiary.clone(), + tokens_redeemed: token_balance, + usdc_received: usdc_amount, + roi_percentage, + } + .publish(&env); Ok(()) } @@ -214,59 +243,79 @@ impl VaultContract { // ============ View/Getter Functions ============ /// Returns the admin address. - pub fn get_admin(env: Env) -> Address { + pub fn get_admin(env: Env) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.storage() .instance() .get(&DataKey::Admin) - .expect("Admin not found") + .ok_or(ContractError::AdminNotFound) } /// Returns whether claiming is currently enabled. - pub fn is_enabled(env: Env) -> bool { + pub fn is_enabled(env: Env) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.storage() .instance() .get(&DataKey::Enabled) - .unwrap_or(false) + .ok_or(ContractError::EnabledFlagNotFound) } /// Returns the ROI percentage (e.g., 5 means 5% return). - pub fn get_roi_percentage(env: Env) -> i128 { + pub fn get_roi_percentage(env: Env) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.storage() .instance() .get(&DataKey::RoiPercentage) - .expect("ROI percentage not found") + .ok_or(ContractError::RoiPercentageNotFound) } /// Returns the participation token contract address. - pub fn get_token_address(env: Env) -> Address { + pub fn get_token_address(env: Env) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.storage() .instance() .get(&DataKey::TokenAddress) - .expect("Token address not found") + .ok_or(ContractError::TokenAddressNotFound) } /// Returns the USDC stablecoin contract address. - pub fn get_usdc_address(env: Env) -> Address { + pub fn get_usdc_address(env: Env) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.storage() .instance() .get(&DataKey::UsdcAddress) - .expect("USDC address not found") + .ok_or(ContractError::UsdcAddressNotFound) } /// Returns the current USDC balance held by the vault. - pub fn get_vault_usdc_balance(env: Env) -> i128 { + pub fn get_vault_usdc_balance(env: Env) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); let usdc_address: Address = env .storage() .instance() .get(&DataKey::UsdcAddress) - .expect("USDC address not found"); + .ok_or(ContractError::UsdcAddressNotFound)?; let usdc_client = TokenClient::new(&env, &usdc_address); - usdc_client.balance(&env.current_contract_address()) + Ok(usdc_client.balance(&env.current_contract_address())) } - /// Returns the total amount of participation tokens that have been redeemed. pub fn get_total_tokens_redeemed(env: Env) -> i128 { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); env.storage() .instance() .get(&DataKey::TotalTokensRedeemed) @@ -282,36 +331,41 @@ impl VaultContract { /// * `beneficiary` - The address to preview the claim for /// /// # Returns - /// A `ClaimPreview` struct with all relevant claim information - pub fn preview_claim(env: Env, beneficiary: Address) -> ClaimPreview { + /// A `Result` with all relevant claim information + pub fn preview_claim(env: Env, beneficiary: Address) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); let roi_percentage: i128 = env .storage() .instance() .get(&DataKey::RoiPercentage) - .unwrap_or(0); + .ok_or(ContractError::RoiPercentageNotFound)?; let token_address: Address = env .storage() .instance() .get(&DataKey::TokenAddress) - .expect("Token address not found"); + .ok_or(ContractError::TokenAddressNotFound)?; let token_client = TokenClient::new(&env, &token_address); let token_balance = token_client.balance(&beneficiary); - let usdc_amount = if token_balance > 0 { - (token_balance * (100 + roi_percentage)) / 100 + let (usdc_amount, roi_amount) = if token_balance > 0 { + let usdc = calculate_usdc_amount(token_balance, roi_percentage)?; + let roi = usdc + .checked_sub(token_balance) + .ok_or(ContractError::ArithmeticOverflow)?; + (usdc, roi) } else { - 0 + (0, 0) }; - let roi_amount = usdc_amount - token_balance; - let usdc_address: Address = env .storage() .instance() .get(&DataKey::UsdcAddress) - .expect("USDC address not found"); + .ok_or(ContractError::UsdcAddressNotFound)?; let usdc_client = TokenClient::new(&env, &usdc_address); let vault_usdc_balance = usdc_client.balance(&env.current_contract_address()); @@ -320,51 +374,54 @@ impl VaultContract { .storage() .instance() .get(&DataKey::Enabled) - .unwrap_or(false); + .ok_or(ContractError::EnabledFlagNotFound)?; - ClaimPreview { + Ok(ClaimPreview { token_balance, usdc_amount, roi_amount, vault_has_sufficient_balance: vault_usdc_balance >= usdc_amount, claim_enabled: enabled, - } + }) } // ============ Overview Functions ============ /// Returns a complete snapshot of the vault's current state. /// Useful for dashboards and analytics integrations. - pub fn get_vault_overview(env: Env) -> VaultOverview { + pub fn get_vault_overview(env: Env) -> Result { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); let admin: Address = env .storage() .instance() .get(&DataKey::Admin) - .expect("Admin not found"); + .ok_or(ContractError::AdminNotFound)?; let enabled: bool = env .storage() .instance() .get(&DataKey::Enabled) - .unwrap_or(false); + .ok_or(ContractError::EnabledFlagNotFound)?; let roi_percentage: i128 = env .storage() .instance() .get(&DataKey::RoiPercentage) - .unwrap_or(0); + .ok_or(ContractError::RoiPercentageNotFound)?; let token_address: Address = env .storage() .instance() .get(&DataKey::TokenAddress) - .expect("Token address not found"); + .ok_or(ContractError::TokenAddressNotFound)?; let usdc_address: Address = env .storage() .instance() .get(&DataKey::UsdcAddress) - .expect("USDC address not found"); + .ok_or(ContractError::UsdcAddressNotFound)?; let usdc_client = TokenClient::new(&env, &usdc_address); let vault_usdc_balance = usdc_client.balance(&env.current_contract_address()); @@ -375,7 +432,7 @@ impl VaultContract { .get(&DataKey::TotalTokensRedeemed) .unwrap_or(0); - VaultOverview { + Ok(VaultOverview { admin, enabled, roi_percentage, @@ -383,6 +440,6 @@ impl VaultContract { usdc_address, vault_usdc_balance, total_tokens_redeemed, - } + }) } } diff --git a/apps/smart-contracts/contracts/vault-contract/src/error.rs b/apps/smart-contracts/contracts/vault-contract/src/error.rs index 898ae73..877d202 100644 --- a/apps/smart-contracts/contracts/vault-contract/src/error.rs +++ b/apps/smart-contracts/contracts/vault-contract/src/error.rs @@ -9,6 +9,16 @@ pub enum ContractError { ExchangeIsCurrentlyDisabled = 3, BeneficiaryHasNoTokensToClaim = 4, VaultDoesNotHaveEnoughUSDC = 5, + TokenAndUsdcCannotBeSame = 6, + InvalidAddressConfiguration = 7, + AlreadyInitialized = 8, + InvalidRoiPercentage = 9, + EnabledFlagNotFound = 10, + RoiPercentageNotFound = 11, + TokenAddressNotFound = 12, + UsdcAddressNotFound = 13, + ArithmeticOverflow = 14, + NotInitialized = 15, } impl fmt::Display for ContractError { @@ -27,6 +37,32 @@ impl fmt::Display for ContractError { ContractError::VaultDoesNotHaveEnoughUSDC => { write!(f, "Vault does not have enough USDC") } + ContractError::TokenAndUsdcCannotBeSame => { + write!(f, "Token and USDC addresses cannot be the same") + } + ContractError::InvalidAddressConfiguration => { + write!(f, "Invalid address configuration: admin cannot be token or USDC address") + } + ContractError::AlreadyInitialized => { + write!(f, "Contract has already been initialized") + } + ContractError::InvalidRoiPercentage => { + write!(f, "ROI percentage must be between 0 and 1000") + } + ContractError::EnabledFlagNotFound => { + write!(f, "Enabled flag not found in storage") + } + ContractError::RoiPercentageNotFound => { + write!(f, "ROI percentage not found in storage") + } + ContractError::TokenAddressNotFound => { + write!(f, "Token address not found in storage") + } + ContractError::UsdcAddressNotFound => { + write!(f, "USDC address not found in storage") + } + ContractError::ArithmeticOverflow => write!(f, "Arithmetic overflow"), + ContractError::NotInitialized => write!(f, "Contract not initialized"), } } } \ No newline at end of file diff --git a/apps/smart-contracts/contracts/vault-contract/src/events.rs b/apps/smart-contracts/contracts/vault-contract/src/events.rs index ff8b703..6a9cc8f 100644 --- a/apps/smart-contracts/contracts/vault-contract/src/events.rs +++ b/apps/smart-contracts/contracts/vault-contract/src/events.rs @@ -1,44 +1,20 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contractevent, Address}; /// Event emitted when a beneficiary successfully claims their ROI. /// This enables indexers and explorers to track claim activity. -#[contracttype] +#[contractevent(topics = ["vault_claim"], data_format = "vec")] #[derive(Clone, Debug)] pub struct ClaimEvent { - /// The address that claimed the ROI pub beneficiary: Address, - /// Amount of participation tokens redeemed pub tokens_redeemed: i128, - /// Amount of USDC received (including ROI) pub usdc_received: i128, - /// The ROI percentage at the time of claim pub roi_percentage: i128, } /// Event emitted when the vault availability is changed by admin. -#[contracttype] +#[contractevent(topics = ["vault_avail"], data_format = "vec")] #[derive(Clone, Debug)] pub struct AvailabilityChangedEvent { - /// The admin who made the change pub admin: Address, - /// The new enabled status pub enabled: bool, } - -/// Helper functions for publishing events -pub mod events { - use super::*; - use soroban_sdk::symbol_short; - - /// Publishes a ClaimEvent to the blockchain event log. - pub fn emit_claim(env: &Env, event: ClaimEvent) { - env.events() - .publish((symbol_short!("claim"),), event); - } - - /// Publishes an AvailabilityChangedEvent to the blockchain event log. - pub fn emit_availability_changed(env: &Env, event: AvailabilityChangedEvent) { - env.events() - .publish((symbol_short!("avail"),), event); - } -} diff --git a/apps/smart-contracts/contracts/vault-contract/src/lib.rs b/apps/smart-contracts/contracts/vault-contract/src/lib.rs index 4277f37..259177d 100644 --- a/apps/smart-contracts/contracts/vault-contract/src/lib.rs +++ b/apps/smart-contracts/contracts/vault-contract/src/lib.rs @@ -1,14 +1,16 @@ #![no_std] +mod contract; mod error; mod events; mod storage_types; -mod vault; +mod types; +pub use crate::contract::VaultContract; pub use crate::error::ContractError; pub use crate::events::{AvailabilityChangedEvent, ClaimEvent}; pub use crate::storage_types::DataKey; -pub use crate::vault::{ClaimPreview, VaultContract, VaultOverview}; +pub use crate::types::{ClaimPreview, VaultOverview}; #[cfg(test)] mod test; diff --git a/apps/smart-contracts/contracts/vault-contract/src/storage_types.rs b/apps/smart-contracts/contracts/vault-contract/src/storage_types.rs index a352678..867946d 100644 --- a/apps/smart-contracts/contracts/vault-contract/src/storage_types.rs +++ b/apps/smart-contracts/contracts/vault-contract/src/storage_types.rs @@ -1,5 +1,9 @@ use soroban_sdk::contracttype; +pub(crate) const DAY_IN_LEDGERS: u32 = 17280; +pub(crate) const INSTANCE_BUMP_AMOUNT: u32 = 7 * DAY_IN_LEDGERS; +pub(crate) const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - DAY_IN_LEDGERS; + /// Typed storage keys for the vault contract. /// Using an enum instead of raw strings improves type safety and readability. #[derive(Clone)] @@ -17,4 +21,6 @@ pub enum DataKey { UsdcAddress, /// Total tokens that have been redeemed through the vault TotalTokensRedeemed, + /// Flag to prevent constructor re-invocation + Initialized, } diff --git a/apps/smart-contracts/contracts/vault-contract/src/test.rs b/apps/smart-contracts/contracts/vault-contract/src/test.rs index 3a3073a..f482106 100644 --- a/apps/smart-contracts/contracts/vault-contract/src/test.rs +++ b/apps/smart-contracts/contracts/vault-contract/src/test.rs @@ -2,9 +2,9 @@ extern crate std; use crate::error::ContractError; -use crate::vault::{VaultContract, VaultContractClient}; +use crate::contract::{VaultContract, VaultContractClient}; use soroban_sdk::{testutils::Address as _, testutils::Events as _, token, Address, Env, String}; -use soroban_token_contract::{Token as FactoryToken, TokenClient as FactoryTokenClient}; +use participation_token::{Token as FactoryToken, TokenClient as FactoryTokenClient}; use token::Client as TokenClient; use token::StellarAssetClient as TokenAdminClient; @@ -51,6 +51,36 @@ fn create_vault<'a>( VaultContractClient::new(e, &contract_id) } +// ============ Constructor Validation Tests ============ + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_constructor_rejects_negative_roi_percentage() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + let _ = create_vault(&env, &admin, true, -1, &token.address, &usdc_client.address); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_constructor_rejects_roi_percentage_over_max() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + let _ = create_vault(&env, &admin, true, 1001, &token.address, &usdc_client.address); +} + // ============ Original Tests (Updated) ============ #[test] @@ -67,9 +97,9 @@ fn test_vault_deployment_and_availability() { let vault = create_vault(&env, &admin, false, 10, &token.address, &usdc_client.address); - vault.availability_for_exchange(&admin, &true); + vault.availability_for_exchange(&true); - vault.availability_for_exchange(&admin, &false); + vault.availability_for_exchange(&false); } #[test] @@ -199,7 +229,7 @@ fn test_claim_with_6_percent_premium() { assert_eq!(usdc_client.balance(&vault.address), 94); } -// ============ New Getter Function Tests ============ +// ============ Getter Function Tests ============ #[test] fn test_get_admin() { @@ -237,7 +267,7 @@ fn test_is_enabled() { assert_eq!(vault_enabled.is_enabled(), true); // Test toggling - vault_disabled.availability_for_exchange(&admin, &true); + vault_disabled.availability_for_exchange(&true); assert_eq!(vault_disabled.is_enabled(), true); } @@ -524,7 +554,7 @@ fn test_claim_emits_event() { // Verify event was emitted let events = env.events().all(); - assert!(!events.is_empty(), "Expected claim event to be emitted"); + assert!(!events.events().is_empty(), "Expected claim event to be emitted"); } #[test] @@ -540,16 +570,138 @@ fn test_availability_change_emits_event() { let vault = create_vault(&env, &admin, false, 10, &token.address, &usdc_client.address); - vault.availability_for_exchange(&admin, &true); + vault.availability_for_exchange(&true); // Verify event was emitted let events = env.events().all(); assert!( - !events.is_empty(), + !events.events().is_empty(), "Expected availability changed event to be emitted" ); } +// ============ Constructor Validation Tests ============ + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_constructor_rejects_same_token_and_usdc() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + + let token = create_token_factory(&env, &token_admin); + + // Pass the same address for both token and usdc — should panic + create_vault(&env, &admin, true, 10, &token.address, &token.address); +} + +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_constructor_rejects_admin_equals_token() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &token_admin); + let token = create_token_factory(&env, &token_admin); + + // Pass token address as admin — should panic + create_vault(&env, &token.address, true, 10, &token.address, &usdc_client.address); +} + +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_constructor_rejects_admin_equals_usdc() { + let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &token_admin); + let token = create_token_factory(&env, &token_admin); + + // Pass usdc address as admin — should panic + create_vault(&env, &usdc_client.address, true, 10, &token.address, &usdc_client.address); +} + +#[test] +fn test_constructor_accepts_valid_distinct_addresses() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // All distinct addresses — should succeed + let vault = create_vault(&env, &admin, true, 10, &token.address, &usdc_client.address); + + assert_eq!(vault.get_admin(), admin); + assert_eq!(vault.get_token_address(), token.address); + assert_eq!(vault.get_usdc_address(), usdc_client.address); +} + +// ============ Input Validation Tests ============ + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_constructor_rejects_negative_roi() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // Negative ROI should panic + create_vault(&env, &admin, true, -5, &token.address, &usdc_client.address); +} + +#[test] +fn test_constructor_accepts_zero_roi() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // Zero ROI is valid (no profit, just principal return) + let vault = create_vault(&env, &admin, true, 0, &token.address, &usdc_client.address); + assert_eq!(vault.get_roi_percentage(), 0); +} + +// ============ Re-initialization Protection Tests ============ + +#[test] +fn test_constructor_sets_initialized_flag() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (usdc_client, _usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // First deploy works fine + let vault = create_vault(&env, &admin, true, 10, &token.address, &usdc_client.address); + + // Vault is functional after initialization + assert_eq!(vault.get_admin(), admin); + assert_eq!(vault.is_enabled(), true); + assert_eq!(vault.get_roi_percentage(), 10); +} + // ============ Edge Case Tests ============ #[test] @@ -611,3 +763,275 @@ fn test_high_roi_percentage() { vault.claim(&beneficiary); assert_eq!(usdc_client.balance(&beneficiary), 200); } + +// ============ Constructor Validation Tests (CoKeFish #24) ============ + +#[test] +fn test_constructor_accepts_max_roi() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (usdc_client, _) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + let vault = create_vault(&env, &admin, true, 1000, &token.address, &usdc_client.address); + assert_eq!(vault.get_roi_percentage(), 1000); +} + +// ============ Security Tests (#33) ============ + +#[test] +fn test_claim_overflow_in_formula() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, _) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // Use max ROI (1000) and a token balance that causes overflow: token_balance * (100 + roi) overflows i128 + // token_balance * 1100 > i128::MAX when token_balance > i128::MAX / 1100 + let overflow_balance: i128 = i128::MAX / 1100 + 1; + let vault = create_vault(&env, &admin, true, 1000, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &overflow_balance); + + // claim() will compute: overflow_balance * (100 + 1000) / 100 which overflows i128 -> returns ArithmeticOverflow + let result = vault.try_claim(&beneficiary); + assert_eq!(result, Err(Ok(ContractError::ArithmeticOverflow))); +} + +#[test] +fn test_claim_zero_roi() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // 0% ROI — principal only, no profit + let vault = create_vault(&env, &admin, true, 0, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &100); + usdc_admin.mint(&vault.address, &200); + + vault.claim(&beneficiary); + + assert_eq!(token.balance(&beneficiary), 0); + assert_eq!(usdc_client.balance(&beneficiary), 100); // exactly principal + assert_eq!(usdc_client.balance(&vault.address), 100); +} + +#[test] +fn test_double_claim_same_beneficiary() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + let vault = create_vault(&env, &admin, true, 10, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &100); + usdc_admin.mint(&vault.address, &500); + + // First claim succeeds + vault.claim(&beneficiary); + assert_eq!(usdc_client.balance(&beneficiary), 110); + assert_eq!(token.balance(&beneficiary), 0); + + // Second claim fails — tokens already burned + let result = vault.try_claim(&beneficiary); + assert_eq!(result, Err(Ok(ContractError::BeneficiaryHasNoTokensToClaim))); +} + +#[test] +#[should_panic] +fn test_non_admin_cannot_change_availability() { + let env = Env::default(); + // Do NOT mock_all_auths — we want auth to fail for non-admin + + let admin = Address::generate(&env); + let _non_admin = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (usdc_client, _) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + let vault = create_vault(&env, &admin, false, 10, &token.address, &usdc_client.address); + + // Non-admin tries to enable — should fail auth + vault.availability_for_exchange(&true); +} + +#[test] +fn test_claim_exact_vault_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // 10% ROI: 100 tokens → 110 USDC + let vault = create_vault(&env, &admin, true, 10, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &100); + usdc_admin.mint(&vault.address, &110); // exactly enough + + vault.claim(&beneficiary); + + assert_eq!(usdc_client.balance(&beneficiary), 110); + assert_eq!(usdc_client.balance(&vault.address), 0); // vault fully drained +} + +#[test] +fn test_preview_matches_actual_claim() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + let vault = create_vault(&env, &admin, true, 15, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &200); + usdc_admin.mint(&vault.address, &1000); + + // Preview before claim + let preview = vault.preview_claim(&beneficiary); + assert_eq!(preview.token_balance, 200); + assert_eq!(preview.usdc_amount, 230); // 200 * 1.15 = 230 + assert_eq!(preview.vault_has_sufficient_balance, true); + assert_eq!(preview.claim_enabled, true); + + // Actual claim + vault.claim(&beneficiary); + + // Verify preview amounts match actual + assert_eq!(usdc_client.balance(&beneficiary), preview.usdc_amount); + assert_eq!(token.balance(&beneficiary), 0); +} + +#[test] +fn test_small_amount_truncation() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // 1% ROI: 3 tokens → 3 * 101 / 100 = 303 / 100 = 3 (integer division truncates) + let vault = create_vault(&env, &admin, true, 1, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &3); + usdc_admin.mint(&vault.address, &100); + + let preview = vault.preview_claim(&beneficiary); + assert_eq!(preview.usdc_amount, 3); // 303 / 100 = 3 (truncated) + assert_eq!(preview.roi_amount, 0); // 3 - 3 = 0 (lost to truncation) + + vault.claim(&beneficiary); + assert_eq!(usdc_client.balance(&beneficiary), 3); +} + +#[test] +fn test_claim_single_token_high_roi() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // 50% ROI: 1 token → 1 * 150 / 100 = 1 (truncated) + let vault = create_vault(&env, &admin, true, 50, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &1); + usdc_admin.mint(&vault.address, &100); + + vault.claim(&beneficiary); + assert_eq!(usdc_client.balance(&beneficiary), 1); // 150/100 = 1 +} + +#[test] +fn test_claim_large_token_amount() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // 10% ROI with large amounts + let vault = create_vault(&env, &admin, true, 10, &token.address, &usdc_client.address); + + let large_amount: i128 = 1_000_000_000_000; // 1 trillion + token.mint(&beneficiary, &large_amount); + let vault_funds: i128 = 2_000_000_000_000; + usdc_admin.mint(&vault.address, &vault_funds); + + vault.claim(&beneficiary); + + // 1_000_000_000_000 * 110 / 100 = 1_100_000_000_000 + assert_eq!(usdc_client.balance(&beneficiary), 1_100_000_000_000); +} + +#[test] +fn test_enable_disable_enable_then_claim() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + + let (usdc_client, usdc_admin) = create_usdc_token(&env, &admin); + let token = create_token_factory(&env, &token_admin); + + // Start disabled + let vault = create_vault(&env, &admin, false, 5, &token.address, &usdc_client.address); + + token.mint(&beneficiary, &100); + usdc_admin.mint(&vault.address, &200); + + // Claim should fail when disabled + let result = vault.try_claim(&beneficiary); + assert_eq!(result, Err(Ok(ContractError::ExchangeIsCurrentlyDisabled))); + + // Enable → claim should work + vault.availability_for_exchange(&true); + vault.claim(&beneficiary); + assert_eq!(usdc_client.balance(&beneficiary), 105); +} diff --git a/apps/smart-contracts/contracts/vault-contract/src/types.rs b/apps/smart-contracts/contracts/vault-contract/src/types.rs new file mode 100644 index 0000000..b045d8b --- /dev/null +++ b/apps/smart-contracts/contracts/vault-contract/src/types.rs @@ -0,0 +1,39 @@ +use soroban_sdk::{contracttype, Address}; + +/// A complete snapshot of the vault's current state. +/// Useful for dashboards, analytics, and indexer integrations. +#[derive(Clone, Debug)] +#[contracttype] +pub struct VaultOverview { + /// The admin address that controls the vault + pub admin: Address, + /// Whether claiming is currently enabled + pub enabled: bool, + /// The ROI percentage (e.g., 5 = 5% return on investment) + pub roi_percentage: i128, + /// The participation token contract address + pub token_address: Address, + /// The USDC stablecoin contract address + pub usdc_address: Address, + /// Current USDC balance available in the vault + pub vault_usdc_balance: i128, + /// Total participation tokens that have been redeemed + pub total_tokens_redeemed: i128, +} + +/// Information about a beneficiary's claimable ROI. +#[derive(Clone, Debug)] +#[contracttype] +pub struct ClaimPreview { + /// The beneficiary's current token balance + pub token_balance: i128, + /// The amount of USDC the beneficiary would receive + pub usdc_amount: i128, + /// The ROI portion of the USDC amount (profit) + pub roi_amount: i128, + /// Whether the vault has enough USDC to fulfill this claim + pub vault_has_sufficient_balance: bool, + /// Whether claiming is currently enabled + pub claim_enabled: bool, +} + diff --git a/docs/VAULT_SECURITY.md b/docs/VAULT_SECURITY.md new file mode 100644 index 0000000..3a99004 --- /dev/null +++ b/docs/VAULT_SECURITY.md @@ -0,0 +1,97 @@ +# Vault Contract — Security & Trust Assumptions + +## Overview + +The vault contract (`contracts/vault-contract`) holds USDC and distributes it to investors who redeem their participation tokens plus ROI. It relies on three external addresses provided at deployment time: `token`, `usdc`, and `admin`. + +**These addresses are immutable.** Once the contract is deployed, they cannot be changed. A misconfigured vault is permanently non-functional. + +## Trust Assumptions + +### 1. Token Address (`token`) + +The `token` parameter must be the **participation token contract** deployed via the token factory during the tokenize escrow flow. + +- The vault calls `balance()` and `burn()` on this address. +- If the address is not a valid Soroban token contract, all `claim()` calls will fail. +- If the address points to a malicious contract, it could report fake balances or fail to burn tokens. + +**Verification:** After deploying the token factory, confirm that its contract address matches what you pass to the vault constructor. Cross-reference with the `set_token()` call on the participation token contract. + +### 2. USDC Address (`usdc`) + +The `usdc` parameter must be the **canonical USDC Stellar Asset Contract (SAC)** on the target network. + +| Network | USDC Issuer | +|---------|-------------| +| Testnet | Use `register_stellar_asset_contract_v2` in tests | +| Mainnet | Verify the issuer against Circle's official documentation | + +- The vault calls `balance()` and `transfer()` on this address. +- If the address is not the real USDC SAC, investors will receive worthless tokens or the transfer will fail. + +**Verification:** Use Stellar Expert or the Horizon API to confirm the USDC contract address before deployment. + +### 3. Admin Address (`admin`) + +The `admin` controls whether the vault is open for claims via `availability_for_exchange()`. + +- A compromised admin can disable claims permanently, locking investor funds. +- An admin set to a contract address may not be able to sign transactions. + +**Recommendation:** Use a multisig or a well-secured operational wallet. Never use a personal development key for mainnet deployments. + +## On-Chain Validations + +The constructor enforces the following checks: + +| Check | Error Code | Description | +|-------|-----------|-------------| +| Not already initialized | `AlreadyInitialized (8)` | Prevents constructor re-invocation on upgrade/redeploy | +| `token != usdc` | `TokenAndUsdcCannotBeSame (6)` | Prevents deploying with the same address for both token and USDC | +| `admin != token` | `InvalidAddressConfiguration (7)` | Prevents admin from being a token contract | +| `admin != usdc` | `InvalidAddressConfiguration (7)` | Prevents admin from being the USDC contract | + +These are **basic sanity checks**, not a substitute for deployer diligence. They catch obvious misconfigurations but cannot verify that an address implements the correct interface or is the "right" contract. + +## Initialization & Upgrade Behavior + +The vault contract uses an `Initialized` storage flag as defense-in-depth against constructor re-invocation. + +**How it works:** +1. On first deployment, `Initialized` is `false` (default). The constructor runs normally and sets it to `true`. +2. Any subsequent attempt to call the constructor will find `Initialized = true` and panic with `AlreadyInitialized (8)`. + +**On WASM upgrades (`update_current_contract_wasm`):** +- Soroban does NOT re-call `__constructor` during WASM upgrades — storage is preserved. +- The `Initialized` flag remains `true`, so even if a future SDK version changes this behavior, the contract is protected. +- All existing storage (admin, token, usdc, etc.) persists through the upgrade. + +**When re-deployment is needed:** +- If you need to change immutable parameters (token, usdc, admin), you must deploy a **new contract instance**. There is no migration path for these values. +- The old vault should be drained of USDC and disabled before deploying a replacement. + +## Deployment Checklist + +Before deploying a vault contract to any network: + +- [ ] **Token address** — Confirm it matches the token factory contract deployed in the tokenize escrow flow +- [ ] **USDC address** — Verify it is the canonical USDC SAC for the target network +- [ ] **Admin address** — Ensure it is a secure, operational wallet (multisig preferred) +- [ ] **ROI percentage** — Double-check the value (e.g., 5 = 5%, not 0.05) +- [ ] **Enabled flag** — Set to `false` initially; enable only after funding the vault with USDC +- [ ] **Fund the vault** — Transfer sufficient USDC to cover all expected claims (principal + ROI) +- [ ] **Test a preview** — Call `preview_claim()` with a known beneficiary to verify the math + +## Token Registry (Future) + +Soroban does not currently provide a native on-chain token registry. When the Stellar ecosystem introduces one, the constructor should be updated to validate `token` and `usdc` against it. This would provide a stronger guarantee that the addresses are legitimate token contracts. + +For now, off-chain verification (deployment scripts, CI checks, manual review) is the recommended approach. + +## Related Files + +- **Contract**: `apps/smart-contracts/contracts/vault-contract/src/contract.rs` +- **Error codes**: `apps/smart-contracts/contracts/vault-contract/src/error.rs` +- **Deployment flow**: `docs/TOKENIZE-ESCROW.md` +- **Product context**: `docs/PRODUCT.md` diff --git a/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md b/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md new file mode 100644 index 0000000..b1e4817 --- /dev/null +++ b/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md @@ -0,0 +1,302 @@ +# Security Audit: VaultContract + +**Date:** March 2025 +**Contract:** `vault-contract` +**Scope:** Audit report. +**Author:** [@Villarley](https://github.com/Villarley) + +--- + +## Summary Table + +| Severity | Count | Findings | +|----------|-------|----------| +| **Critical** | 2 | F-01, F-02 | +| **High** | 3 | F-03, F-04, F-05 | +| **Medium** | 5 | F-06, F-07, F-08, F-09, F-10 | +| **Low** | 3 | F-11, F-12, F-13 | +| **Informational** | 2 | F-14, F-15 | +| **Enhancement** | 1 | F-17 (Scout) | + +**Scout (static analysis):** 4 critical, 5 medium, 1 enhancement. See [Scout Findings](#scout-findings) section. + +--- + +## Executive Summary + +The following files of the `vault-contract` were audited in their current state: + +- [`vault.rs`](../apps/smart-contracts/contracts/vault-contract/src/vault.rs) — main contract logic +- [`error.rs`](../apps/smart-contracts/contracts/vault-contract/src/error.rs) — typed errors +- [`events.rs`](../apps/smart-contracts/contracts/vault-contract/src/events.rs) — event emission +- [`storage_types.rs`](../apps/smart-contracts/contracts/vault-contract/src/storage_types.rs) — storage keys +- [`test.rs`](../apps/smart-contracts/contracts/vault-contract/src/test.rs) — contract tests + +--- + +## Findings by Severity + +### CRITICAL + +#### F-01: Missing TTL Extension in Storage + +| Field | Details | +|-------|---------| +| **Severity** | Critical | +| **Description** | The contract does not call `extend_ttl` in any operation. In Soroban, instance storage expires if not extended periodically. The `token-factory` contract does extend TTL on every operation that modifies or reads state (see [`contract.rs:86-89`](../apps/smart-contracts/contracts/token-factory/src/contract.rs)). | +| **Impact** | If TTL expires, the vault state (admin, enabled, roi_percentage, token_address, usdc_address, total_tokens_redeemed) is lost. USDC funds would be trapped in the contract address with no way to recover them. | +| **Recommendation** | Add `env.storage().instance().extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT)` in all functions that read or write storage, following the token-factory pattern. Define constants similar to token-factory's `storage_types.rs`. | + +--- + +#### F-02: Arithmetic Overflow in Claim Formula + +| Field | Details | +|-------|---------| +| **Severity** | Critical | +| **Description** | The expression `token_balance * (100 + roi_percentage) / 100` (lines 172 and 304) uses unverified arithmetic. With extreme values it can cause `i128` overflow. | +| **Impact** | Overflow can cause panic or incorrect results. Although amounts are typically bounded in practice, an attacker with very large tokens or a misconfigured ROI could exploit this. | +| **Recommendation** | Use `checked_mul` and `checked_div`. Add `ArithmeticOverflow` variant to `ContractError` and validate in the constructor that `roi_percentage` is within a reasonable range (e.g. 0–1000). | + +--- + +### HIGH + +#### F-03: Constructor Without Input Validation + +| Field | Details | +|-------|---------| +| **Severity** | High | +| **Description** | `__constructor` (lines 60-80) does not validate parameters. It accepts: negative or extreme `roi_percentage` (e.g. `i128::MAX`); arbitrary `admin`, `token`, `usdc` addresses (including malicious contracts); `token == usdc` (same contract for token and USDC). | +| **Impact** | Vault deployed with invalid or malicious configuration; loss of funds or unexpected behavior. | +| **Recommendation** | Validate in constructor: `0 <= roi_percentage <= ROI_MAX`; `admin`, `token`, `usdc` are not zero address; `token != usdc`. Optionally verify that `token` and `usdc` implement the expected interface. | + +--- + +#### F-04: Use of `.expect()` in Critical Paths + +| Field | Details | +|-------|---------| +| **Severity** | High | +| **Description** | There are 14 uses of `.expect()` in `vault.rs` (lines 146, 156, 162, 177, 221, 237, 245, 253, 262, 297, 314, 343, 361, 367). If the key does not exist (corrupt storage, expired TTL, incorrect migration), the contract panics. | +| **Impact** | Runtime panic; failed transaction; possible gas/fee loss without useful message for the user. | +| **Recommendation** | Replace with `ok_or(ContractError::XxxNotFound)?` in functions that return `Result`. In getters that return values directly, document that they assume initialized state and consider returning `Result` or a safe default where appropriate. | + +--- + +#### F-05: Overflow in `TotalTokensRedeemed` + +| Field | Details | +|-------|---------| +| **Severity** | High | +| **Description** | At line 198: `total_redeemed + token_balance` can overflow `i128` if the accumulated total is very large. | +| **Impact** | Overflow → panic; counter desynchronized from actual state if wrapping arithmetic were used. | +| **Recommendation** | Use `checked_add` and propagate the error with `ContractError::ArithmeticOverflow`. | + +--- + +### MEDIUM + +#### F-06: `preview_claim` vs `claim` Divergence on Negative ROI / Underflow in `roi_amount` + +| Field | Details | +|-------|---------| +| **Severity** | Medium (Scout: CRITICAL for underflow) | +| **Description** | `preview_claim` uses `unwrap_or(0)` for `roi_percentage` (line 291), while `claim` uses `expect` (line 156). With negative ROI, `roi_amount = usdc_amount - token_balance` (line 308) can cause **underflow** if `usdc_amount < token_balance`. Scout: `[CRITICAL] This subtraction operation could underflow`. | +| **Impact** | Underflow → panic; preview can show values inconsistent with actual `claim` result in edge cases. | +| **Recommendation** | Use `checked_sub` for `roi_amount`. Unify storage handling and validate `roi_percentage >= 0` in constructor to avoid negative ROI. | + +--- + +#### F-07: Events Without `#[contractevent]` + +| Field | Details | +|-------|---------| +| **Severity** | Medium | +| **Description** | Events are emitted with `env.events().publish((symbol_short!("claim"),), event)` instead of the `#[contractevent]` macro recommended by the SDK. The escrow contract uses `#[contractevent]` in [`events/handler.rs`](../apps/smart-contracts/contracts/escrow/src/events/handler.rs). | +| **Impact** | Events are not included in the contract spec; indexers and clients lack auto-generated types; reduced interoperability. | +| **Recommendation** | Migrate to `#[contractevent]` with appropriate topics and `#[topic]` on key fields (beneficiary, etc.) for indexing. | + +--- + +#### F-08: No Validation of External Contract Addresses + +| Field | Details | +|-------|---------| +| **Severity** | Medium | +| **Description** | The `token` and `usdc` addresses are stored without verifying they are valid token contracts. A malicious admin or deployment error could point to malicious contracts. | +| **Impact** | Malicious contracts could implement callbacks in `burn`/`transfer` and cause reentrancy or incorrect logic. | +| **Recommendation** | Document that the deployer must use trusted addresses. Optionally: if the ecosystem provides a token registry, validate against it. Assume the admin is trusted. | + +--- + +#### F-09: Authorization Pattern in `availability_for_exchange` + +| Field | Details | +|-------|---------| +| **Severity** | Medium | +| **Description** | The function receives `admin` as a parameter and calls `admin.require_auth()` followed by `admin != stored_admin`. Scout: `[MEDIUM] Usage of admin parameter might be unnecessary` — suggests retrieving admin from storage instead of passing it as a parameter. | +| **Impact** | The pattern is correct. An attacker cannot impersonate the admin without the keys. Best practice is to obtain admin from storage; current design is acceptable but can be improved for clarity. | +| **Recommendation** | Read `stored_admin` first and use `stored_admin.require_auth()` to make explicit that the stored admin is authorized, not the parameter. Remove the `admin` parameter if the signature allows. | + +--- + +#### F-10: Constructor Re-invocation + +| Field | Details | +|-------|---------| +| **Severity** | Medium | +| **Description** | There is no explicit protection against a second invocation of `__constructor`. In Soroban the constructor runs on deployment; re-invocation depends on the upgrade/redeploy model. | +| **Impact** | If the constructor could be called again in some upgrade flow, all state would be overwritten. | +| **Recommendation** | Add an initialization flag (like token-factory's `write_escrow_id`/`write_mint_authority`) that panics if already initialized. Document expected behavior on upgrades. | + +--- + +### LOW + +#### F-11: Use of `i128` for Non-negative Amounts + +| Field | Details | +|-------|---------| +| **Severity** | Low | +| **Description** | Balances, amounts, and ROI are represented with `i128`. Financial amounts are typically non-negative. | +| **Impact** | Allows negative values that are later rejected at runtime; increases surface for sign-related errors. | +| **Recommendation** | Consider newtypes or early validation. Short term: validate `amount >= 0` in constructor for `roi_percentage` and any amount input. | + +--- + +#### F-12: Inconsistency Between `unwrap_or` and `expect` in Getters + +| Field | Details | +|-------|---------| +| **Severity** | Low | +| **Description** | `is_enabled` and `get_total_tokens_redeemed` use `unwrap_or(false)`/`unwrap_or(0)`; other getters use `expect`. Inconsistent behavior when storage is empty. | +| **Impact** | Makes it harder to reason about contract state when data is missing. | +| **Recommendation** | Define a clear policy: either all getters assume initialized state (and use `expect` with clear messages), or they return `Result`/documented default values consistently. | + +--- + +#### F-13: `preview_claim` Does Not Return `Result` + +| Field | Details | +|-------|---------| +| **Severity** | Low | +| **Description** | `preview_claim` returns `ClaimPreview` directly. If storage read fails (e.g. `expect` on token_address or usdc_address), it panics. | +| **Impact** | A read-only function can panic instead of returning a controlled error. | +| **Recommendation** | Change signature to `Result` and propagate errors, or document that it assumes correctly initialized contract. | + +--- + +### INFORMATIONAL + +#### F-14: Additional Tests Recommended + +| Field | Details | +|-------|---------| +| **Severity** | Informational | +| **Description** | Current tests do not explicitly cover: overflow in claim formula; negative ROI in constructor; behavior with expired TTL (when TTL is implemented); reentrancy scenarios (if custom tokens are used). | +| **Impact** | Lower coverage of edge cases and attack scenarios. | +| **Recommendation** | Add tests for overflow, constructor validation, and (when applicable) TTL and reentrancy. | + +--- + +#### F-15: Code Quality and `no_std` + +| Field | Details | +|-------|---------| +| **Severity** | Informational | +| **Description** | The crate uses `#![no_std]` correctly. Tests use `extern crate std` only under `#[cfg(test)]`, which is appropriate. | +| **Impact** | None significant. | +| **Recommendation** | Ensure no dependencies introduce `std` into the production binary. | + +--- + +## Claim Flow Diagram and Failure Points + +```mermaid +flowchart TD + subgraph claim [claim] + A[require_auth beneficiary] --> B[Read enabled, roi, token, usdc] + B --> C[Get token_balance] + C --> D{balance > 0?} + D -->|No| E[Err BeneficiaryHasNoTokensToClaim] + D -->|Yes| F["usdc_amount = balance * (100+roi)/100"] + F --> G{Overflow?} + G -->|Yes| H[Panic] + G -->|No| I{vault_usdc >= usdc_amount?} + I -->|No| J[Err VaultDoesNotHaveEnoughUSDC] + I -->|Yes| K[token_client.burn] + K --> L[usdc_client.transfer] + L --> M[total_redeemed += token_balance] + M --> N{Overflow?} + N -->|Yes| O[Panic] + N -->|No| P[Emit event] + end +``` + +--- + +## Related Open Issues + +No open issues were found in the repository that explicitly address these findings. + +--- + +## Report Acceptance Criteria + +| Criterion | Status | +|----------|--------| +| Each finding includes severity, description, impact, and recommendation | Met | +| Findings classified by severity (Critical / High / Medium / Low / Informational) | Met | +| Indication of related open issues | Met (none existing) | +| Claim flow diagram with failure points | Met | + +--- + +## Scout Findings + +Results from static analysis with [Scout](https://github.com/stellar/scout) (Soroban security linter): + +| Scout Severity | Location | Description | Correlation | +|----------------|----------|-------------|-------------| +| **CRITICAL** | `vault.rs:171` | Overflow/underflow in `token_balance * (100 + roi_percentage) / 100` | F-02 | +| **CRITICAL** | `vault.rs:198` | Overflow in `total_redeemed + token_balance` | F-05 | +| **CRITICAL** | `vault.rs:303` | Overflow/underflow in `preview_claim` formula | F-02 | +| **CRITICAL** | `vault.rs:308` | Underflow in `usdc_amount - token_balance` | F-06 | +| **MEDIUM** | `vault.rs:142` | Unsafe `expect` — Enabled flag | F-04 | +| **MEDIUM** | `vault.rs:152` | Unsafe `expect` — ROI percentage | F-04 | +| **MEDIUM** | `vault.rs:158` | Unsafe `expect` — Token address | F-04 | +| **MEDIUM** | `vault.rs:173` | Unsafe `expect` — USDC address | F-04 | +| **MEDIUM** | `vault.rs:96` | Admin parameter unnecessary; retrieve from storage | F-09 | +| **ENHANCEMENT** | — | Soroban version: 23.1.1 → 25.3.0 | New | + +### Scout Summary + +``` ++----------------+----------+----------+--------+-------+-------------+ +| Crate | Status | Critical | Medium | Minor | Enhancement | ++----------------+----------+----------+--------+-------+-------------+ +| vault_contract | Analyzed | 4 | 5 | 0 | 1 | ++----------------+----------+----------+--------+-------+-------------+ +``` + +### F-17: Outdated Soroban Version (Scout) + +| Field | Details | +|-------|---------| +| **Severity** | Enhancement | +| **Description** | The project uses Soroban 23.1.1. The latest version is 25.3.0. Scout: `#[warn(soroban_version)]`. | +| **Impact** | Possible missing security patches and SDK improvements in the latest versions. | +| **Recommendation** | Update the `soroban-sdk` dependency in the workspace to 25.3.0 (or latest stable). Review changelog for breaking changes. | + +--- + +## References + +- [Soroban Security Best Practices — Stellar Developers](https://developers.stellar.org/docs/smart-contracts/guides/security) +- [Soroban SDK — Storage, TTL and Archiving](https://developers.stellar.org/docs/smart-contracts/guides/storage) +- [Soroban SDK — Authorization and require_auth](https://developers.stellar.org/docs/smart-contracts/guides/authorization) +- [Soroban SDK — Events with #[contractevent]](https://developers.stellar.org/docs/smart-contracts/guides/events) +- [Rust — Checked arithmetic: checked_*, saturating_*, wrapping_*](https://doc.rust-lang.org/std/primitive.i128.html#method.checked_add) +- [OWASP Smart Contract Top 10](https://owasp.org/www-project-smart-contract-top-10/) +- [SWC Registry — Smart Contract Weakness Classification](https://swcregistry.io/)