diff --git a/README.md b/README.md index b7fbb11..d2ad40c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,28 @@ Horizon proof exists). --- +### ๐Ÿ”„ Upgrades & Governance + +* Single-admin governance โ€” one address (set at `initialize`) controls upgrades, migrations, and feature flags +* Contract version stored in persistent ledger โ€” survives ledger entry expiry +* Feature flags allow toggling behaviours without a full WASM upgrade +* `ContractInitialized` and `ContractUpgraded` events let indexers detect which contract version produced any given document event + +--- + +### ๐Ÿ“ฆ Batch Operations + +* `batch_register_documents` โ€” register up to 20 documents in one transaction +* `batch_revoke_documents` โ€” revoke up to 20 documents in one transaction + +**Atomicity:** All documents succeed or none are written. If any item in the batch fails (e.g. duplicate hash, wrong issuer, already revoked), the entire call returns an error and no state is changed. + +**Batch size limit:** Maximum 20 documents per call. Exceeding this returns `BatchTooLarge` (error code 7). Empty batches return `BatchEmpty` (error code 8). + +**Fee implications:** A single transaction covers the entire batch regardless of size, making bulk operations significantly cheaper than individual calls. For best results, pre-validate document uniqueness and existence client-side before submitting to avoid wasted transaction fees on partial failures. + +--- + ## ๐Ÿง  How It Works 1. Document is hashed (SHA256) @@ -142,6 +164,68 @@ soroban contract deploy \ --- +### Initialize After Deployment + +After deploying, call `initialize` to set the admin address and record version 1 on-chain: + +```bash +soroban contract invoke \ + --id \ + --source \ + --network testnet \ + -- initialize \ + --admin +``` + +--- + +### Upgrade Procedure + +1. **Build the new WASM** and upload it to the ledger: + +```bash +cargo build --target wasm32-unknown-unknown --release +soroban contract install \ + --wasm target/wasm32-unknown-unknown/release/proofstell_contract.wasm \ + --network testnet +# Note the returned WASM hash +``` + +2. **Call `upgrade`** with the new WASM hash: + +```bash +soroban contract invoke \ + --id \ + --source \ + --network testnet \ + -- upgrade \ + --admin \ + --new_wasm_hash +``` + +3. **Call `migrate`** to apply any data transformations and bump the version: + +```bash +soroban contract invoke \ + --id \ + --source \ + --network testnet \ + -- migrate \ + --admin +``` + +### Rollback Plan + +Soroban contract upgrades are irreversible on-chain โ€” there is no undo. To roll back: + +1. Keep the previous WASM hash recorded before upgrading. +2. If the new version is broken, call `upgrade` again with the old WASM hash. +3. If the migration mutated storage in an incompatible way, a compensating migration must be written into the rolled-back WASM. + +**Recommendation:** always test upgrades on testnet before applying to mainnet. See [docs/UPGRADE_GOVERNANCE.md](docs/UPGRADE_GOVERNANCE.md) for the full decision process. + +--- + ## ๐Ÿงช Testing ```bash diff --git a/docs/UPGRADE_GOVERNANCE.md b/docs/UPGRADE_GOVERNANCE.md new file mode 100644 index 0000000..2aec578 --- /dev/null +++ b/docs/UPGRADE_GOVERNANCE.md @@ -0,0 +1,95 @@ +# ProofStell Contract Upgrade Governance + +This document defines the decision process for upgrading the ProofStell Soroban contract, classifying change types, and authorizing governance actions. + +--- + +## Change Classification + +### Non-Breaking Changes (Safe to deploy without client migration) + +- Adding new contract entry points +- Adding new event types +- Adding new error codes +- Adding new feature flags +- Internal refactors that preserve existing function signatures and storage layout + +### Breaking Changes (Require coordinated client migration) + +- Renaming or removing existing entry points +- Changing the type or field order of `DocumentRecord`, `DocumentInfo`, or any `#[contracttype]` +- Changing existing event topic strings or data fields +- Renumbering existing error codes +- Changing the storage key layout (`DataKey` variants) + +Any breaking change must follow the full upgrade process below. + +--- + +## Governance Roles + +| Role | Responsibility | +|------|----------------| +| **Admin** | The address stored at `DataKey::Admin`. Authorized to call `upgrade`, `migrate`, and `set_feature_flag`. | +| **Maintainers** | Open-source contributors with merge rights on this repository. Propose and review changes. | +| **Community** | GitHub Issues and Discussions โ€” the venue for raising concerns before any upgrade. | + +--- + +## Upgrade Decision Process + +### For Non-Breaking Changes + +1. Open a PR with the change. +2. At least one maintainer reviews and approves. +3. Merge to `main`. Deploy at the next release window. + +### For Breaking Changes + +1. **Proposal** โ€” Open a GitHub Issue labelled `breaking-change` describing: + - What is changing and why + - Which clients or indexers are affected + - The proposed migration window (minimum 2 weeks for mainnet) +2. **Discussion window** โ€” At least 7 days for community feedback. +3. **PR review** โ€” Two maintainer approvals required. +4. **Testnet dry-run** โ€” Run the full upgrade procedure on testnet and verify: + - All existing tests pass against the new WASM + - `migrate` completes without error + - Document verification still works for pre-migration records +5. **Mainnet upgrade** โ€” Admin calls `upgrade` then `migrate` on mainnet. +6. **Client migration notice** โ€” Post a GitHub Discussion and update client documentation. + +--- + +## Emergency Upgrade Procedure + +For critical security fixes that cannot wait for the standard process: + +1. Admin calls `upgrade` with the patched WASM on testnet immediately. +2. Verify no data corruption with a smoke test. +3. Admin calls `upgrade` on mainnet. +4. Open a GitHub Issue retroactively documenting the emergency change within 24 hours. +5. A follow-up PR with tests and documentation is required within 72 hours. + +--- + +## Incompatible Storage Layout Changes + +If a new version requires a `DocumentRecord` field to be added or removed: + +1. The old storage key format must be read and transformed in `migrate`. +2. A new `DataKey` variant should be introduced for the new format; do not reuse old keys. +3. After migration completes, the old keys may be cleaned up in a subsequent release. +4. Tests must cover reading old-format records and verifying they are correctly transformed. + +--- + +## Transferring Admin + +To transfer governance to a new address, the current admin must call `initialize` is not re-callable โ€” the admin address can only change by deploying a new contract or by the current admin upgrading to a contract version that supports admin transfer. Add an `transfer_admin(env, admin, new_admin)` entry point in a future version if this is needed. + +--- + +## Audit Requirements + +Before any mainnet upgrade that introduces new entry points or modifies existing logic, a security review is strongly recommended. See the [security review checklist](.github/SECURITY.md) if available. diff --git a/docs/superpowers/specs/2026-06-21-batch-operations-design.md b/docs/superpowers/specs/2026-06-21-batch-operations-design.md new file mode 100644 index 0000000..25051da --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-batch-operations-design.md @@ -0,0 +1,121 @@ +--- +name: batch-operations-design +description: Design spec for batch_register_documents and batch_revoke_documents on the ProofStell Soroban contract +metadata: + type: project +--- + +# Batch Operations Design + +**Issue:** #30 โ€” Implement batch operations for bulk registration and revocation + +## Problem + +The contract only exposes single-document `register_document` and `revoke_document` entry points. Registering N documents requires N separate transactions, multiplying fees, latency, and the risk of partial on-chain state. + +## Approach + +Strict fail-fast atomicity. Both batch functions validate the full input upfront, then iterate โ€” returning an error on the first failure. Because Soroban aborts and rolls back the entire transaction on any contract error, no partial state is ever written. This is the simplest model and exactly matches "all succeed or all fail." + +Batch size is capped at **20 documents** to stay within Soroban instruction and storage operation limits. Empty batches are rejected to avoid ambiguous success responses. + +## Data Model Changes + +### New contracttype โ€” `DocumentInfo` + +```rust +#[contracttype] +pub struct DocumentInfo { + pub owner: Address, + pub document_hash: BytesN<32>, +} +``` + +Used as the element type for `batch_register_documents`. Groups owner and hash together so the vector is a single typed parameter (Soroban does not support tuple parameters in contract function signatures). + +### New error codes + +| Variant | Code | Description | +|----------------|------|------------------------------------------| +| `BatchTooLarge`| 7 | Input vector exceeds the 20-item limit | +| `BatchEmpty` | 8 | Input vector is empty | + +These extend `ContractError` without renumbering existing codes. + +## New Entry Points + +### `batch_register_documents` + +```rust +pub fn batch_register_documents( + env: Env, + issuer: Address, + documents: Vec, +) -> Result, ContractError> +``` + +- Calls `issuer.require_auth()` once for the whole batch. +- Returns `BatchEmpty` if `documents.len() == 0`. +- Returns `BatchTooLarge` if `documents.len() > 20`. +- Iterates in order; on any error (e.g. `AlreadyRegistered`) returns immediately. +- On full success: persists all records, emits one `DocumentRegistered` event per document, returns `Vec`. + +### `batch_revoke_documents` + +```rust +pub fn batch_revoke_documents( + env: Env, + issuer: Address, + document_hashes: Vec>, +) -> Result, ContractError> +``` + +- Calls `issuer.require_auth()` once for the whole batch. +- Returns `BatchEmpty` if `document_hashes.len() == 0`. +- Returns `BatchTooLarge` if `document_hashes.len() > 20`. +- Iterates in order; on any error (`DocumentNotFound`, `OnlyIssuerCanRevoke`, `AlreadyRevoked`) returns immediately. +- On full success: updates all records to `Revoked`, emits one `DocumentRevoked` event per document, returns `Vec`. + +## Error Handling + +All existing per-document errors (`AlreadyRegistered`, `DocumentNotFound`, `OnlyIssuerCanRevoke`, `AlreadyRevoked`) propagate unchanged from within the batch loop. The caller receives the first error that occurred; no document index is embedded in the error (Soroban error codes are `u32` scalars โ€” no payload). Clients should validate uniqueness and existence before submitting large batches to avoid wasted fees. + +## Events + +One event is emitted per document, identical in structure to the single-document events: +- `DocumentRegistered { issuer, owner, document_hash }` per registered document. +- `DocumentRevoked { issuer, document_hash }` per revoked document. + +No batch-level event is emitted โ€” downstream consumers already process individual document events and do not need a separate batch envelope. + +## Tests + +Located inline in `src/lib.rs` (matching existing test structure): + +| Test | Scenario | +|------|----------| +| `batch_register_all_succeed` | 3 distinct documents register successfully, all returned Active | +| `batch_register_fails_on_duplicate` | 1 of 3 is already registered โ€” entire batch fails, others not stored | +| `batch_register_rejects_empty` | empty vec โ†’ `BatchEmpty` | +| `batch_register_rejects_oversized` | 21 items โ†’ `BatchTooLarge` | +| `batch_revoke_all_succeed` | 3 registered documents revoked, all returned Revoked | +| `batch_revoke_fails_on_missing` | 1 of 3 not registered โ€” entire batch fails | +| `batch_revoke_fails_on_wrong_issuer` | different issuer โ†’ `OnlyIssuerCanRevoke`, nothing revoked | +| `batch_revoke_fails_on_already_revoked` | 1 already revoked โ†’ `AlreadyRevoked`, nothing else revoked | +| `batch_revoke_rejects_empty` | empty vec โ†’ `BatchEmpty` | +| `batch_revoke_rejects_oversized` | 21 items โ†’ `BatchTooLarge` | + +## README Changes + +Add a **Batch Operations** section documenting: +- Function signatures and parameter types. +- The 20-document limit and the rationale (Soroban instruction budget). +- Atomicity guarantee and fee implications (one transaction regardless of batch size). +- Recommendation to pre-validate uniqueness/existence client-side. + +## Files to Change + +| File | Change | +|------|--------| +| `src/lib.rs` | Add `DocumentInfo`, `BatchTooLarge`/`BatchEmpty` errors, `batch_register_documents`, `batch_revoke_documents`, and all tests | +| `README.md` | Add Batch Operations section | diff --git a/docs/superpowers/specs/2026-06-21-upgrade-governance-design.md b/docs/superpowers/specs/2026-06-21-upgrade-governance-design.md new file mode 100644 index 0000000..0131578 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-upgrade-governance-design.md @@ -0,0 +1,134 @@ +--- +name: upgrade-governance-design +description: Design spec for contract upgrade mechanism, versioning, migration path, and governance for ProofStell Soroban contract (issue #29) +metadata: + type: project +--- + +# Contract Upgrade & Migration Governance Design + +**Issue:** #29 โ€” Add contract upgrade and migration path governance + +## Problem + +The ProofStell contract has no upgrade mechanism. Future changes to `DocumentRecord`, error types, or event formats would require redeploying to a new address, losing all historical state and forcing clients to migrate manually. There is no version signal for indexers to detect which contract version produced a given event. + +## Approach + +Single-admin governance: one `Address` set at initialization controls upgrades, migrations, and feature flags. This is the standard Soroban pattern โ€” simple, auditable, and appropriate for an open-source contract at this maturity level. Multi-sig governance can be layered on top by pointing the admin at a multisig contract. + +## Storage Layout + +Three new `DataKey` variants added alongside the existing `Document(BytesN<32>)`: + +| Key | Type | Description | +|-----|------|-------------| +| `DataKey::Admin` | `Address` | The governance admin address | +| `DataKey::Version` | `u32` | Current contract version (absent = 0 = pre-versioned) | +| `DataKey::FeatureFlag(Symbol)` | `bool` | Named feature toggles | + +All stored in **persistent** storage to survive ledger expiry. + +## New Error Codes + +| Variant | Code | Description | +|---------|------|-------------| +| `AlreadyInitialized` | 9 | `initialize` called when version key already exists | +| `NotInitialized` | 10 | Governance call before `initialize` | +| `Unauthorized` | 11 | Caller is not the stored admin | +| `MigrationNotNeeded` | 12 | `migrate` called when already on latest version | + +## New Entry Points + +### `initialize(env, admin: Address) -> Result<(), ContractError>` +- Requires admin auth. +- Fails with `AlreadyInitialized` if `DataKey::Version` already exists in storage. +- Stores admin address and sets version to 1. +- Emits `ContractInitialized { admin, version: 1 }`. + +### `upgrade(env, admin: Address, new_wasm_hash: BytesN<32>) -> Result<(), ContractError>` +- Requires admin auth. Validates against stored admin. +- Fails with `NotInitialized` if no version key exists. +- Records old version, calls `env.deployer().update_current_contract_wasm(new_wasm_hash)`. +- Emits `ContractUpgraded { admin, old_version, new_version: old_version }`. +- Note: version number is NOT auto-incremented on upgrade โ€” `migrate` does that. This separates code deployment from data migration. + +### `migrate(env, admin: Address) -> Result` +- Requires admin auth. Validates against stored admin or accepts admin arg when version=0 (bootstrapping). +- Detects current version from storage (absent = 0). +- Applies the appropriate migration step: + - `0 โ†’ 1`: Pre-versioned state โ€” stores admin and bumps version to 1. (Handles existing deployments upgraded via Stellar account authority.) + - `1 โ†’ current`: No-op for now; placeholder for future version transitions. + - Already at latest: returns `MigrationNotNeeded`. +- Emits no extra event (the version bump is visible via `get_version`). +- Returns the new version number. + +### `get_version(env) -> u32` +- Returns the stored version, or 0 if not initialized. Never fails. + +### `get_admin(env) -> Option
` +- Returns the stored admin address, or `None` if not initialized. + +### `set_feature_flag(env, admin: Address, flag: Symbol, enabled: bool) -> Result<(), ContractError>` +- Admin-only. Stores `DataKey::FeatureFlag(flag) โ†’ enabled`. + +### `get_feature_flag(env, flag: Symbol) -> bool` +- Returns the stored value, or `false` if not set. + +## New Events + +```rust +#[contractevent(topics = ["init"], data_format = "vec")] +pub struct ContractInitialized { + #[topic] + pub admin: Address, + pub version: u32, +} + +#[contractevent(topics = ["upgrade"], data_format = "vec")] +pub struct ContractUpgraded { + #[topic] + pub admin: Address, + pub old_version: u32, + pub new_version: u32, +} +``` + +Indexers use these events as version markers. Events between two `ContractUpgraded` events were produced by the same contract version โ€” no per-event version field needed on existing events. + +## Tests + +| Test | Scenario | +|------|----------| +| `initialize_sets_admin_and_version` | Fresh init โ†’ admin stored, version=1 | +| `double_initialize_fails` | Second init โ†’ `AlreadyInitialized` | +| `get_version_returns_zero_before_init` | Uninitialized โ†’ `get_version` returns 0 | +| `get_admin_returns_none_before_init` | Uninitialized โ†’ `get_admin` returns None | +| `non_admin_cannot_upgrade` | Wrong address โ†’ `Unauthorized` | +| `upgrade_requires_initialization` | No init โ†’ `NotInitialized` | +| `migrate_v0_to_v1_sets_versioning` | Pre-versioned state โ†’ migrate โ†’ version=1 | +| `migrate_already_current_fails` | Initialized at v1 โ†’ migrate โ†’ `MigrationNotNeeded` | +| `admin_can_set_and_get_feature_flag` | Set flag โ†’ get flag returns true | +| `get_feature_flag_returns_false_for_unset` | Unset flag โ†’ false | +| `non_admin_cannot_set_feature_flag` | Wrong address โ†’ `Unauthorized` | +| `documents_still_work_after_migration` | Register doc โ†’ migrate โ†’ doc still verifiable | + +## Documentation + +### README additions +- Upgrade procedure (upload new WASM hash โ†’ call `upgrade` โ†’ call `migrate`) +- Rollback plan (Soroban upgrades are irreversible on-chain; rollback = re-upgrade to old WASM hash) +- Environment/deployment notes + +### New file: `docs/UPGRADE_GOVERNANCE.md` +- Decision process for breaking vs non-breaking changes +- Who can propose, review, and authorize upgrades +- Emergency upgrade procedure + +## Files Changed + +| File | Change | +|------|--------| +| `src/lib.rs` | New DataKey variants, error codes, events, and 7 new entry points + 12 tests | +| `README.md` | Upgrade & Migration section | +| `docs/UPGRADE_GOVERNANCE.md` | New governance document | diff --git a/src/lib.rs b/src/lib.rs index 5961112..fba988f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ // The service-side modules require std and are only available on non-WASM targets. #[cfg(not(target_arch = "wasm32"))] +#[macro_use] extern crate std; #[cfg(not(target_arch = "wasm32"))] @@ -22,6 +23,7 @@ pub mod rate_limit; pub mod stellar; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, Address, BytesN, Env, + Symbol, Vec, }; #[contracttype] @@ -43,8 +45,22 @@ pub struct DocumentRecord { #[contracttype] pub enum DataKey { Document(BytesN<32>), + Admin, + Version, + FeatureFlag(Symbol), } +pub const CONTRACT_VERSION: u32 = 1; + +#[contracttype] +#[derive(Clone, Debug)] +pub struct DocumentInfo { + pub owner: Address, + pub document_hash: BytesN<32>, +} + +pub const MAX_BATCH_SIZE: u32 = 20; + /// Enumeration of all error conditions that can occur within the ProofStell contract. /// /// Each variant maps to a unique numeric code for Soroban client interoperability, @@ -59,6 +75,12 @@ pub enum DataKey { /// | `AlreadyRevoked` | 4 | The document has already been revoked | /// | `InvalidOwner` | 5 | The provided owner address is not valid for this op | /// | `InvalidIssuer` | 6 | The provided issuer address is not valid for this op | +/// | `BatchTooLarge` | 7 | Batch exceeds the 20-document limit | +/// | `BatchEmpty` | 8 | Batch input is empty | +/// | `AlreadyInitialized` | 9 | `initialize` called when contract is already initialized | +/// | `NotInitialized` | 10 | Governance call before `initialize` | +/// | `Unauthorized` | 11 | Caller is not the stored admin | +/// | `MigrationNotNeeded` | 12 | Contract is already at the latest version | #[contracterror] #[derive(Clone, Debug, Eq, PartialEq)] pub enum ContractError { @@ -74,6 +96,18 @@ pub enum ContractError { InvalidOwner = 5, /// The provided issuer address failed validation. Code: 6 InvalidIssuer = 6, + /// The batch exceeds the maximum allowed size (20). Code: 7 + BatchTooLarge = 7, + /// The batch is empty. Code: 8 + BatchEmpty = 8, + /// The contract has already been initialized. Code: 9 + AlreadyInitialized = 9, + /// The contract has not been initialized yet. Code: 10 + NotInitialized = 10, + /// The caller is not the authorized admin. Code: 11 + Unauthorized = 11, + /// The contract is already at the latest version; no migration is needed. Code: 12 + MigrationNotNeeded = 12, } #[contractevent(topics = ["register"], data_format = "vec")] @@ -91,6 +125,21 @@ pub struct DocumentRevoked { pub document_hash: BytesN<32>, } +#[contractevent(topics = ["init"], data_format = "vec")] +pub struct ContractInitialized { + #[topic] + pub admin: Address, + pub version: u32, +} + +#[contractevent(topics = ["upgrade"], data_format = "vec")] +pub struct ContractUpgraded { + #[topic] + pub admin: Address, + pub old_version: u32, + pub new_version: u32, +} + #[contract] pub struct ProofStellContract; @@ -256,6 +305,312 @@ impl ProofStellContract { Ok(record) } + + /// Registers multiple documents in a single atomic transaction. + /// + /// The issuer authorizes once for the entire batch. All documents must be + /// valid or the entire batch fails โ€” no partial state is written. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `issuer` - Address of the entity registering the documents (must authorize) + /// * `documents` - Vector of [`DocumentInfo`] (owner + hash pairs), max 20 items + /// + /// # Returns + /// A vector of newly created [`DocumentRecord`]s, all with `DocumentStatus::Active` + /// + /// # Errors + /// * [`ContractError::BatchEmpty`] โ€” if the vector is empty + /// * [`ContractError::BatchTooLarge`] โ€” if the vector exceeds 20 items + /// * [`ContractError::AlreadyRegistered`] โ€” if any document hash is already registered + pub fn batch_register_documents( + env: Env, + issuer: Address, + documents: Vec, + ) -> Result, ContractError> { + issuer.require_auth(); + + if documents.is_empty() { + return Err(ContractError::BatchEmpty); + } + if documents.len() > MAX_BATCH_SIZE { + return Err(ContractError::BatchTooLarge); + } + + let mut records = Vec::new(&env); + + for doc in documents.iter() { + let key = DataKey::Document(doc.document_hash.clone()); + + if env.storage().persistent().has(&key) { + return Err(ContractError::AlreadyRegistered); + } + + let record = DocumentRecord { + issuer: issuer.clone(), + owner: doc.owner.clone(), + timestamp: env.ledger().timestamp(), + status: DocumentStatus::Active, + }; + + env.storage().persistent().set(&key, &record); + DocumentRegistered { + issuer: issuer.clone(), + owner: doc.owner.clone(), + document_hash: doc.document_hash.clone(), + } + .publish(&env); + + records.push_back(record); + } + + Ok(records) + } + + /// Revokes multiple documents in a single atomic transaction. + /// + /// The issuer authorizes once for the entire batch. All documents must be + /// revocable or the entire batch fails โ€” no partial state is written. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `issuer` - Address of the original issuer (must authorize) + /// * `document_hashes` - Vector of 32-byte document hashes to revoke, max 20 items + /// + /// # Returns + /// A vector of updated [`DocumentRecord`]s, all with `DocumentStatus::Revoked` + /// + /// # Errors + /// * [`ContractError::BatchEmpty`] โ€” if the vector is empty + /// * [`ContractError::BatchTooLarge`] โ€” if the vector exceeds 20 items + /// * [`ContractError::DocumentNotFound`] โ€” if any hash has no record + /// * [`ContractError::OnlyIssuerCanRevoke`] โ€” if the caller is not the original issuer of any document + /// * [`ContractError::AlreadyRevoked`] โ€” if any document is already revoked + pub fn batch_revoke_documents( + env: Env, + issuer: Address, + document_hashes: Vec>, + ) -> Result, ContractError> { + issuer.require_auth(); + + if document_hashes.is_empty() { + return Err(ContractError::BatchEmpty); + } + if document_hashes.len() > MAX_BATCH_SIZE { + return Err(ContractError::BatchTooLarge); + } + + let mut records = Vec::new(&env); + + for document_hash in document_hashes.iter() { + let key = DataKey::Document(document_hash.clone()); + + let mut record: DocumentRecord = env + .storage() + .persistent() + .get(&key) + .ok_or(ContractError::DocumentNotFound)?; + + if record.issuer != issuer { + return Err(ContractError::OnlyIssuerCanRevoke); + } + + if record.status == DocumentStatus::Revoked { + return Err(ContractError::AlreadyRevoked); + } + + record.status = DocumentStatus::Revoked; + env.storage().persistent().set(&key, &record); + DocumentRevoked { + issuer: issuer.clone(), + document_hash: document_hash.clone(), + } + .publish(&env); + + records.push_back(record); + } + + Ok(records) + } + + /// Initializes the contract for a new deployment, setting the admin and contract version. + /// + /// Must be called once after deployment. Subsequent calls return `AlreadyInitialized`. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `admin` - Address that will govern future upgrades and migrations (must authorize) + /// + /// # Errors + /// * [`ContractError::AlreadyInitialized`] โ€” if the contract has already been initialized + pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + + if env.storage().persistent().has(&DataKey::Version) { + return Err(ContractError::AlreadyInitialized); + } + + env.storage().persistent().set(&DataKey::Admin, &admin); + env.storage() + .persistent() + .set(&DataKey::Version, &CONTRACT_VERSION); + + ContractInitialized { + admin, + version: CONTRACT_VERSION, + } + .publish(&env); + + Ok(()) + } + + /// Returns the current contract version stored in ledger (0 if not initialized). + pub fn get_version(env: Env) -> u32 { + env.storage() + .persistent() + .get::(&DataKey::Version) + .unwrap_or(0) + } + + /// Returns the stored admin address, or `None` if the contract is not yet initialized. + pub fn get_admin(env: Env) -> Option
{ + env.storage().persistent().get::(&DataKey::Admin) + } + + /// Upgrades the contract WASM to the given hash. + /// + /// The new WASM must already be uploaded to the Stellar ledger. After this call, + /// subsequent invocations will execute the new WASM. Call `migrate` afterwards + /// to apply any data transformations required by the new version. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `admin` - The governance admin address (must authorize and match stored admin) + /// * `new_wasm_hash` - 32-byte hash of the uploaded WASM binary + /// + /// # Errors + /// * [`ContractError::NotInitialized`] โ€” if the contract has not been initialized + /// * [`ContractError::Unauthorized`] โ€” if the caller is not the stored admin + pub fn upgrade( + env: Env, + admin: Address, + new_wasm_hash: BytesN<32>, + ) -> Result<(), ContractError> { + admin.require_auth(); + + let stored_admin = env + .storage() + .persistent() + .get::(&DataKey::Admin) + .ok_or(ContractError::NotInitialized)?; + + if stored_admin != admin { + return Err(ContractError::Unauthorized); + } + + let old_version = env + .storage() + .persistent() + .get::(&DataKey::Version) + .unwrap_or(0); + + env.deployer().update_current_contract_wasm(new_wasm_hash); + + ContractUpgraded { + admin, + old_version, + new_version: old_version, + } + .publish(&env); + + Ok(()) + } + + /// Migrates contract state to the current version. + /// + /// Detects the stored version and applies the appropriate data transformation: + /// - Version 0 (pre-versioning): stores the admin and sets version to 1. This handles + /// existing deployments that were upgraded without a prior `initialize` call. + /// - Version 1 (current): already up to date; returns `MigrationNotNeeded`. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `admin` - Must authorize; used as admin address when migrating from version 0 + /// + /// # Returns + /// The version number after migration + /// + /// # Errors + /// * [`ContractError::Unauthorized`] โ€” if caller does not match stored admin (v1+) + /// * [`ContractError::MigrationNotNeeded`] โ€” if already at the latest version + pub fn migrate(env: Env, admin: Address) -> Result { + admin.require_auth(); + + let current_version = env + .storage() + .persistent() + .get::(&DataKey::Version) + .unwrap_or(0); + + match current_version { + 0 => { + // Pre-versioned state: bootstrap versioning without requiring prior initialize. + // The admin arg becomes the stored admin for all future governance calls. + env.storage().persistent().set(&DataKey::Admin, &admin); + env.storage() + .persistent() + .set(&DataKey::Version, &CONTRACT_VERSION); + Ok(CONTRACT_VERSION) + } + _ => Err(ContractError::MigrationNotNeeded), + } + } + + /// Sets a named feature flag. + /// + /// Feature flags allow toggling contract behaviours without a full WASM upgrade. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `admin` - The governance admin address (must authorize and match stored admin) + /// * `flag` - The flag name as a `Symbol` + /// * `enabled` - `true` to enable, `false` to disable + /// + /// # Errors + /// * [`ContractError::NotInitialized`] โ€” if the contract has not been initialized + /// * [`ContractError::Unauthorized`] โ€” if the caller is not the stored admin + pub fn set_feature_flag( + env: Env, + admin: Address, + flag: Symbol, + enabled: bool, + ) -> Result<(), ContractError> { + admin.require_auth(); + + let stored_admin = env + .storage() + .persistent() + .get::(&DataKey::Admin) + .ok_or(ContractError::NotInitialized)?; + + if stored_admin != admin { + return Err(ContractError::Unauthorized); + } + + env.storage() + .persistent() + .set(&DataKey::FeatureFlag(flag), &enabled); + + Ok(()) + } + + /// Returns the value of a named feature flag (`false` if not set). + pub fn get_feature_flag(env: Env, flag: Symbol) -> bool { + env.storage() + .persistent() + .get::(&DataKey::FeatureFlag(flag)) + .unwrap_or(false) + } } #[cfg(test)] @@ -419,4 +774,358 @@ mod tests { assert!(client.document_exists(&document_hash)); } + + // --- batch_register_documents --- + + #[test] + fn batch_register_all_succeed() { + let (env, client, issuer, owner, _) = setup(); + + let docs = soroban_sdk::vec![ + &env, + DocumentInfo { owner: owner.clone(), document_hash: BytesN::from_array(&env, &[1; 32]) }, + DocumentInfo { owner: owner.clone(), document_hash: BytesN::from_array(&env, &[2; 32]) }, + DocumentInfo { owner: owner.clone(), document_hash: BytesN::from_array(&env, &[3; 32]) }, + ]; + + let records = client.batch_register_documents(&issuer, &docs); + + assert_eq!(records.len(), 3); + for record in records.iter() { + assert_eq!(record.status, DocumentStatus::Active); + assert_eq!(record.issuer, issuer); + } + } + + #[test] + fn batch_register_fails_on_duplicate() { + let (env, client, issuer, owner, _) = setup(); + + let hash1 = BytesN::from_array(&env, &[1; 32]); + let hash2 = BytesN::from_array(&env, &[2; 32]); + client.register_document(&issuer, &owner, &hash1); + + let docs = soroban_sdk::vec![ + &env, + DocumentInfo { owner: owner.clone(), document_hash: BytesN::from_array(&env, &[3; 32]) }, + DocumentInfo { owner: owner.clone(), document_hash: hash1.clone() }, + DocumentInfo { owner: owner.clone(), document_hash: hash2.clone() }, + ]; + + let err = client + .try_batch_register_documents(&issuer, &docs) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::AlreadyRegistered); + // hash2 must not have been stored (batch was atomic) + assert!(!client.document_exists(&hash2)); + } + + #[test] + fn batch_register_rejects_empty() { + let (env, client, issuer, _, _) = setup(); + + let docs: soroban_sdk::Vec = soroban_sdk::vec![&env]; + let err = client + .try_batch_register_documents(&issuer, &docs) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::BatchEmpty); + } + + #[test] + fn batch_register_rejects_oversized() { + let (env, client, issuer, owner, _) = setup(); + + let mut docs = soroban_sdk::vec![&env]; + for i in 0..21u8 { + docs.push_back(DocumentInfo { + owner: owner.clone(), + document_hash: BytesN::from_array(&env, &[i; 32]), + }); + } + + let err = client + .try_batch_register_documents(&issuer, &docs) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::BatchTooLarge); + } + + // --- batch_revoke_documents --- + + #[test] + fn batch_revoke_all_succeed() { + let (env, client, issuer, owner, _) = setup(); + + let hashes = [ + BytesN::from_array(&env, &[1; 32]), + BytesN::from_array(&env, &[2; 32]), + BytesN::from_array(&env, &[3; 32]), + ]; + for h in &hashes { + client.register_document(&issuer, &owner, h); + } + + let hash_vec = soroban_sdk::vec![&env, hashes[0].clone(), hashes[1].clone(), hashes[2].clone()]; + let records = client.batch_revoke_documents(&issuer, &hash_vec); + + assert_eq!(records.len(), 3); + for record in records.iter() { + assert_eq!(record.status, DocumentStatus::Revoked); + } + } + + #[test] + fn batch_revoke_fails_on_missing() { + let (env, client, issuer, owner, _) = setup(); + + let hash1 = BytesN::from_array(&env, &[1; 32]); + let hash_missing = BytesN::from_array(&env, &[99; 32]); + client.register_document(&issuer, &owner, &hash1); + + let hash_vec = soroban_sdk::vec![&env, hash1.clone(), hash_missing]; + let err = client + .try_batch_revoke_documents(&issuer, &hash_vec) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::DocumentNotFound); + // hash1 must still be Active (batch was atomic) + assert_eq!(client.get_document_status(&hash1), DocumentStatus::Active); + } + + #[test] + fn batch_revoke_fails_on_wrong_issuer() { + let (env, client, issuer, owner, _) = setup(); + + let hash1 = BytesN::from_array(&env, &[1; 32]); + let hash2 = BytesN::from_array(&env, &[2; 32]); + client.register_document(&issuer, &owner, &hash1); + client.register_document(&issuer, &owner, &hash2); + + let other = Address::generate(&env); + let hash_vec = soroban_sdk::vec![&env, hash1.clone(), hash2.clone()]; + let err = client + .try_batch_revoke_documents(&other, &hash_vec) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::OnlyIssuerCanRevoke); + assert_eq!(client.get_document_status(&hash1), DocumentStatus::Active); + } + + #[test] + fn batch_revoke_fails_on_already_revoked() { + let (env, client, issuer, owner, _) = setup(); + + let hash1 = BytesN::from_array(&env, &[1; 32]); + let hash2 = BytesN::from_array(&env, &[2; 32]); + client.register_document(&issuer, &owner, &hash1); + client.register_document(&issuer, &owner, &hash2); + client.revoke_document(&issuer, &hash1); + + let hash_vec = soroban_sdk::vec![&env, hash1, hash2.clone()]; + let err = client + .try_batch_revoke_documents(&issuer, &hash_vec) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::AlreadyRevoked); + assert_eq!(client.get_document_status(&hash2), DocumentStatus::Active); + } + + #[test] + fn batch_revoke_rejects_empty() { + let (_env, client, issuer, _, _) = setup(); + + let hash_vec: soroban_sdk::Vec> = soroban_sdk::vec![&_env]; + let err = client + .try_batch_revoke_documents(&issuer, &hash_vec) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::BatchEmpty); + } + + #[test] + fn batch_revoke_rejects_oversized() { + let (env, client, issuer, owner, _) = setup(); + + let mut hash_vec: soroban_sdk::Vec> = soroban_sdk::vec![&env]; + for i in 0..21u8 { + let h = BytesN::from_array(&env, &[i; 32]); + client.register_document(&issuer, &owner, &h); + hash_vec.push_back(h); + } + + let err = client + .try_batch_revoke_documents(&issuer, &hash_vec) + .unwrap_err() + .unwrap(); + + assert_eq!(err, ContractError::BatchTooLarge); + } + + // --- initialize / get_version / get_admin --- + + #[test] + fn initialize_sets_admin_and_version() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + + client.initialize(&admin); + + assert_eq!(client.get_version(), 1); + assert_eq!(client.get_admin(), Some(admin)); + } + + #[test] + fn get_version_returns_zero_before_init() { + let (_env, client, _, _, _) = setup(); + assert_eq!(client.get_version(), 0); + } + + #[test] + fn get_admin_returns_none_before_init() { + let (_env, client, _, _, _) = setup(); + assert_eq!(client.get_admin(), None); + } + + #[test] + fn double_initialize_fails() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + + client.initialize(&admin); + + let err = client.try_initialize(&admin).unwrap_err().unwrap(); + assert_eq!(err, ContractError::AlreadyInitialized); + } + + // --- upgrade --- + + #[test] + fn upgrade_requires_initialization() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + let wasm_hash = BytesN::from_array(&env, &[0u8; 32]); + + let err = client + .try_upgrade(&admin, &wasm_hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::NotInitialized); + } + + #[test] + fn non_admin_cannot_upgrade() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + let other = Address::generate(&env); + let wasm_hash = BytesN::from_array(&env, &[0u8; 32]); + + client.initialize(&admin); + + let err = client + .try_upgrade(&other, &wasm_hash) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized); + } + + // --- migrate --- + + #[test] + fn migrate_v0_to_v1_sets_versioning() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + + // Simulate pre-versioned state: no initialize called, DataKey::Version absent. + let new_version = client.migrate(&admin); + + assert_eq!(new_version, 1); + assert_eq!(client.get_version(), 1); + assert_eq!(client.get_admin(), Some(admin)); + } + + #[test] + fn migrate_already_current_fails() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + + client.initialize(&admin); + + let err = client.try_migrate(&admin).unwrap_err().unwrap(); + assert_eq!(err, ContractError::MigrationNotNeeded); + } + + #[test] + fn documents_still_work_after_migration() { + let (env, client, issuer, owner, document_hash) = setup(); + let admin = Address::generate(&env); + + // Register a document before any versioning is set up. + client.register_document(&issuer, &owner, &document_hash); + + // Migrate from v0 to v1. + client.migrate(&admin); + + // Document still verifiable after migration. + assert!(client.verify_document(&document_hash)); + assert_eq!(client.get_document_status(&document_hash), DocumentStatus::Active); + } + + // --- feature flags --- + + #[test] + fn get_feature_flag_returns_false_for_unset() { + let (env, client, _, _, _) = setup(); + let flag = Symbol::new(&env, "batch_ops"); + assert!(!client.get_feature_flag(&flag)); + } + + #[test] + fn admin_can_set_and_get_feature_flag() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + let flag = Symbol::new(&env, "batch_ops"); + + client.initialize(&admin); + client.set_feature_flag(&admin, &flag, &true); + + assert!(client.get_feature_flag(&flag)); + } + + #[test] + fn non_admin_cannot_set_feature_flag() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + let other = Address::generate(&env); + let flag = Symbol::new(&env, "batch_ops"); + + client.initialize(&admin); + + let err = client + .try_set_feature_flag(&other, &flag, &true) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized); + } + + #[test] + fn set_feature_flag_requires_initialization() { + let (env, client, _, _, _) = setup(); + let admin = Address::generate(&env); + let flag = Symbol::new(&env, "batch_ops"); + + let err = client + .try_set_feature_flag(&admin, &flag, &true) + .unwrap_err() + .unwrap(); + assert_eq!(err, ContractError::NotInitialized); + } }