diff --git a/docs/ARCHITECTURE_ESSENTIALS.md b/docs/ARCHITECTURE_ESSENTIALS.md index ed4ce1c..54c0607 100644 --- a/docs/ARCHITECTURE_ESSENTIALS.md +++ b/docs/ARCHITECTURE_ESSENTIALS.md @@ -1,6 +1,6 @@ # Kinlock — Architecture Essentials (v0.3) -> Quick-reference outline. Full detail: `ARCHITECTURE.md`. Product intent: `PRD.md`. +> Quick-reference outline. Full detail: `ARCHITECTURE.md`. Product intent: `PRD.md`. Contract surface: `ERRORS.md`, `EVENTS.md`. > If code and this file disagree, stop and flag it. Don't silently pick one. ## 1. What this is diff --git a/docs/ERRORS.md b/docs/ERRORS.md new file mode 100644 index 0000000..75ba91a --- /dev/null +++ b/docs/ERRORS.md @@ -0,0 +1,52 @@ +# Kinlock Contract Errors Reference + +This document is the authoritative specification for all error codes emitted by the `kinlock` Soroban smart contract. + +Error codes are non-contiguous numerical values returned via Soroban's `#[contracterror]` attribute (`Error` enum in `contracts/kinlock/src/errors.rs`). Numerical codes form a permanent part of the public interface: **they are never renumbered or reused**. + +## Retired Error Codes + +The following error codes were retired prior to public deployment and are never returned by the contract. They are reserved to prevent accidental reuse: +- **`1` (`AlreadyInitialized`)**: Retired in ADR-0018 when contract initialization moved into `__constructor`. +- **`27` (`TrancheSumMismatch`)**: Retired when the lock's `total` amount was derived directly from the sum of its tranches rather than accepted as a separate input parameter. + +--- + +## Active Error Codes Catalog + +Total active error variants: **32**. + +| Code | Variant Name | Category | Firing Condition | Caller Guidance | Terminal for Lock | +| :---: | :--- | :--- | :--- | :--- | :---: | +| **2** | `NotInitialized` | Setup & Admin | Configuration has not been written (`config` missing in instance storage). | Contract deployer must initialize the contract via `__constructor` before invoking entry points. | N/A (no lock exists) | +| **3** | `NotAttester` | Setup & Admin | Caller address is not present on the authorized attester roster. | Ensure the caller keypair is authorized by the contract admin via `add_attester`. | N/A (no lock exists) | +| **4** | `NotVouchingAttester` | Setup & Admin | Caller is on the attester roster but is neither the original vouching attester that registered this specific payee nor the contract admin. | The operation (`update_payout`, `suspend_payee`, `reactivate_payee`) must be signed by the specific attester who vouched for the payee, or by the admin. | N/A (no lock exists) | +| **5** | `InvalidCap` | Setup & Admin | Admin called `set_caps` with invalid numeric parameters (`min_amount <= 0`, `max_per_lock < min_amount`, or `max_global < max_per_lock`). | Pass valid non-zero amounts satisfying: `0 < min_amount <= max_per_lock <= max_global`. | N/A (no lock exists) | +| **10** | `PayeeAlreadyExists` | Registry | `register_payee` called with a `payee_id` that is already registered in contract storage. | Verify the payee ID. If the payee is already registered, use update functions (`update_payout`, `set_status`) instead. | N/A (no lock exists) | +| **11** | `PayeeNotFound` | Registry | Target `payee_id` does not exist in registry storage during registry management or `create_lock`. | Verify the 32-byte `payee_id` against the official registry directory before invoking. | No (lock creation reverted) | +| **12** | `PayeeNotActive` | Registry / Vault | Payee status is `Suspended` or `Revoked` during `create_lock`, or not `Active` during `release`. | On `create_lock`: cannot lock funds to an inactive payee; wait for reactivation. On `release`: payout is halted; if suspended, wait for reactivation; if revoked or grace expires, sender can refund. | No (temporary while suspended; sender can refund if revoked or grace expires) | +| **13** | `InvalidStatusTransition` | Registry | Status change is disallowed: attempting to reactivate a `Revoked` payee, or transitioning to the status the payee already holds. | `Revoked` is permanent and terminal. `Active` and `Suspended` payees may transition between each other. | N/A (registry level) | +| **14** | `PayoutUnchanged` | Registry | `update_payout` called with a `new_payout` identical to the current payout address. | No update required; supply a different payout address if a rotation is intended. | N/A (registry level) | +| **15** | `PayeeRevoked` | Registry | `update_payout` attempted on a payee whose status is `Revoked`. | Revoked payees are permanently frozen; their payout destination cannot be updated. | N/A (registry level) | +| **16** | `InvalidPayout` | Registry | `update_payout` or `register_payee` provides the Kinlock contract address itself as the payee payout address. | Provide a valid external account or recipient contract address; funds cannot be routed to Kinlock itself. | N/A (registry level) | +| **20** | `PausedNewLocks` | Lock Creation | `create_lock` called while `paused_new_locks` is active in contract configuration. | Wait until admin clears the pause flag. Existing open locks remain fully operational (release, refund, decline unaffected). | No (lock not created) | +| **21** | `TokenNotAllowed` | Lock Creation | `create_lock` called with a token address not present on the admin allowlist. | Use an approved asset (e.g. allowlisted Circle USDC SAC address). | No (lock not created) | +| **22** | `AmountBelowMinimum` | Lock Creation | Sum of all tranche amounts in `create_lock` is less than `config.min_amount`. | Increase the total lock amount to meet or exceed the minimum threshold (e.g. 1 USDC = 10,000,000 stroops). | No (lock not created) | +| **23** | `AmountAboveLockCap` | Lock Creation | Sum of all tranche amounts in `create_lock` exceeds `config.max_per_lock`. | Reduce the lock amount or split the transfer into multiple distinct locks under the cap. | No (lock not created) | +| **24** | `GlobalCapExceeded` | Lock Creation | Total lock amount would cause aggregate `total_locked` across all open locks to exceed `config.max_global`. | Wait for existing locks to complete (release/refund/decline) or for admin to increase the global cap. | No (lock not created) | +| **25** | `InvalidTrancheCount` | Lock Creation | `tranches` vector in `create_lock` is empty (`len == 0`) or exceeds `MAX_TRANCHES` (12). | Supply between 1 and 12 tranche specifications. | No (lock not created) | +| **26** | `InvalidTrancheAmount` | Lock Creation | At least one tranche in `create_lock` specifies an `amount <= 0`. | Ensure every tranche amount is a strictly positive integer in base units. | No (lock not created) | +| **28** | `UnlockAfterExpiry` | Lock Creation | A tranche specifies an `unlock_time > expires_at`. | Set all tranche unlock timestamps to be less than or equal to the overall lock `expires_at`. | No (lock not created) | +| **29** | `UnlockOutOfOrder` | Lock Creation | Tranche unlock timestamps decrease (`tranches[i].unlock_time < tranches[i-1].unlock_time`). | Order tranches monotonically non-decreasing by `unlock_time`. | No (lock not created) | +| **30** | `ExpiryTooSoon` | Lock Creation | Lock expiry timestamp is less than `now + MIN_LOCK_DURATION_SECS` (1 hour / 3,600s). | Set `expires_at` to at least 1 hour into the future from the current ledger timestamp. | No (lock not created) | +| **31** | `ExpiryTooFar` | Lock Creation | Lock expiry timestamp exceeds `now + MAX_LOCK_DURATION_SECS` (149 days / 12,873,600s, ADR-0020). | Set `expires_at` within 149 days of the current ledger timestamp. | No (lock not created) | +| **32** | `SenderIsPayout` | Lock Creation | Sender address in `create_lock` matches the payee's current payout address. | Senders cannot lock funds to themselves; verify the target payee identity. | No (lock not created) | +| **33** | `LockTtlTooLong` | Lock Creation | Lock duration plus TTL grace period exceeds the network maximum entry TTL. | Shorten the lock duration so storage rent can be sustained within Soroban network limits. | No (lock not created) | +| **40** | `LockNotFound` | Lock Actions | Looked up `lock_id` does not exist in storage during `release`, `refund`, `decline`, `bump_lock`, or `get_lock`. | Verify the `lock_id` (u64); check indexer or explorer to ensure transaction was confirmed. | Call failed (lock absent) | +| **41** | `LockNotOpen` | Lock Actions | Action attempted on a lock whose status is not `Open` (already `Completed`, `Refunded`, or `Declined`). | The lock has reached a final state; inspect receipt or lock history. | Yes (already terminal) | +| **42** | `TrancheIndexOutOfRange` | Lock Actions | `release` called with `tranche_idx >= lock.tranches.len()`. | Pass a valid 0-based tranche index in the range `[0, tranche_count - 1]`. | No (retryable with correct index) | +| **43** | `TrancheAlreadyReleased` | Lock Actions | `release` called on a tranche where `released == true`. | This tranche has already been paid out to the payee; check remaining unreleased tranches. | No (other tranches unaffected) | +| **44** | `TrancheNotUnlocked` | Lock Actions | `release` called before the current ledger timestamp reaches `tranche.unlock_time`. | Wait until `ledger.timestamp >= tranche.unlock_time` before calling `release`. | No (retryable once unlocked) | +| **45** | `LockExpired` | Lock Actions | `release` called when `ledger.timestamp >= lock.expires_at`. | The lock has expired and can no longer be released to the payee; the sender should call `refund` to recover remaining funds. | Yes for release; lock transitions to refund path | +| **46** | `RefundNotAllowed` | Lock Actions | `refund` called when the lock has not expired and the payee is neither `Revoked` nor `Suspended` past the 14-day grace period (`SUSPENSION_REFUND_GRACE_SECS`). | Wait until the lock reaches `expires_at`, or until the payee is revoked, or until 14 days have elapsed since payee suspension. | No (retryable once conditions are met) | +| **50** | `Overflow` | Arithmetic | Mathematical overflow detected during arithmetic computation (e.g. tranche summation, balance addition/subtraction). | Check integer amounts; ensure values fit within standard positive `i128` scale without overflow. | No (safeguard revert) | diff --git a/docs/EVENTS.md b/docs/EVENTS.md new file mode 100644 index 0000000..67b87a7 --- /dev/null +++ b/docs/EVENTS.md @@ -0,0 +1,185 @@ +# Kinlock Contract Events Reference + +This document is the authoritative specification for all events emitted by the `kinlock` Soroban smart contract. + +Events are consumed by the Kinlock indexer (`kinlock-sdk/services/indexer`) to maintain materialized Postgres state for exploratory list queries, and are verifiable directly on-chain via archive nodes. + +## Event Invariants and Standards + +1. **Schema Versioning**: Every event struct carries `pub schema_version: u32`. When emitted, this field equals `constants::EVENT_SCHEMA_VERSION` (currently `1`). Any structural change to an event payload requires incrementing `EVENT_SCHEMA_VERSION` and upgrading the indexer schema. +2. **Numeric Representation & Scale**: All token amounts are represented as signed 128-bit integers (`i128`) in atomic base units (stroops). For USDC on Stellar (via the Stellar Asset Contract / SEP-41), 1 USDC equals $10^7$ base units (`10_000_000`). **Consumers must parse amounts as BigInt or fixed-point strings, never as IEEE-754 floating-point numbers**. +3. **Payout Snapshotting (Hard Rule 2)**: The `LockCreated` event records the payee's payout address (`payout: Address`) at the exact block height when the lock was instantiated. On-chain, `lock.payout` is immutable. If the payee later rotates their address in the registry via `update_payout`, existing open locks continue to release only to their snapshotted address. + +--- + +## Registry Events (`registry.rs`) + +### 1. `PayeeRegistered` +Emitted when an authorized attester registers a new payee in the registry (`registry.rs:48`). + +- **Topic**: `payee_id: BytesN<32>` (unique 32-byte hash identifying the payee) +- **Data Payload**: + - `schema_version`: `u32` — Event schema version (`1`). + - `payout`: `Address` — Stellar address (G... or C...) designated to receive released lock funds. + - `category`: `Category` — Enum representation of the payee category (`School = 0`, `Healthcare = 1`, `Contractor = 2`, `Supplier = 3`, `Other = 4`). + - `attester`: `Address` — Stellar address of the vouching attester on the active roster. + - `meta_hash`: `BytesN<32>` — SHA-256 hash of the canonical off-chain payee metadata record. + +```json +{ + "topic": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "data": { + "schema_version": 1, + "payout": "GBBD7U7HUR7P7WD47Y6XAUDFB4D36R6P6P6K4L5P7N7N3M2J1K0L9M8N", + "category": 0, + "attester": "GAATT3ST3RROST3R7Y6XAUDFB4D36R6P6P6K4L5P7N7N3M2J1K0L9M8N", + "meta_hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + } +} +``` + +--- + +### 2. `PayeeStatusChanged` +Emitted when an authorized attester or admin updates the operating status of a payee (`registry.rs:100`). + +- **Topic**: `payee_id: BytesN<32>` — Payee identifier. +- **Data Payload**: + - `schema_version`: `u32` — Event schema version (`1`). + - `status`: `PayeeStatus` — Updated status enum (`Active = 0`, `Suspended = 1`, `Revoked = 2`). + - `changed_by`: `Address` — Stellar address of the attester or admin who executed the status change. + +```json +{ + "topic": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "data": { + "schema_version": 1, + "status": 1, + "changed_by": "GAATT3ST3RROST3R7Y6XAUDFB4D36R6P6P6K4L5P7N7N3M2J1K0L9M8N" + } +} +``` + +--- + +### 3. `PayoutUpdated` +Emitted when the vouching attester updates the destination payout address for future locks created for a payee (`registry.rs:137`). + +- **Topic**: `payee_id: BytesN<32>` — Payee identifier. +- **Data Payload**: + - `schema_version`: `u32` — Event schema version (`1`). + - `new_payout`: `Address` — Newly configured payout destination address for subsequent locks. + +*Note: Existing locks previously created under this payee retain their original snapshotted payout address.* + +```json +{ + "topic": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "data": { + "schema_version": 1, + "new_payout": "GCNEWPAYOUT47Y6XAUDFB4D36R6P6P6K4L5P7N7N3M2J1K0L9M8N1234" + } +} +``` + +--- + +## Vault Events (`vault.rs`) + +### 4. `LockCreated` +Emitted when a sender successfully instantiates an escrow lock in the vault (`vault.rs:147`). + +- **Topic**: `id: u64` — Monotonically increasing lock ID assigned by the contract. +- **Data Payload**: + - `schema_version`: `u32` — Event schema version (`1`). + - `sender`: `Address` — Stellar account or contract that deposited the funds. + - `payee_id`: `BytesN<32>` — Target verified payee in the registry. + - `payout`: `Address` — **Immutable payout snapshot**. Destination for all subsequent releases for this lock. + - `token`: `Address` — Allowlisted token address (e.g. SAC USDC). + - `total`: `i128` — Total locked amount in base units (stroops). For 100 USDC, `total = 1_000_000_000`. + - `ref_hash`: `BytesN<32>` — Double-hashed claim reference (`sha256(reference || salt)`). + - `expires_at`: `u64` — Unix timestamp (seconds) when lock expires and becomes refundable. + - `tranche_count`: `u32` — Total number of scheduled tranches in this lock (1 to 12). + +```json +{ + "topic": 11, + "data": { + "schema_version": 1, + "sender": "GBSENDER47Y6XAUDFB4D36R6P6P6K4L5P7N7N3M2J1K0L9M8N0001", + "payee_id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "payout": "GBBD7U7HUR7P7WD47Y6XAUDFB4D36R6P6P6K4L5P7N7N3M2J1K0L9M8N", + "token": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "total": 1000000000, + "ref_hash": "0xdeadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", + "expires_at": 1792400000, + "tranche_count": 2 + } +} +``` + +--- + +### 5. `Released` +Emitted when an authorized payout caller triggers release of a specific tranche (`vault.rs:211`). + +- **Topic**: `id: u64` — Lock identifier. +- **Data Payload**: + - `schema_version`: `u32` — Event schema version (`1`). + - `idx`: `u32` — 0-based tranche index that was released. + - `amount`: `i128` — Released tranche amount transferred to payout address in base units. + - `payout`: `Address` — Destination address where released funds were sent. + +```json +{ + "topic": 11, + "data": { + "schema_version": 1, + "idx": 0, + "amount": 500000000, + "payout": "GBBD7U7HUR7P7WD47Y6XAUDFB4D36R6P6P6K4L5P7N7N3M2J1K0L9M8N" + } +} +``` + +--- + +### 6. `Refunded` +Emitted when unreleased escrow funds are refunded back to the sender (`vault.rs:272`). + +- **Topic**: `id: u64` — Lock identifier. +- **Data Payload**: + - `schema_version`: `u32` — Event schema version (`1`). + - `amount`: `i128` — Total refunded amount returned to sender in base units (remainder of unreleased tranches). + - `reason`: `RefundReason` — Reason for refund (`Expired = 0`, `Revoked = 1`, `SuspendedTimeout = 2`). + +```json +{ + "topic": 11, + "data": { + "schema_version": 1, + "amount": 500000000, + "reason": 0 + } +} +``` + +--- + +### 7. `Declined` +Emitted when the payee's payout address explicitly declines an open lock, returning all remaining unreleased funds to the sender (`vault.rs:291`). + +- **Topic**: `id: u64` — Lock identifier. +- **Data Payload**: + - `schema_version`: `u32` — Event schema version (`1`). + - `amount`: `i128` — Total unreleased amount returned to sender in base units upon payee decline. + +```json +{ + "topic": 11, + "data": { + "schema_version": 1, + "amount": 500000000 + } +} +``` diff --git a/scripts/sync-docs.sh b/scripts/sync-docs.sh index 3a4d66e..dfaf2b7 100755 --- a/scripts/sync-docs.sh +++ b/scripts/sync-docs.sh @@ -8,7 +8,7 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" HEADER='> Synced from Kinlock-Org/.github. Do not edit here.' -DOCS=(PRD.md ARCHITECTURE.md ARCHITECTURE_ESSENTIALS.md) +DOCS=(PRD.md ARCHITECTURE.md ARCHITECTURE_ESSENTIALS.md ERRORS.md EVENTS.md) check=0 if [[ "${1:-}" == "--check" ]]; then check=1; shift; fi