diff --git a/docs/contract-api.md b/docs/contract-api.md index 6718f74..b77081b 100644 --- a/docs/contract-api.md +++ b/docs/contract-api.md @@ -1,206 +1,1106 @@ -# AutoShare Contract API Examples +# AutoShare Contract API Reference -Reference examples for integrating with the AutoShare Soroban contract. -All Stellar CLI invocations target testnet; swap `--network testnet` for `--network mainnet` in production. +Complete reference for all entry points of the `AutoShareContract` Soroban smart contract. + +**Source:** `contract/contracts/hello-world/src/lib.rs` +**Network:** Stellar (Soroban). Swap `--network testnet` for `--network mainnet` in production. +**Contract ID:** Set `CONTRACT_ID` to your deployed contract address. + +--- + +## Table of Contents + +1. [Data Types](#data-types) +2. [Error Codes](#error-codes) +3. [Admin Management](#admin-management) +4. [AutoShare Group Management](#autoshare-group-management) +5. [Token & Payment Configuration](#token--payment-configuration) +6. [Subscription & Usage](#subscription--usage) +7. [Scheduled Notifications](#scheduled-notifications) +8. [Notification Lifecycle Actions](#notification-lifecycle-actions) +9. [Batch Notifications](#batch-notifications) +10. [Recipient Preferences](#recipient-preferences) +11. [Audit Logging](#audit-logging) +12. [Sender Reputation](#sender-reputation) +13. [Notification Limits](#notification-limits) +14. [Schema Version](#schema-version) +15. [Access Logging](#access-logging) +16. [Contract Events](#contract-events) + +--- + +## Data Types + +### `AutoShareDetails` + +```rust +{ + id: BytesN<32>, // 32-byte group identifier + name: String, // Human-readable group name + creator: Address, // Creator wallet address + priority: NotificationPriority, + usage_count: u32, // Remaining usages + total_usages_paid: u32, // Lifetime usages purchased + members: Vec, + is_active: bool +} +``` + +### `GroupMember` + +```rust +{ address: Address, percentage: u32 } // percentages must sum to 100 +``` + +### `ScheduledNotification` + +```rust +{ + id: BytesN<32>, + creator: Address, + created_at: u64, // Ledger timestamp (seconds) + expires_at: u64, // Expiry ledger timestamp (seconds) + revoked_by: Option
, + revoked_at: Option, + delivered: bool, + delivered_at: Option, + recalled_by: Option
, + recalled_at: Option, + title: String +} +``` + +### `PaymentHistory` + +```rust +{ user: Address, group_id: BytesN<32>, usages_purchased: u32, amount_paid: i128, timestamp: u64 } +``` + +### `AuditRecord` + +```rust +{ seq: u64, notification_id: BytesN<32>, action: AuditAction, actor: Address, timestamp: u64 } +``` + +### `NotificationLimits` + +```rust +{ max_payload_size: u32, max_expiration_seconds: u64, min_expiration_seconds: u64, max_batch_size: u32 } +``` + +### `RecipientPreferences` + +```rust +{ recipient: Address, channels: Vec, categories: Vec, updated_at: u64 } +``` + +### Enums + +**`NotificationCategory`** — `Group=0, Admin=1, Financial=2, Notification=3` + +**`NotificationPriority`** — `Low=0, Medium=1, High=2, Critical=3` + +**`DeliveryChannel`** — `Wallet, Email, InApp` + +**`NotificationCategory` (preferences)** — `Payment, GroupMembership, GroupStatus, SystemAlerts, General` + +**`AuditAction`** — `Created=0, DeliveryAttempt=1, DeliveryFailed=2, Acknowledged=3, Cancelled=4, Expired=5` + +--- + +## Error Codes + +| Code | Name | Description | +|------|---------------------------|----------------------------------------------------------------| +| 1 | InvalidInput | Supplied parameter is invalid | +| 2 | AlreadyExists | Entity already exists | +| 3 | NotFound | Entity not found in storage | +| 4 | UnsupportedToken | Payment token is not on the supported list | +| 5 | InsufficientPayment | Provided payment is below the required amount | +| 6 | NoUsagesRemaining | Group has exhausted all purchased usages | +| 7 | InvalidUsageCount | Usage count is zero or otherwise invalid | +| 8 | Unauthorized | Caller is not authorised for this action | +| 9 | InsufficientBalance | Caller's token balance is too low | +| 10 | InvalidAmount | Specified amount is invalid | +| 11 | ContractPaused | Action is rejected while the contract is paused | +| 12 | AlreadyPaused | Contract is already paused | +| 13 | NotPaused | Contract is not paused | +| 14 | InvalidTotalPercentage | Member percentages do not sum to 100 | +| 15 | EmptyMembers | Member list is empty | +| 16 | DuplicateMember | Member list contains duplicate addresses | +| 17 | GroupInactive | Target group is deactivated | +| 18 | GroupAlreadyActive | Group is already active | +| 19 | GroupAlreadyInactive | Group is already inactive | +| 20 | InsufficientContractBalance | Contract balance is too low for the requested withdrawal | +| 21 | NameTooLong | Name string exceeds the maximum allowed length | +| 22 | TooManyMembers | Member list exceeds the maximum allowed size | +| 23 | NotificationExpired | Notification has already expired | +| 24 | InvalidExpirationDuration | Expiration duration is zero or would overflow the ledger clock | +| 25 | NotificationNotExpired | Notification lifetime has not yet elapsed | +| 26 | BatchTooLarge | Batch exceeds `max_batch_size` limit | +| 27 | NotificationRevoked | Notification has been revoked | +| 28 | NotAuthorizedToRevoke | Caller is not authorised to revoke this notification | +| 30 | NotificationDelivered | Notification is already delivered and cannot be recalled | + +--- + +## Admin Management + +### `version` + +Returns the current contract version number. + +**Parameters:** none +**Returns:** `u32` + +```bash +stellar contract invoke --id $CONTRACT_ID --network testnet -- version +``` + +--- + +### `initialize_admin` + +Initialises the contract admin. Can only be called once. Subsequent calls are rejected. + +**Parameters** + +| Name | Type | Description | +|-------|---------|------------------------| +| admin | Address | Initial admin address | + +```bash +stellar contract invoke --id $CONTRACT_ID --source admin-key --network testnet \ + -- initialize_admin --admin GADMIN... +``` --- -## subscribe (create) +### `pause` / `unpause` -Creates a new AutoShare group and locks in the initial payment for `usage_count` usages. +Pauses or unpauses the contract. Only the current admin can call these. Most state-changing operations are rejected while the contract is paused. **Parameters** -| Name | Type | Description | -|------|------|-------------| -| `id` | `BytesN<32>` | Unique group identifier (32-byte hex) | -| `name` | `String` | Human-readable group name | -| `creator` | `Address` | Wallet address of the creator | -| `usage_count` | `u32` | Number of usages to pre-purchase | -| `payment_token` | `Address` | Token contract address used for payment | +| Name | Type | Description | +|-------|---------|----------------------------------| +| admin | Address | Must match the stored admin address | + +```bash +stellar contract invoke --id $CONTRACT_ID --source admin-key --network testnet -- pause --admin GADMIN... +stellar contract invoke --id $CONTRACT_ID --source admin-key --network testnet -- unpause --admin GADMIN... +``` + +**Errors:** `Unauthorized`, `AlreadyPaused` / `NotPaused` + +--- + +### `get_paused_status` + +Returns `true` if the contract is currently paused. + +**Returns:** `bool` + +```bash +stellar contract invoke --id $CONTRACT_ID --network testnet -- get_paused_status +``` + +--- + +### `get_admin` + +Returns the current admin address. + +**Returns:** `Address` + +--- + +### `transfer_admin` + +Transfers admin rights to a new address. Only the current admin can call this. Emits `AdminTransferred`. + +**Parameters** + +| Name | Type | Description | +|---------------|---------|--------------------------| +| current_admin | Address | Existing admin (must auth) | +| new_admin | Address | New admin address | + +**Errors:** `Unauthorized` + +--- + +### `withdraw` + +Withdraws tokens collected as usage fees. Only admin can call. Emits `Withdrawal`. + +**Parameters** + +| Name | Type | Description | +|-----------|---------|-------------------------------| +| admin | Address | Must match stored admin | +| token | Address | Token contract to withdraw | +| amount | i128 | Amount to transfer | +| recipient | Address | Destination address | + +**Errors:** `Unauthorized`, `InsufficientContractBalance` + +--- + +### `get_contract_balance` + +Returns the contract's current balance for a given token. + +**Parameters** + +| Name | Type | +|-------|---------| +| token | Address | + +**Returns:** `i128` + +--- + +### `register_category` / `get_registered_categories` / `is_category_registered` + +Manages the on-chain notification category registry. Only admin can register categories. + +**`register_category` parameters** + +| Name | Type | +|----------|----------------------| +| admin | Address | +| category | NotificationCategory | + +--- + +## AutoShare Group Management + +### `create` + +Creates a new AutoShare group, stores it on-chain, and debits `usage_count × usage_fee` tokens from the creator's wallet. Emits `AutoshareCreated`. + +**Parameters** + +| Name | Type | Description | +|---------------|-------------|----------------------------------------------| +| id | BytesN<32> | Unique 32-byte group identifier | +| name | String | Human-readable group name (max 64 chars) | +| creator | Address | Creator wallet address (must auth) | +| usage_count | u32 | Number of usages to pre-purchase (> 0) | +| payment_token | Address | Token contract used for payment | + +**Errors:** `AlreadyExists`, `UnsupportedToken`, `InvalidUsageCount`, `InsufficientBalance`, `ContractPaused` **Stellar CLI** ```bash -stellar contract invoke \ - --id \ - --source creator-key \ - --network testnet \ - -- \ - create \ +stellar contract invoke --id $CONTRACT_ID --source creator-key --network testnet \ + -- create \ --id 0000000000000000000000000000000000000000000000000000000000000001 \ - --name "Team Alpha Plan" \ + --name "Engineering Fund" \ --creator GABC1234...XYZ \ --usage_count 100 \ --payment_token CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC ``` -**Expected outcome** - -- A new group is stored on-chain with `is_active = true` and `usage_count = 100`. -- Contract emits `AutoshareCreated { creator, group_id }`. -- The creator's wallet is debited `usage_count × usage_fee` tokens. - -**JavaScript / TypeScript SDK** +**TypeScript SDK** ```typescript -import { Contract, SorobanRpc, TransactionBuilder, Networks, BASE_FEE } from "@stellar/stellar-sdk"; +import { Contract, SorobanRpc, TransactionBuilder, Networks, BASE_FEE, nativeToScVal, xdr } from "@stellar/stellar-sdk"; -const CONTRACT_ID = ""; const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org"); +const contract = new Contract(CONTRACT_ID); const groupId = Buffer.alloc(32, 0); -groupId[31] = 1; // unique id bytes +groupId[31] = 1; -const tx = new TransactionBuilder(creatorAccount, { - fee: BASE_FEE, - networkPassphrase: Networks.TESTNET, -}) - .addOperation( - contract.call( - "create", - xdr.ScVal.scvBytes(groupId), // id - nativeToScVal("Team Alpha Plan"), // name - nativeToScVal(creatorAddress, { type: "address" }), // creator - nativeToScVal(100, { type: "u32" }), // usage_count - nativeToScVal(tokenAddress, { type: "address" }), // payment_token - ) - ) - .setTimeout(30) - .build(); +const tx = new TransactionBuilder(creatorAccount, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET }) + .addOperation(contract.call( + "create", + xdr.ScVal.scvBytes(groupId), + nativeToScVal("Engineering Fund"), + nativeToScVal(creatorAddress, { type: "address" }), + nativeToScVal(100, { type: "u32" }), + nativeToScVal(tokenAddress, { type: "address" }), + )) + .setTimeout(30).build(); ``` --- -## execute_payment (topup_subscription) +### `get` -Tops up an existing group by purchasing additional usages. +Retrieves a group by ID. + +**Parameters:** `id: BytesN<32>` +**Returns:** `AutoShareDetails` +**Errors:** `NotFound` + +--- + +### `get_all_groups` + +Returns all AutoShare groups stored on-chain. + +**Returns:** `Vec` + +--- + +### `get_groups_by_creator` + +Returns all groups created by a specific address. + +**Parameters:** `creator: Address` +**Returns:** `Vec` + +--- + +### `update_members` + +Replaces the full member list for a group. Caller must be the group creator. All percentages must sum to exactly 100. Emits `AutoshareUpdated`. **Parameters** -| Name | Type | Description | -|------|------|-------------| -| `id` | `BytesN<32>` | Group identifier | -| `additional_usages` | `u32` | Number of usages to add | -| `payment_token` | `Address` | Token used for payment | -| `payer` | `Address` | Address authorising the payment | +| Name | Type | Description | +|-------------|--------------------|------------------------------------| +| id | BytesN<32> | Group identifier | +| caller | Address | Must be group creator | +| new_members | Vec | New member list (% must sum to 100) | -**Stellar CLI** +**Errors:** `NotFound`, `Unauthorized`, `InvalidTotalPercentage`, `EmptyMembers`, `DuplicateMember`, `TooManyMembers` + +--- + +### `add_group_member` + +Adds a single member to an existing group. Caller must be the group creator. + +**Parameters** + +| Name | Type | Description | +|------------|------------|---------------------------------| +| id | BytesN<32> | Group identifier | +| caller | Address | Must be group creator | +| address | Address | New member address | +| percentage | u32 | Member's share (%) | + +**Errors:** `NotFound`, `Unauthorized`, `DuplicateMember`, `TooManyMembers` + +--- + +### `get_group_members` + +Returns the current member list for a group. + +**Parameters:** `id: BytesN<32>` +**Returns:** `Vec` + +--- + +### `is_group_member` + +Returns `true` if `address` is a member of the group. + +**Parameters:** `id: BytesN<32>`, `address: Address` +**Returns:** `bool` + +--- + +### `deactivate_group` / `activate_group` + +Deactivates or reactivates a group. Only the creator can call these. Emits `GroupDeactivated` / `GroupActivated`. + +**Parameters** + +| Name | Type | Description | +|--------|------------|-------------------------------| +| id | BytesN<32> | Group identifier | +| caller | Address | Must be group creator | + +**Errors:** `NotFound`, `Unauthorized`, `GroupAlreadyInactive` / `GroupAlreadyActive` ```bash -stellar contract invoke \ - --id \ - --source payer-key \ - --network testnet \ - -- \ - topup_subscription \ +# Deactivate +stellar contract invoke --id $CONTRACT_ID --source creator-key --network testnet \ + -- deactivate_group \ + --id 0000000000000000000000000000000000000000000000000000000000000001 \ + --caller GABC1234...XYZ + +# Reactivate +stellar contract invoke --id $CONTRACT_ID --source creator-key --network testnet \ + -- activate_group \ + --id 0000000000000000000000000000000000000000000000000000000000000001 \ + --caller GABC1234...XYZ +``` + +--- + +### `is_group_active` + +Returns `true` if the group is currently active. + +**Parameters:** `id: BytesN<32>` +**Returns:** `bool` + +--- + +## Token & Payment Configuration + +### `add_supported_token` / `remove_supported_token` + +Adds or removes a token from the list of accepted payment tokens. Admin only. + +**Parameters:** `token: Address`, `admin: Address` +**Errors:** `Unauthorized` + +--- + +### `get_supported_tokens` + +Returns all supported payment token addresses. + +**Returns:** `Vec
` + +--- + +### `is_token_supported` + +Returns `true` if a token is on the supported list. + +**Parameters:** `token: Address` +**Returns:** `bool` + +--- + +### `set_usage_fee` + +Sets the per-usage fee charged when creating or topping up a group. Admin only. + +**Parameters:** `fee: u32`, `admin: Address` +**Errors:** `Unauthorized` + +--- + +### `get_usage_fee` + +Returns the current usage fee. + +**Returns:** `u32` + +--- + +## Subscription & Usage + +### `topup_subscription` + +Purchases additional usages for an existing group. Emits no dedicated event but appends a `PaymentHistory` record. + +**Parameters** + +| Name | Type | Description | +|--------------------|------------|--------------------------------------| +| id | BytesN<32> | Group identifier | +| additional_usages | u32 | Number of usages to add (> 0) | +| payment_token | Address | Token used for payment | +| payer | Address | Address authorising the payment | + +**Errors:** `NotFound`, `GroupInactive`, `UnsupportedToken`, `InvalidUsageCount`, `InsufficientBalance` + +```bash +stellar contract invoke --id $CONTRACT_ID --source payer-key --network testnet \ + -- topup_subscription \ --id 0000000000000000000000000000000000000000000000000000000000000001 \ --additional_usages 50 \ --payment_token CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC \ --payer GABC1234...XYZ ``` -**Expected outcome** +--- + +### `reduce_usage` + +Decrements the group's remaining usage count by 1. Callable by any group member. + +**Parameters:** `id: BytesN<32>`, `caller: Address` +**Errors:** `NotFound`, `Unauthorized`, `NoUsagesRemaining`, `GroupInactive` + +--- + +### `get_remaining_usages` + +Returns remaining usages for a group. + +**Parameters:** `id: BytesN<32>` +**Returns:** `u32` + +--- + +### `get_total_usages_paid` + +Returns the total lifetime usages purchased for a group. + +**Parameters:** `id: BytesN<32>` +**Returns:** `u32` + +--- + +### `get_user_payment_history` + +Returns all payment records for a user across all groups. + +**Parameters:** `user: Address` +**Returns:** `Vec` + +--- + +### `get_group_payment_history` + +Returns all payment records for a specific group. + +**Parameters:** `id: BytesN<32>` +**Returns:** `Vec` + +--- + +## Scheduled Notifications + +### `schedule_notification` + +Schedules a notification on-chain with a time-to-live. The notification becomes expired once the ledger timestamp reaches `created_at + ttl_seconds`. Emits `NotificationScheduled`. -- `usage_count` for the group increases by `50`. -- Payer is debited `50 × usage_fee` tokens. -- A `PaymentHistory` record is appended for the payer and the group. +**Parameters** + +| Name | Type | Description | +|-----------------|------------|-------------------------------------------------------| +| notification_id | BytesN<32> | Unique 32-byte notification identifier | +| creator | Address | Creator address (must auth) | +| ttl_seconds | u64 | Lifetime in seconds (must be within configured limits) | +| title | String | Notification title (validated metadata) | -**JavaScript / TypeScript SDK** +**Errors:** `AlreadyExists`, `InvalidExpirationDuration`, `ContractPaused` + +```bash +stellar contract invoke --id $CONTRACT_ID --source creator-key --network testnet \ + -- schedule_notification \ + --notification_id 0000000000000000000000000000000000000000000000000000000000000002 \ + --creator GABC1234...XYZ \ + --ttl_seconds 3600 \ + --title "Task Completed" +``` + +**TypeScript SDK** ```typescript -const tx = new TransactionBuilder(payerAccount, { - fee: BASE_FEE, - networkPassphrase: Networks.TESTNET, -}) - .addOperation( - contract.call( - "topup_subscription", - xdr.ScVal.scvBytes(groupId), - nativeToScVal(50, { type: "u32" }), - nativeToScVal(tokenAddress, { type: "address" }), - nativeToScVal(payerAddress, { type: "address" }), - ) - ) - .setTimeout(30) - .build(); +const notifId = Buffer.alloc(32, 0); +notifId[31] = 2; + +const tx = new TransactionBuilder(creatorAccount, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET }) + .addOperation(contract.call( + "schedule_notification", + xdr.ScVal.scvBytes(notifId), + nativeToScVal(creatorAddress, { type: "address" }), + nativeToScVal(3600n, { type: "u64" }), + nativeToScVal("Task Completed"), + )) + .setTimeout(30).build(); ``` --- -## cancel (deactivate_group) +### `get_notification` -Deactivates a group. Only the creator can call this. The group data is preserved on-chain for audit purposes. +Returns the stored details for a scheduled notification. + +**Parameters:** `notification_id: BytesN<32>` +**Returns:** `ScheduledNotification` +**Errors:** `NotFound` + +--- + +### `is_notification_expired` + +Returns `true` if the notification's lifetime has elapsed. + +**Parameters:** `notification_id: BytesN<32>` +**Returns:** `bool` + +--- + +### `expire_notification` + +Finalises expiry for a notification whose lifetime has elapsed. Callable by anyone. Emits `NotificationExpired`. + +**Parameters:** `notification_id: BytesN<32>` +**Errors:** `NotFound`, `NotificationNotExpired`, `NotificationRevoked` + +--- + +## Notification Lifecycle Actions + +### `revoke_notification` + +Revokes a notification, preventing any further interaction. Only the creator or contract admin can call this. Emits `NotificationRevoked`. **Parameters** -| Name | Type | Description | -|------|------|-------------| -| `id` | `BytesN<32>` | Group identifier | -| `caller` | `Address` | Must match the group's creator address | +| Name | Type | Description | +|-----------------|------------|-------------------------------------------| +| notification_id | BytesN<32> | Target notification ID | +| caller | Address | Must be creator or admin | -**Stellar CLI** +**Errors:** `NotFound`, `NotAuthorizedToRevoke`, `AlreadyRevoked`, `NotificationExpired` + +--- + +### `is_notification_revoked` + +Returns `true` if the notification has been revoked. + +**Parameters:** `notification_id: BytesN<32>` +**Returns:** `bool` + +--- + +### `cancel_notification` + +Cancels a scheduled notification. Emits `ScheduledNotificationCancelled`. + +**Parameters:** `notification_id: BytesN<32>`, `caller: Address` +**Errors:** `NotFound`, `Unauthorized`, `ContractPaused` + +--- + +### `recall_notification` + +Recalls a notification before delivery confirmation. Only the creator or admin can recall. Emits `NotificationRecalled`. + +**Parameters:** `notification_id: BytesN<32>`, `caller: Address` +**Errors:** `NotFound`, `Unauthorized`, `NotificationDelivered`, `NotificationRevoked`, `NotificationExpired` + +--- + +### `confirm_notification_delivery` + +Marks a notification as delivered. Only the creator or admin can confirm. Emits `NotificationDelivered`. + +**Parameters:** `notification_id: BytesN<32>`, `caller: Address` +**Errors:** `NotFound`, `Unauthorized`, `NotificationRevoked`, `NotificationExpired` + +--- + +### `extend_notification_expiry` + +Extends the expiry of an active notification by `extension_seconds`. Only the creator or admin can extend. Emits `NotificationExtended`. + +**Parameters** + +| Name | Type | Description | +|--------------------|------------|------------------------------------| +| notification_id | BytesN<32> | Target notification ID | +| caller | Address | Must be creator or admin | +| extension_seconds | u64 | Seconds to add to current `expires_at` | + +**Errors:** `NotFound`, `Unauthorized`, `NotificationRevoked`, `NotificationExpired` + +--- + +### `acknowledge_notifications` + +Batch-acknowledges multiple notifications in a single transaction. Emits `NotificationAcknowledged` for each. + +**Parameters** + +| Name | Type | Description | +|------------------|------------------|---------------------------------------| +| caller | Address | Acknowledging address (must auth) | +| notification_ids | Vec> | List of notification IDs to acknowledge | + +**Errors:** `NotFound`, `Unauthorized` per notification + +--- + +## Batch Notifications + +### `batch_schedule_notifications` + +Creates up to `max_batch_size` (default 50) notifications in a single transaction. All three vectors (`ids`, `ttl_seconds`, `titles`) must have the same length. Emits one `NotificationScheduled` event per notification, plus a single `BatchNotificationsCreated` summary event. + +**Parameters** + +| Name | Type | Description | +|-------------|-----------------|--------------------------------------------------| +| ids | Vec> | Unique IDs for each notification | +| creator | Address | Creator address (must auth) | +| ttl_seconds | Vec | TTL in seconds for each notification (parallel) | +| titles | Vec | Titles for each notification (parallel) | + +**Errors:** `BatchTooLarge`, `AlreadyExists`, `InvalidExpirationDuration`, `ContractPaused` ```bash -stellar contract invoke \ - --id \ - --source creator-key \ - --network testnet \ - -- \ - deactivate_group \ - --id 0000000000000000000000000000000000000000000000000000000000000001 \ - --caller GABC1234...XYZ +# Example: create 2 notifications in one call +stellar contract invoke --id $CONTRACT_ID --source creator-key --network testnet \ + -- batch_schedule_notifications \ + --ids '["0000...0001","0000...0002"]' \ + --creator GABC1234...XYZ \ + --ttl_seconds '[3600,7200]' \ + --titles '["Notification A","Notification B"]' ``` -**Expected outcome** +--- -- Group `is_active` is set to `false`. -- Contract emits `GroupDeactivated { creator, group_id }`. -- Subsequent payment attempts against the group will fail with an error. +### `emit_batch_completed` -**Error cases** +Signals to off-chain listeners that a batch of notifications has finished processing. Emits `BatchProcessingCompleted`. -| Error | Cause | -|-------|-------| -| `Unauthorized` | `caller` does not match the group creator | -| `GroupNotFound` | No group exists for the given `id` | -| `GroupAlreadyInactive` | Group is already deactivated | +**Parameters:** `batch_id: BytesN<32>`, `processed_count: u32` -**JavaScript / TypeScript SDK** +--- -```typescript -const tx = new TransactionBuilder(creatorAccount, { - fee: BASE_FEE, - networkPassphrase: Networks.TESTNET, -}) - .addOperation( - contract.call( - "deactivate_group", - xdr.ScVal.scvBytes(groupId), - nativeToScVal(creatorAddress, { type: "address" }), - ) - ) - .setTimeout(30) - .build(); +## Recipient Preferences + +Preferences are stored per-recipient on-chain. All write methods require the caller to authenticate as `recipient`. Reads default to all-enabled if no preferences have been set. + +### `get_preferences` + +Returns the full preference set for a recipient. + +**Parameters:** `recipient: Address` +**Returns:** `RecipientPreferences` + +```bash +stellar contract invoke --id $CONTRACT_ID --network testnet \ + -- get_preferences --recipient GABC1234...XYZ +``` + +--- + +### `set_preferences` + +Atomically replaces all channel and category preferences. + +**Parameters** + +| Name | Type | Description | +|------------|--------------------------|------------------------------------| +| recipient | Address | Must auth | +| channels | Vec | Full list of channel preferences | +| categories | Vec | Full list of category preferences | + +**Example `channels` value:** +```json +[ + { "channel": "Wallet", "enabled": true }, + { "channel": "Email", "enabled": false }, + { "channel": "InApp", "enabled": true } +] +``` + +**Example `categories` value:** +```json +[ + { "category": "Payment", "enabled": true }, + { "category": "GroupMembership", "enabled": true }, + { "category": "GroupStatus", "enabled": false }, + { "category": "SystemAlerts", "enabled": true }, + { "category": "General", "enabled": true } +] ``` --- -## Re-activating a cancelled group +### `set_channel_preference` -Use `activate_group` with the same `id` and creator `caller` to restore the group. +Toggles a single delivery channel. + +**Parameters:** `recipient: Address`, `channel: DeliveryChannel`, `enabled: bool` ```bash -stellar contract invoke \ - --id \ - --source creator-key \ - --network testnet \ - -- \ - activate_group \ - --id 0000000000000000000000000000000000000000000000000000000000000001 \ - --caller GABC1234...XYZ +stellar contract invoke --id $CONTRACT_ID --source recipient-key --network testnet \ + -- set_channel_preference \ + --recipient GABC1234...XYZ \ + --channel Email \ + --enabled false +``` + +--- + +### `set_category_preference` + +Toggles a single notification category. + +**Parameters:** `recipient: Address`, `category: NotificationCategory`, `enabled: bool` + +--- + +### `reset_preferences` + +Resets all preferences to the all-enabled defaults. + +**Parameters:** `recipient: Address` (must auth) + +--- + +### `is_channel_enabled` + +Returns `true` if the given delivery channel is enabled for a recipient. + +**Parameters:** `recipient: Address`, `channel: DeliveryChannel` +**Returns:** `bool` + +--- + +### `is_category_enabled` + +Returns `true` if the given notification category is enabled for a recipient. + +**Parameters:** `recipient: Address`, `category: NotificationCategory` +**Returns:** `bool` + +--- + +## Audit Logging + +### `get_audit_log` + +Returns the complete immutable audit log in append order. + +**Returns:** `Vec` + +--- + +### `get_notification_audit` + +Returns all audit records for a specific notification. + +**Parameters:** `notification_id: BytesN<32>` +**Returns:** `Vec` + +--- + +### `record_delivery_attempt` + +Records a delivery attempt for a notification in the audit log. Emits `AuditRecordAppended`. + +**Parameters:** `notification_id: BytesN<32>`, `actor: Address` + +--- + +### `record_delivery_failure` + +Records a delivery failure in the audit log. Emits `AuditRecordAppended`. + +**Parameters:** `notification_id: BytesN<32>`, `actor: Address` + +--- + +### `record_acknowledgment` + +Records that the recipient acknowledged a notification. Emits `AuditRecordAppended`. + +**Parameters:** `notification_id: BytesN<32>`, `actor: Address` + +--- + +## Sender Reputation + +Reputation scores range from 0 (lowest) to 100 (highest) and are updated automatically by delivery outcomes. + +### Reputation Tiers + +| Tier | Value | Name | +|------|-------|-------------| +| 0 | — | Unverified | +| 1 | — | Bronze | +| 2 | — | Silver | +| 3 | — | Gold | +| 4 | — | Platinum | + +--- + +### `record_delivery_success` + +Records a successful delivery for a sender. Updates reputation score. Emits `ReputationUpdated`. + +**Parameters:** `sender: Address` + +--- + +### `record_delivery_failure` + +Records a failed delivery for a sender. Decrements reputation score. Emits `ReputationUpdated`. + +**Parameters:** `sender: Address` + +--- + +### `get_sender_reputation_score` + +Returns the reputation score (0–100) for a sender. Returns 50 if no history exists. + +**Parameters:** `sender: Address` +**Returns:** `i64` + +--- + +### `get_sender_reputation` + +Returns the full reputation record including counts and current score. + +**Parameters:** `sender: Address` +**Returns:** `SenderReputation` + +--- + +### `get_sender_reputation_tier` + +Returns the numeric reputation tier (0–4). + +**Parameters:** `sender: Address` +**Returns:** `u32` + +--- + +## Notification Limits + +### `configure_notification_limits` + +Sets protocol-level limits on notification payloads and batch sizes. Admin only. Emits `NotificationLimitsConfigured`. + +**Parameters** + +| Name | Type | Description | +|------------------------|------|---------------------------------------------------| +| admin | Address | Must be current admin | +| max_payload_size | u32 | Maximum notification payload size in bytes | +| max_expiration_seconds | u64 | Maximum allowed `ttl_seconds` value | +| min_expiration_seconds | u64 | Minimum allowed `ttl_seconds` value | +| max_batch_size | u32 | Maximum notifications per `batch_schedule_notifications` call | + +**Errors:** `Unauthorized`, `InvalidLimit` + +```bash +stellar contract invoke --id $CONTRACT_ID --source admin-key --network testnet \ + -- configure_notification_limits \ + --admin GADMIN... \ + --max_payload_size 4096 \ + --max_expiration_seconds 2592000 \ + --min_expiration_seconds 60 \ + --max_batch_size 50 +``` + +--- + +### `get_notification_limits` + +Returns the current notification limits configuration. + +**Returns:** `NotificationLimits` + +--- + +## Schema Version + +### `set_schema_version` + +Sets the on-chain notification schema version. Admin only. Rejects versions outside the supported range. Emits `SchemaVersionSet`. + +**Parameters:** `admin: Address`, `schema_version: u32` +**Errors:** `Unauthorized`, `InvalidInput` + +--- + +### `get_schema_version` + +Returns the current schema version (0 if never set). + +**Returns:** `u32` + +--- + +### `is_version_supported` + +Returns `true` if the given version is within the supported range. + +**Parameters:** `version: u32` +**Returns:** `bool` + +--- + +## Access Logging + +### `record_notification_access` + +Emits a `NotificationAccessed` event whenever a protected notification record is read. Use this to build an immutable access trail for compliance. + +**Parameters:** `notification_id: BytesN<32>`, `accessor: Address` + +--- + +## Contract Events + +Every event emitted by `AutoShareContract` carries `category: NotificationCategory` and `priority: NotificationPriority` as the last two indexed topics. Existing listeners that only read the event name are unaffected — the trailing topics are ignored if not consumed. + +### Event Reference + +| Event symbol | Additional topics | Data payload | Category | Priority | +|------------------------------------|------------------------------------------------|-------------------------------------------|--------------|----------| +| `autoshare_created` | `creator` | `id: BytesN<32>` | Group (0) | Low (0) | +| `autoshare_updated` | `updater` | `id: BytesN<32>` | Group (0) | Medium (1) | +| `group_deactivated` | `creator` | `id: BytesN<32>` | Group (0) | Medium (1) | +| `group_activated` | `creator` | `id: BytesN<32>` | Group (0) | Medium (1) | +| `contract_paused` | `admin` | — | Admin (1) | High (2) | +| `contract_unpaused` | `admin` | — | Admin (1) | High (2) | +| `admin_transferred` | `old_admin` | `new_admin: Address` | Admin (1) | Critical (3) | +| `withdrawal` | `token`, `recipient` | `amount: i128` | Financial (2) | High (2) | +| `authorization_failure` | `caller` | `action: String` | Admin (1) | Critical (3) | +| `category_registered` | `admin` | — | Admin (1) | Low (0) | +| `notification_scheduled` | `creator` | `notification_id: BytesN<32>` | Notification (3) | Low (0) | +| `notification_expired` | `notification_id` | `expires_at: u64` | Notification (3) | Low (0) | +| `notification_revoked` | `notification_id`, `revoked_by` | — (derivable from ledger) | Notification (3) | Medium (1) | +| `scheduled_notification_cancelled` | `caller` | `notification_id: BytesN<32>` | Notification (3) | Medium (1) | +| `notification_delivered` | `notification_id`, `delivered_by` | `delivered_at: u64` | Notification (3) | Medium (1) | +| `notification_recalled` | `notification_id`, `recalled_by` | `recalled_at: u64` | Notification (3) | Medium (1) | +| `notification_extended` | `notification_id`, `caller` | `new_expires_at: u64` | Notification (3) | Low (0) | +| `notification_acknowledged` | `notification_id`, `acknowledger` | `timestamp: u64` | Notification (3) | Low (0) | +| `notification_accessed` | `notification_id`, `accessor` | `accessed_at: u64` | Notification (3) | Low (0) | +| `batch_notifications_created` | `creator` | `count: u32`, `ids: Vec>` | Notification (3) | Low (0) | +| `batch_processing_completed` | `batch_id` | `processed_count: u32` | Notification (3) | Low (0) | +| `audit_record_appended` | `notification_id`, `action: AuditAction` | `seq: u64`, `actor: Address` | Notification (3) | Low (0) | +| `reputation_updated` | `sender` | `new_score: i64`, `successful_count: u32`, `failed_count: u32` | Admin (1) | Low (0) | +| `reputation_tier_changed` | `sender` | `old_tier: u32`, `new_tier: u32`, `reputation_score: i64` | Admin (1) | Medium (1) | +| `notification_limits_configured` | `admin` | `max_payload_size`, `max_expiration_seconds`, `min_expiration_seconds`, `max_batch_size` | Admin (1) | Medium (1) | +| `schema_version_set` | `admin` | `schema_version: u32`, `previous_version: u32` | Admin (1) | Medium (1) | + +### Parsing Events Off-Chain + +```typescript +import { xdr, scValToNative } from "@stellar/stellar-sdk"; + +const CATEGORIES = ["Group", "Admin", "Financial", "Notification"]; +const PRIORITIES = ["Low", "Medium", "High", "Critical"]; + +function parseSorobanEvent(rawEvent: any) { + const topics = rawEvent.topic.map((t: string) => xdr.ScVal.fromXDR(t, "base64")); + const data = scValToNative(xdr.ScVal.fromXDR(rawEvent.value, "base64")); + + const eventName = scValToNative(topics[0]).toString(); + + // AutoShare events append category + priority as the last two topics + let category: string | undefined; + let priority: string | undefined; + if (topics.length >= 3) { + const rawCategory = scValToNative(topics[topics.length - 2]); + const rawPriority = scValToNative(topics[topics.length - 1]); + category = CATEGORIES[rawCategory]; + priority = PRIORITIES[rawPriority]; + } + + return { contractId: rawEvent.contractId, eventName, category, priority, data }; +} ``` --- -> Full interface specification: [`contract/contracts/hello-world/src/interfaces/autoshare.rs`](../contract/contracts/hello-world/src/interfaces/autoshare.rs) +*For the complete listener REST API reference, see [../listener/API.md](../listener/API.md).* +*For on-chain event catalog with full topic lists, see [../EVENT_CATALOG.md](../EVENT_CATALOG.md).* diff --git a/listener/API.md b/listener/API.md index e781849..93febf9 100644 --- a/listener/API.md +++ b/listener/API.md @@ -2,21 +2,190 @@ Base URL: `http://localhost:8787` (configured via `EVENTS_API_PORT`) +Every response carries `X-Request-Id` (a UUID unique to the request) and `X-Correlation-Id` (echo of the caller-supplied `X-Correlation-Id` header, or a server-generated UUID if omitted). Include both when reporting issues. + For a centralized list of API errors, causes, examples, and troubleshooting steps, see [API_ERROR_REFERENCE.md](./API_ERROR_REFERENCE.md). --- +## Table of Contents + +1. [Health & Status](#health--status) +2. [Events](#events) +3. [Scheduled Notifications](#scheduled-notifications) +4. [Notification Delivery History](#notification-delivery-history) +5. [Notification Search](#notification-search) +6. [Batch Validation](#batch-validation) +7. [Notification Templates](#notification-templates) +8. [Analytics](#analytics) +9. [User Notification Preferences](#user-notification-preferences) +10. [Webhooks](#webhooks) +11. [Rate Limiting](#rate-limiting) +12. [Error Codes Reference](#error-codes-reference) + +--- + +## Health & Status + +### GET /health + +Returns the operational status of all service dependencies. + +**Response `200`** — all systems operational (or Discord degraded but Stellar RPC healthy) + +```json +{ + "status": "ok", + "timestamp": "2024-06-20T14:00:00.000Z", + "services": { + "stellarRpc": { "status": "ok", "latencyMs": 42 }, + "discord": { "status": "ok", "latencyMs": 87 }, + "eventRegistry": { "status": "ok", "eventCount": 128 } + } +} +``` + +`status` is `"degraded"` when Discord is unreachable but Stellar RPC is healthy: + +```json +{ + "status": "degraded", + "timestamp": "2024-06-20T14:00:00.000Z", + "services": { + "stellarRpc": { "status": "ok", "latencyMs": 38 }, + "discord": { "status": "error", "latencyMs": 5001, "detail": "HTTP 401" }, + "eventRegistry": { "status": "ok", "eventCount": 128 } + } +} +``` + +**Response `503`** — Stellar RPC is unreachable + +```json +{ + "status": "error", + "timestamp": "2024-06-20T14:00:00.000Z", + "services": { + "stellarRpc": { "status": "error", "latencyMs": 5001, "detail": "Health check timed out" }, + "discord": { "status": "ok", "latencyMs": 65 }, + "eventRegistry": { "status": "ok", "eventCount": 128 } + } +} +``` + +**Response `500`** — health check itself threw an unexpected error + +```json +{ "status": "error", "detail": "Internal health check failure" } +``` + +A service entry's `status` can be `"ok"`, `"error"`, or `"not_configured"`. `"not_configured"` means the service URL was not provided at startup and is not checked. + +--- + +### GET /api/status + +Returns the pause status of all configured smart contracts. + +**Response `200`** + +```json +{ + "timestamp": "2024-06-20T14:00:00.000Z", + "contracts": [ + { "address": "CCEMX6...", "paused": false }, + { "address": "CCEMX7...", "paused": true, "error": "Failed to simulate contract call" } + ] +} +``` + +| Field | Type | Description | +|-----------|---------|--------------------------------------------------------------------| +| timestamp | string | ISO 8601 timestamp of when the status was fetched | +| contracts | array | One entry per configured contract | +| address | string | Contract address | +| paused | boolean | Whether the contract is currently paused | +| error | string | Present only when the contract status could not be fetched | + +**Response `500`** + +```json +{ "status": "error", "detail": "Internal status check failure" } +``` + +--- + +### GET /api/indexing/health + +Reports the current indexing lag between the local event registry and the Stellar network tip. + +**Response `200`** + +```json +{ + "status": "synced", + "timestamp": "2024-06-20T14:00:00.000Z", + "indexedLedger": 54321, + "networkTipLedger": 54321, + "ledgerLag": 0, + "processingDelayMs": 850, + "lastIngestedAt": "2024-06-20T13:59:59.000Z", + "detail": null +} +``` + +| Field | Type | Description | +|-------------------|---------------|---------------------------------------------------------------------------| +| status | string | `"synced"`, `"syncing"`, or `"degraded"` | +| indexedLedger | number\|null | Ledger sequence of the last ingested event | +| networkTipLedger | number\|null | Latest ledger on the Stellar network | +| ledgerLag | number\|null | `networkTipLedger - indexedLedger` (0 = in sync) | +| processingDelayMs | number\|null | Milliseconds since the last event was ingested | +| lastIngestedAt | string\|null | ISO 8601 timestamp of the last ingested event | +| detail | string\|null | Human-readable explanation when `status` is not `"synced"` | + +**Status thresholds:** +- `"synced"` — `ledgerLag == 0` and `processingDelayMs <= 60 000` +- `"syncing"` — `ledgerLag <= 5` and `processingDelayMs <= 300 000` +- `"degraded"` — outside the above bounds, or `networkTipLedger` is unavailable + +--- + +### GET /api/notifications/health + +Returns the last report produced by the notification health monitor. + +**Response `200`** + +```json +{ + "status": "healthy", + "checkedAt": "2024-06-20T14:00:00.000Z", + "pendingCount": 5, + "overdueCount": 0, + "failureRatePercent": 1.2 +} +``` + +**Response `503`** — health monitor not configured or no report produced yet + +```json +{ "error": "Health monitor not configured or no report yet" } +``` + +--- + ## Events ### GET /api/events -Returns all stored contract events. +Returns all stored contract events, newest first. **Query Parameters** -| Name | Type | Required | Description | -|-------|--------|----------|------------------------------------| -| limit | number | No | Maximum number of events to return | +| Name | Type | Required | Description | +|-------|--------|----------|------------------------------------------------------| +| limit | number | No | Maximum number of events to return (default: all) | **Response `200`** @@ -25,23 +194,35 @@ Returns all stored contract events. "count": 42, "events": [ { - "eventId": "string", - "contractAddress": "string", - "eventName": "string | null", + "eventId": "0000000000000000-1", + "contractAddress": "CCEMX6JKPEUGYAOU4YZP3WBXGPWK7AEFDEDLRXFIDIJPQFMRXTHUVIO", + "eventName": "autoshare_created", "ledger": 12345, "type": "contract", - "topic": ["TaskCreated"], - "value": "string", - "txHash": "string", + "topic": ["autoshare_created", "GABC..."], + "value": "AAAAAQ==", + "txHash": "abc123...", "receivedAt": 1718640000000 } ] } ``` +| Field | Type | Description | +|-----------------|---------------|----------------------------------------------------------| +| eventId | string | Soroban event ID (`ledger-index` format) | +| contractAddress | string | Contract that emitted the event | +| eventName | string\|null | Decoded event name (first topic symbol), if decodeable | +| ledger | number | Ledger sequence containing the event | +| type | string | Always `"contract"` for Soroban events | +| topic | string[] | Decoded event topics | +| value | string | Base64-encoded XDR event data | +| txHash | string | Transaction hash | +| receivedAt | number | Unix timestamp (ms) when the listener received the event | + --- -## User Notification Preferences +## Scheduled Notifications Preferences control which notification categories are delivered per user. Categories default to **enabled** when not explicitly set. @@ -111,62 +292,58 @@ Updates one or more notification category flags for a user. Unspecified categori { "error": "Invalid body: expected { categories: { [key]: boolean } }" } ``` ---- - -## Notification Categories +> **Note:** Available notification categories include `discord` and any custom categories defined in your deployment. Categories default to **enabled** when not explicitly set. -| Category | Description | -|-----------|------------------------------| -| `discord` | Discord webhook notifications | - -Additional categories can be added by extending the `categories` map. +> **Per-contract user binding:** To apply a user's preferences to events from a specific contract, set `userId` in the contract address config: `{ "address": "CCEMX6...", "events": ["*"], "userId": "alice" }`. If `userId` is omitted, the `"global"` user's preferences apply. --- -## Per-Contract User Binding +```json +{ "error": "Failed to insert notification into database" } +``` -To apply user preferences to a specific contract's events, set `userId` in the contract address config: +**Response `503`** — scheduler feature is disabled ```json -{ - "CONTRACT_ADDRESSES": [ - { - "address": "CCEMX6...", - "events": ["*"], - "userId": "alice" - } - ] -} +{ "error": "Scheduler not enabled" } ``` -If `userId` is omitted, the `"global"` user's preferences are applied. - --- -## Scheduled Notifications +### GET /api/schedule/:id + +Returns a single scheduled notification by its numeric ID. -### POST /api/schedule +**Path Parameters** -Schedules a notification for future delivery. +| Name | Description | +|------|--------------------------| +| id | Notification ID (integer) | +**Response `200`** > **Payload size limit**: The `payload` object is serialised to JSON before storage. The resulting byte length must not exceed the configured maximum (default **64 KB / 65 536 bytes**). Oversized payloads are rejected with HTTP `413`. Override the limit at runtime with the `MAX_PAYLOAD_SIZE_BYTES` environment variable. **Request Body** ```json { + "id": 42, "executeAt": "2024-06-20T15:00:00.000Z", "payload": { "content": "Your task was completed." }, "targetRecipient": "https://discord.com/api/webhooks/...", "notificationType": "discord", + "status": "pending", + "retries": 0, "maxRetries": 3, "priority": 1, "eventId": "abc123", "contractAddress": "CCEMX6...", - "metadata": {} + "metadata": null, + "createdAt": 1718640000000 } ``` +**Response `400`** — non-numeric `:id` | Field | Type | Required | Description | |-------------------|----------|----------|----------------------------------------------------------| | executeAt | string | Yes | ISO 8601 datetime — when to deliver the notification | @@ -188,15 +365,16 @@ Schedules a notification for future delivery. **Response `400`** — missing required fields ```json -{ "error": "Missing required fields: executeAt, payload, targetRecipient" } +{ "error": "Invalid notification ID" } ``` -**Response `400`** — `executeAt` cannot be parsed as a date +**Response `404`** — no notification with that ID ```json -{ "error": "executeAt is not a valid date" } +{ "error": "Notification not found" } ``` +**Response `500`** — database read failure **Response `413`** — payload exceeds the maximum allowed size ```json @@ -206,7 +384,7 @@ Schedules a notification for future delivery. **Response `500`** — internal scheduling failure ```json -{ "error": "Failed to insert notification into database" } +{ "error": "SQLITE_ERROR: ..." } ``` **Response `503`** — scheduler feature is disabled @@ -217,46 +395,80 @@ Schedules a notification for future delivery. --- -### GET /api/schedule/:id - -Returns a single scheduled notification by its numeric ID. - -**Path Parameters** +### GET /api/schedule/stats -| Name | Description | -|------|--------------------------| -| id | Notification ID (integer) | +Returns aggregate statistics about the scheduled-notification queue. **Response `200`** ```json { - "id": 42, - "executeAt": "2024-06-20T15:00:00.000Z", - "payload": { "content": "Your task was completed." }, - "targetRecipient": "https://discord.com/api/webhooks/...", - "notificationType": "discord", - "status": "pending", - "retries": 0, - "maxRetries": 3, - "priority": 1, - "eventId": "abc123", - "contractAddress": "CCEMX6...", - "metadata": null, - "createdAt": 1718640000000 + "pending": 15, + "processing": 3, + "completed": 1234, + "failed": 45, + "overdue": 2 } ``` -**Response `400`** — non-numeric `:id` +| Field | Type | Description | +|------------|--------|--------------------------------------------------------------| +| pending | number | Notifications waiting to be processed | +| processing | number | Notifications currently being processed | +| completed | number | Successfully delivered notifications | +| failed | number | Notifications that permanently failed after exhausting retries | +| overdue | number | Pending notifications that are past their `executeAt` time | + +**Response `500`** — database read failure ```json -{ "error": "Invalid notification ID" } +{ "error": "SQLITE_ERROR: ..." } ``` -**Response `404`** — no notification with that ID +**Response `503`** — scheduler feature is disabled ```json -{ "error": "Notification not found" } +{ "error": "Scheduler not enabled" } +``` + +--- + +### GET /api/schedule/execution-metrics + +Returns deduplicated delivery performance metrics. Each notification is counted exactly once regardless of how many retry attempts it took, preventing double-counting. + +**Response `200`** + +```json +{ + "totalNotifications": 100, + "successfulFirstAttempt": 70, + "successfulAfterRetry": 20, + "permanentFailures": 10, + "totalRetryAttempts": 35, + "averageRetriesPerNotification": 0.35, + "averageSuccessDurationMs": 845.5, + "averageFailureDurationMs": 2341.2 +} +``` + +| Field | Type | Description | +|------------------------------|--------|--------------------------------------------------------------------------------| +| totalNotifications | number | Completed or permanently failed notifications (one count per notification ID) | +| successfulFirstAttempt | number | Delivered successfully on the first attempt (zero retries) | +| successfulAfterRetry | number | Delivered successfully after one or more retries | +| permanentFailures | number | Failed permanently after exhausting all retries | +| totalRetryAttempts | number | Sum of retry counts across all notifications | +| averageRetriesPerNotification| number | `totalRetryAttempts / totalNotifications` | +| averageSuccessDurationMs | number | Average duration (ms) of the final successful delivery attempt | +| averageFailureDurationMs | number | Average duration (ms) of the final failed delivery attempt | + +**Computing success rate:** +```javascript +const successRate = + (metrics.successfulFirstAttempt + metrics.successfulAfterRetry) / + metrics.totalNotifications; +// Example: (70 + 20) / 100 = 0.90 ``` **Response `500`** — database read failure @@ -273,21 +485,27 @@ Returns a single scheduled notification by its numeric ID. --- -### GET /api/schedule/stats +### GET /api/schedule/retry-distribution -Returns aggregate statistics about the scheduled-notification queue. +Returns a breakdown of final outcomes grouped by retry count. Useful for tuning retry policies. **Response `200`** ```json -{ - "total": 100, - "pending": 20, - "delivered": 75, - "failed": 5 -} +[ + { "retryCount": 0, "successCount": 70, "failureCount": 0 }, + { "retryCount": 1, "successCount": 15, "failureCount": 2 }, + { "retryCount": 2, "successCount": 5, "failureCount": 3 }, + { "retryCount": 3, "successCount": 0, "failureCount": 5 } +] ``` +| Field | Type | Description | +|--------------|--------|--------------------------------------------------------------| +| retryCount | number | Number of retries before the final outcome | +| successCount | number | Notifications that succeeded after exactly `retryCount` retries | +| failureCount | number | Notifications that failed after exactly `retryCount` retries | + **Response `500`** — database read failure ```json @@ -363,80 +581,635 @@ Existing clients that read `total`, `limit`, `offset`, and `records` continue to --- -## Webhooks +## Notification Search -### POST /api/webhooks +### GET /api/notifications/search -Receives a signed webhook event payload. The request must carry a valid HMAC-SHA256 signature produced with a pre-shared secret. +Full-text and field-based search across scheduled notifications. -**Required Headers** +**Query Parameters** -| Header | Description | -|-------------------|--------------------------------------------------| -| `X-Signature` | HMAC-SHA256 hex digest of the raw request body | -| `X-Key-Id` | Identifier selecting which secret to verify with | +| Name | Type | Required | Description | +|----------|--------|----------|-------------------------------------------------------------------| +| q | string | No | Free-text search across payload, metadata, and recipient fields | +| sender | string | No | Filter by sender address or identifier | +| txHash | string | No | Filter by originating transaction hash | +| eventId | string | No | Filter by correlated contract event ID | +| status | string | No | Filter by status: `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`, `CANCELLED` | +| type | string | No | Filter by notification type: `discord`, `email`, `webhook`, `sms` | +| limit | number | No | Maximum results to return (default: 20) | +| offset | number | No | Number of results to skip (default: 0) | -**Response `202`** +**Response `200`** ```json -{ "status": "accepted" } +{ + "results": [ + { + "id": 42, + "payload": { "content": "Your task completed." }, + "notificationType": "discord", + "targetRecipient": "https://discord.com/api/webhooks/...", + "executeAt": "2024-06-20T15:00:00.000Z", + "status": "COMPLETED", + "eventId": "abc123", + "contractAddress": "CCEMX6...", + "createdAt": "2024-06-20T14:00:00.000Z" + } + ], + "total": 150, + "limit": 20, + "offset": 0 +} ``` -**Response `400`** — request body could not be read +**Response `500`** ```json -{ "error": "Failed to read request body" } +{ "error": "..." } ``` -**Response `401`** — `X-Signature` header absent +--- -```json -{ "error": "Missing signature header" } -``` +### GET /api/search/suggestions -**Response `401`** — `X-Key-Id` header absent +Returns autocomplete suggestions based on a partial query string. Useful for building search UIs. -```json -{ "error": "Missing key-id header" } -``` +**Query Parameters** -**Response `401`** — `X-Key-Id` value does not match any registered secret +| Name | Type | Required | Description | +|-------|--------|----------|--------------------------------------------------| +| q | string | Yes | Partial search string (minimum 1 character) | +| limit | number | No | Maximum suggestions to return (default: 10) | + +**Response `200`** ```json -{ "error": "Unknown key-id" } +{ + "suggestions": [ + "task_completed", + "task_created", + "transfer_complete" + ] +} ``` -**Response `401`** — signature does not match the computed HMAC +**Response `500`** ```json -{ "error": "Invalid signature" } +{ "error": "..." } ``` --- -## Rate Limiting - -The API enforces configurable rate limits to protect against abuse. Limits can be set globally or per-client using API keys or IP addresses. +## Batch Validation -### Rate Limit Headers +### POST /api/notifications/validate-batch -All responses include these headers when rate limiting is enabled: +Validates a batch of notification objects without persisting them. Useful for pre-flight checks before bulk scheduling. -| Header | Description | -|-------------------------|----------------------------------------------------------| -| `X-RateLimit-Limit` | Maximum requests allowed in the current window | -| `X-RateLimit-Remaining` | Requests remaining before hitting the limit | -| `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets | +**Request Body** -When a rate limit is exceeded: +Accepts either a JSON array of notification objects directly, or a wrapper object: -| Header | Description | -|---------------|-------------------------------------------------| -| `Retry-After` | Seconds to wait before retrying the request | +```json +[ + { + "payload": { "content": "Message 1" }, + "targetRecipient": "https://discord.com/api/webhooks/...", + "executeAt": "2024-06-20T15:00:00.000Z", + "notificationType": "discord" + }, + { + "payload": { "content": "Message 2" }, + "targetRecipient": "https://discord.com/api/webhooks/...", + "executeAt": "invalid-date" + } +] +``` -### GET /api/rate-limit/metrics +Or with a wrapper key: -Returns real-time rate limiting statistics for monitoring and analysis. +```json +{ + "notifications": [ ... ] +} +``` + +**Response `200`** — all items passed validation + +```json +{ + "valid": true, + "processedCount": 2, + "errors": [] +} +``` + +**Response `400`** — one or more items failed validation + +```json +{ + "valid": false, + "processedCount": 2, + "errors": [ + { + "index": 1, + "code": "INVALID_DATE", + "message": "executeAt is not a valid ISO 8601 date" + } + ] +} +``` + +| Field | Type | Description | +|----------------|---------|------------------------------------------------------------------------| +| valid | boolean | `true` only when zero validation errors were found | +| processedCount | number | Total items evaluated | +| errors | array | One entry per failed item; empty array when `valid` is `true` | +| errors[].index | number | Zero-based position of the failing item in the input array (`-1` for parse errors) | +| errors[].code | string | Machine-readable error code (e.g. `INVALID_DATE`, `MISSING_FIELD`, `PARSE_ERROR`) | +| errors[].message | string | Human-readable description | + +**Response `400`** — request body is not valid JSON + +```json +{ + "valid": false, + "processedCount": 0, + "errors": [{ "index": -1, "code": "PARSE_ERROR", "message": "Request body must be valid JSON." }] +} +``` + +--- + +## Notification Templates + +Templates allow reusable message bodies with variable substitution. The template service must be enabled at startup; all template endpoints return `503` when it is not. + +### GET /api/templates + +Lists all templates, with optional filtering. + +**Query Parameters** + +| Name | Type | Required | Description | +|-------------|---------|----------|-------------------------------------------------| +| channelType | string | No | Filter by channel type (e.g. `"discord"`, `"email"`) | +| activeOnly | boolean | No | When `true`, return only active templates | + +**Response `200`** + +```json +{ + "count": 3, + "templates": [ + { + "id": 1, + "uniqueKey": "task-completed", + "name": "Task Completed", + "description": "Sent when a task bounty is completed.", + "channelType": "discord", + "subjectTemplate": null, + "bodyTemplate": "Task {{taskId}} has been completed by {{contributor}}.", + "variables": ["taskId", "contributor"], + "defaultValues": {}, + "isActive": true, + "createdBy": "admin", + "createdAt": "2024-06-01T00:00:00.000Z", + "updatedAt": "2024-06-01T00:00:00.000Z" + } + ] +} +``` + +--- + +### POST /api/templates + +Creates a new notification template. + +**Request Body** + +```json +{ + "uniqueKey": "task-completed", + "name": "Task Completed", + "description": "Sent when a task bounty is completed.", + "channelType": "discord", + "bodyTemplate": "Task {{taskId}} has been completed by {{contributor}}.", + "subjectTemplate": null, + "variables": ["taskId", "contributor"], + "defaultValues": {}, + "createdBy": "admin" +} +``` + +| Field | Type | Required | Description | +|-----------------|----------|----------|----------------------------------------------------------| +| uniqueKey | string | Yes | URL-safe unique identifier for this template | +| name | string | Yes | Human-readable display name | +| channelType | string | Yes | Target channel: `"discord"`, `"email"`, `"webhook"`, `"sms"` | +| bodyTemplate | string | Yes | Handlebars-style template body with `{{variable}}` placeholders | +| description | string | No | Optional description | +| subjectTemplate | string | No | Subject line template (relevant for email) | +| variables | string[] | No | List of variable names used in the template | +| defaultValues | object | No | Default values for variables | +| createdBy | string | No | Identifier of the user creating the template | + +**Response `201`** + +```json +{ "id": 1, "uniqueKey": "task-completed" } +``` + +**Response `400`** — missing required fields + +```json +{ "error": "Missing required fields", "required": ["uniqueKey", "name", "channelType", "bodyTemplate"] } +``` + +**Response `409`** — `uniqueKey` already exists + +```json +{ "error": "Template with this unique key already exists" } +``` + +--- + +### GET /api/templates/:id + +Returns a template by its numeric ID. + +**Path Parameters** + +| Name | Description | +|------|----------------------| +| id | Template ID (integer) | + +**Response `200`** — template object (same shape as items in the list response) + +**Response `400`** — non-numeric `:id` + +```json +{ "error": "Invalid template ID" } +``` + +**Response `404`** + +```json +{ "error": "Template not found" } +``` + +--- + +### GET /api/templates/by-key/:uniqueKey + +Returns a template by its `uniqueKey`. + +**Path Parameters** + +| Name | Description | +|-----------|--------------------------| +| uniqueKey | The template's unique key | + +**Response `200`** — template object + +**Response `404`** + +```json +{ "error": "Template not found" } +``` + +--- + +### PUT /api/templates/:id + +Updates an existing template. Only the supplied fields are changed. + +**Path Parameters** + +| Name | Description | +|------|----------------------| +| id | Template ID (integer) | + +**Request Body** — any subset of the template fields: + +```json +{ + "bodyTemplate": "Task {{taskId}} was completed. Reward: {{reward}} XLM.", + "variables": ["taskId", "reward"] +} +``` + +**Response `200`** + +```json +{ "id": 1, "message": "Template updated successfully" } +``` + +**Response `404`** + +```json +{ "error": "Template not found" } +``` + +--- + +### DELETE /api/templates/:id + +Deactivates a template (soft-delete by default). Pass `?hard=true` to permanently delete. + +**Path Parameters** + +| Name | Description | +|------|----------------------| +| id | Template ID (integer) | + +**Query Parameters** + +| Name | Type | Required | Description | +|------|---------|----------|--------------------------------------------------------| +| hard | boolean | No | When `true`, permanently deletes the template record | + +**Response `200`** + +```json +{ "id": 1, "message": "Template deactivated" } +``` + +Or when `hard=true`: + +```json +{ "id": 1, "message": "Template deleted permanently" } +``` + +**Response `404`** + +```json +{ "error": "Template not found" } +``` + +--- + +### POST /api/templates/render + +Renders a template by substituting variables with provided context values. + +**Request Body** + +```json +{ + "templateId": 1, + "context": { + "taskId": "TASK-99", + "contributor": "GABC1234...XYZ" + } +} +``` + +| Field | Type | Required | Description | +|------------|--------|----------|----------------------------------------------------------------| +| templateId | number | No* | Template ID. Provide either `templateId` or `uniqueKey`. | +| uniqueKey | string | No* | Template unique key. Provide either `templateId` or `uniqueKey`. | +| context | object | Yes | Key-value map of variable names to substitution values | + +\* One of `templateId` or `uniqueKey` is required. + +**Response `200`** + +```json +{ + "subject": null, + "body": "Task TASK-99 has been completed by GABC1234...XYZ." +} +``` + +**Response `400`** — missing required fields + +```json +{ "error": "Missing required fields", "required": ["templateId OR uniqueKey", "context"] } +``` + +**Response `404`** + +```json +{ "error": "Template not found" } +``` + +--- + +### GET /api/templates/stats + +Returns usage statistics for all templates, or for a specific template. + +**Query Parameters** + +| Name | Type | Required | Description | +|------------|--------|----------|-------------------------------------| +| templateId | number | No | Limit stats to a specific template | + +**Response `200`** + +```json +{ + "totalTemplates": 5, + "activeTemplates": 4, + "renderCount": 1234, + "byTemplate": [ + { "id": 1, "uniqueKey": "task-completed", "renderCount": 800, "lastRenderedAt": "2024-06-20T14:00:00.000Z" } + ] +} +``` + +--- + +### GET /api/templates/:id/audit + +Returns the full audit history for a specific template (create, update, delete events). + +**Path Parameters** + +| Name | Description | +|------|----------------------| +| id | Template ID or unique key | + +**Response `200`** + +```json +{ + "templateId": "task-completed", + "records": [ + { + "action": "created", + "actor": "admin", + "timestamp": "2024-06-01T00:00:00.000Z", + "changes": {} + }, + { + "action": "updated", + "actor": "admin", + "timestamp": "2024-06-10T12:00:00.000Z", + "changes": { "bodyTemplate": "..." } + } + ] +} +``` + +**Response `404`** + +```json +{ "error": "Template not found" } +``` + +--- + +## Analytics + +### GET /api/analytics + +Returns a real-time aggregated snapshot of notification delivery metrics. Pass `?reset=true` to atomically read and reset the counters. + +**Query Parameters** + +| Name | Type | Required | Description | +|-------|---------|----------|------------------------------------------------------| +| reset | boolean | No | If `true`, resets the aggregator after reading | + +**Response `200`** + +```json +{ + "totalRecorded": 1500, + "successCount": 1350, + "failureCount": 100, + "retryCount": 50, + "byType": { + "discord": { "success": 900, "failure": 60 }, + "email": { "success": 450, "failure": 40 } + }, + "buckets": [ + { "startMs": 1718640000000, "endMs": 1718640060000, "count": 25 } + ] +} +``` + +**Response `503`** — analytics aggregator not available + +```json +{ "error": "Analytics aggregator unavailable" } +``` + +--- + +### GET /api/analytics/history + +Returns historical analytics snapshots persisted by the metrics store. Supports time-range filtering. + +**Query Parameters** + +| Name | Type | Required | Description | +|-------|--------|----------|---------------------------------------------------------------| +| limit | number | No | Maximum snapshots to return (default: 50, max: 100) | +| since | string | No | ISO 8601 lower bound — return only snapshots after this time | + +**Response `200`** + +```json +{ + "snapshots": [ + { + "capturedAt": "2024-06-20T14:00:00.000Z", + "totalRecorded": 1500, + "successCount": 1350, + "failureCount": 100 + } + ] +} +``` + +**Response `503`** — metrics store not configured + +```json +{ "error": "Metrics history store unavailable" } +``` + +--- + +## User Notification Preferences + +### POST /api/webhooks + +Receives a signed webhook event payload. The request must carry a valid HMAC-SHA256 signature produced with a pre-shared secret. + +**Required Headers** + +| Header | Description | +|-------------------|--------------------------------------------------| +| `X-Signature` | HMAC-SHA256 hex digest of the raw request body | +| `X-Key-Id` | Identifier selecting which secret to verify with | + +**Response `202`** + +```json +{ "status": "accepted" } +``` + +**Response `400`** — request body could not be read + +```json +{ "error": "Failed to read request body" } +``` + +**Response `401`** — `X-Signature` header absent + +```json +{ "error": "Missing signature header" } +``` + +**Response `401`** — `X-Key-Id` header absent + +```json +{ "error": "Missing key-id header" } +``` + +**Response `401`** — `X-Key-Id` value does not match any registered secret + +```json +{ "error": "Unknown key-id" } +``` + +**Response `401`** — signature does not match the computed HMAC + +```json +{ "error": "Invalid signature" } +``` + +--- + +## Rate Limiting + +The API enforces configurable rate limits to protect against abuse. Limits can be set globally or per-client using API keys or IP addresses. + +### Rate Limit Headers + +All responses include these headers when rate limiting is enabled: + +| Header | Description | +|-------------------------|----------------------------------------------------------| +| `X-RateLimit-Limit` | Maximum requests allowed in the current window | +| `X-RateLimit-Remaining` | Requests remaining before hitting the limit | +| `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets | + +When a rate limit is exceeded: + +| Header | Description | +|---------------|-------------------------------------------------| +| `Retry-After` | Seconds to wait before retrying the request | + +### GET /api/rate-limit/metrics + +Returns real-time rate limiting statistics for monitoring and analysis. **Query Parameters** @@ -525,6 +1298,7 @@ Content-Type: application/json --- +## Webhooks ## Health ### GET /health