All notable source-level changes to the EcoTask contracts are documented in this file.
The format is based on Keep a Changelog, and source releases follow Semantic Versioning. 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.
- Admin rotation now uses a two-step handover. The current admin proposes a
successor with
propose_admin, and the proposed address must authenticate and callaccept_adminbefore receiving control. The existingtransfer_adminentry point remains as a compatibility alias for the proposal step. Successful proposals and acceptances emitAdminProposedEventandAdminAcceptedEvent, respectively.
- Fixed documentation for the
oraclefield in theVerificationstruct to explicitly state it records the submitter. - Fixed
setup()tuple unpacking in unit tests inverification.rs.
- [#67] Prevent self-transfer allowance drain in
transfer_fromand fix storage re-fetch TOCTOU inspend_allowance.transfer_fromnow panics with"token: cannot transfer to self"whenfrom == to, preventing spenders from burning an owner's allowance without transferring tokens.spend_allowanceinstorage.rsnow accepts the&Allowancestruct directly instead of re-fetching from persistent storage with.unwrap(), eliminating a potential TOCTOU window.
-
[#52] Unbounded
CreatorTasksVec replaced with indexed persistent storage. Eachcreate_taskcall previously read and rewrote an ever-growingVec<u64>underDataKey::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)u64number of tasks for that creator DataKey::CreatorTask(Address, u64)u64task id at the given 0-based index push_creator_tasknow does one read and two writes regardless of the creator's history size (O(1) per task).get_tasks_by_creator_pagedreads only the indexed entries required for the requested page. The unpagedget_tasks_by_creatoris retained for API compatibility but is deprecated and hard-capped at 50 entries to stay within the Soroban 100-entry footprint budget.
- [#41] Added fuzz / property-based arithmetic tests and max supply boundary tests using proptest.
set_minternow emits aMinterUpdatedEvent(#[contractevent]) on every successful minter rotation, containing theadmin(topic),previous_minter, andnew_minterfields.
set_minternow panics with"token: minter must differ from admin"when the caller attempts to setminter == admin, enforcing role separation.
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.0disables 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
submit_proofvalidatesproof_cidlength, rejecting empty or oversized (> MAX_CID_LENbytes) CID strings before hashing/storage.
Error strings:
engine: proof cid must not be emptyengine: 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.
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) -> i128total_supply() -> i128max_supply() -> i128set_max_supply(caller: Address, max_supply: i128)name() -> Stringsymbol() -> Stringdecimal() -> u32decimals() -> u32set_metadata(caller: Address, name: String, symbol: String, decimal: u32)admin() -> Addresstransfer_admin(current_admin: Address, new_admin: Address)minter() -> Addressset_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) -> i128allowance_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 expiredtoken: allowance not foundtoken: already initializedtoken: amount must be non-negativetoken: amount must be positivetoken: expiration must be in the futuretoken: insufficient allowancetoken: insufficient balancetoken: max supply below current supplytoken: max supply must be positivetoken: new admin must be differenttoken: supply cap exceededtoken: unauthorizedallowance underflowbalance overflowbalance underflowsupply overflowsupply underflow
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) -> u64get_task(task_id: u64) -> Taskget_task_live_status(task_id: u64) -> Taskcomplete_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() -> u64is_task_completed(task_id: u64, user: Address) -> boolget_tasks_by_creator(creator: Address) -> Vec<u64>get_tasks_by_creator_paged(creator: Address, cursor: u32, limit: u32) -> Vec<u64>list_tasks(cursor: u64, limit: u32) -> Vec<Task>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<u64> |
TaskStatus variants are Active, Completed, Expired, and Cancelled.
Error strings:
registry: already completedregistry: already initializedregistry: expiry must be in the futureregistry: max completions must be positiveregistry: max completions reachedregistry: new admin must be differentregistry: new expiry must extend the current oneregistry: reward must be positiveregistry: sponsor revokedregistry: task expiredregistry: task is not activeregistry: task not foundregistry: task not yet expiredregistry: task type must not be emptyregistry: unauthorized
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<Address>is_oracle(addr: Address) -> boolset_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() -> boolsubmit_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) -> Verificationget_verification_by_cid_hash(cid_hash: BytesN<32>) -> Verificationget_pending_verifications_paged(cursor: u32, limit: u32) -> Vec<Verification>get_pending_verifications() -> Vec<Verification>get_verifications_by_user(user: Address, cursor: u32, limit: u32) -> Vec<Verification>total_paid() -> i128transfer_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<Address> |
| Instance | DataKey::MinReward |
i128 |
| Instance | DataKey::MaxReward |
i128 |
| Instance | DataKey::VerificationList |
Vec<VerificationKey> |
| 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<u64> |
VerificationStatus variants are Pending, Approved, Rejected, and
Disputed. CID index entries are extended to 4,096 ledgers when written.
Error strings:
engine: already initializedengine: cannot remove the last oracleengine: contract is pausedengine: max reward must be >= min rewardengine: min reward must be positiveengine: new admin must be differentengine: not foundengine: oracle already registeredengine: oracle must be different from adminengine: oracle not registeredengine: proof already submittedengine: proof cid already submittedengine: reward amount must be positiveengine: reward below minimumengine: reward exceeds maximumengine: reward exceeds task budgetengine: task has expiredengine: task is not activeengine: unauthorizedengine: verification is not disputableengine: verification is not disputedengine: verification is not pendingengine: verification not foundtotal_paid overflow