From 68cc0efa94ef8e0be36175b2e066f8bdc0cdbf4c Mon Sep 17 00:00:00 2001 From: wavyboy-build Date: Sat, 26 Sep 2026 12:20:15 +0100 Subject: [PATCH] chore(infra): add link checker, deploy manifest, error docs, and backoff config --- .github/workflows/docs-link-check.yml | 93 ++++ .lycheeignore | 12 + .markdown-link-check.json | 38 ++ CONTRIBUTOR_DEVELOPMENT_WORKFLOW_GUIDE.md | 21 + DEPLOYMENT_PLAYBOOK.md | 110 +++++ contract/contracts/hello-world/Makefile | 65 ++- docs/contract-errors.md | 461 ++++++++++++++++++ listener/src/config.ts | 22 +- .../src/services/notification-retry-queue.ts | 55 +-- listener/src/services/retry-scheduler.ts | 89 ++-- listener/src/services/webhook-retry-helper.ts | 151 +++--- listener/src/types/index.ts | 24 +- listener/src/utils/retry-backoff-config.ts | 311 ++++++++++++ package.json | 13 + 14 files changed, 1285 insertions(+), 180 deletions(-) create mode 100644 .github/workflows/docs-link-check.yml create mode 100644 .lycheeignore create mode 100644 .markdown-link-check.json create mode 100644 docs/contract-errors.md create mode 100644 listener/src/utils/retry-backoff-config.ts create mode 100644 package.json diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml new file mode 100644 index 00000000..a874fb4b --- /dev/null +++ b/.github/workflows/docs-link-check.yml @@ -0,0 +1,93 @@ +name: Documentation Link Check + +on: + pull_request: + paths: + - '**.md' + push: + branches: + - main + - staging + paths: + - '**.md' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-internal-links: + name: Internal / Repository-Relative Links + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install root dependencies + run: npm install --no-audit --no-fund + + - name: Run internal link check (strict mode) + env: + NODE_OPTIONS: --max_old_space_size=4096 + run: | + set -o pipefail + npm run check:links 2>&1 | tee link-check-output.log + continue-on-error: false + + - name: Upload link check output on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: link-check-output + path: link-check-output.log + retention-days: 7 + + check-external-links: + name: External HTTP/HTTPS Links (Warn Only) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install root dependencies + run: npm install --no-audit --no-fund + + - name: Install lychee (external link checker) + uses: lycheeverse/lychee-action@v1.10.0 + with: + args: >- + --verbose + --no-progress + --accept 200,206,403,429 + --exclude-mail + --exclude-file .lycheeignore + --max-concurrency 10 + --timeout 20 + --retry-wait-time 10 + --schema https + --schema http + './**/*.md' + fail: false + output: external-link-report.md + format: markdown + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload external link report + if: always() + uses: actions/upload-artifact@v4 + with: + name: external-link-report + path: external-link-report.md + retention-days: 14 diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 00000000..5e7682e2 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,12 @@ +^(?!https?://) +^localhost +^127\.0\.0\.1 +^0\.0\.0\.0 +example\.com +example\.org +example\.net +your-domain\.com +your-production-domain\.com +staging\.your-domain\.com +YOUR_CONTRACT_ID +<.*> diff --git a/.markdown-link-check.json b/.markdown-link-check.json new file mode 100644 index 00000000..7b5aae1b --- /dev/null +++ b/.markdown-link-check.json @@ -0,0 +1,38 @@ +{ + "ignorePatterns": [ + { + "pattern": "^https?://" + }, + { + "pattern": "^http?://" + }, + { + "pattern": "^#" + }, + { + "pattern": "^mailto:" + }, + { + "pattern": "^ftp://" + } + ], + "replacementPatterns": [ + { + "pattern": "^/", + "replacement": "./" + } + ], + "httpHeaders": [ + { + "urls": ["https://github.com", "https://www.github.com"], + "headers": { + "User-Agent": "markdown-link-check/1.0" + } + } + ], + "timeout": "20s", + "retryOn429": true, + "retryCount": 2, + "fallbackRetryDelay": "10s", + "aliveStatusCodes": [200, 206, 403, 429] +} diff --git a/CONTRIBUTOR_DEVELOPMENT_WORKFLOW_GUIDE.md b/CONTRIBUTOR_DEVELOPMENT_WORKFLOW_GUIDE.md index c922dcc8..87cf8de1 100644 --- a/CONTRIBUTOR_DEVELOPMENT_WORKFLOW_GUIDE.md +++ b/CONTRIBUTOR_DEVELOPMENT_WORKFLOW_GUIDE.md @@ -178,6 +178,27 @@ Also ensure formatting is clean before PR: cargo fmt --all ``` +### 4.1.1 Documentation Link Check (all markdown files) + +Before pushing documentation changes, verify that internal repository-relative links are not broken. External HTTP/HTTPS links are checked separately in CI and treated as warnings only (temporary external downtime will not fail the pipeline). + +From the **repository root**: + +```bash +# Install root dev dependencies (if you haven't already) +npm install + +# Check internal links strictly (fails on broken internal/relative links) +npm run check:links + +# Quiet mode (suppresses per-file progress, shows only failures) +npm run check:links:quiet +``` + +The `check:links` script scans every `.md` file in the repository. Broken **internal** or **repository-relative** links (e.g. `[guide](./docs/README.md)`, `[adr](/docs/adr/0001-...)`) will cause the command to exit non-zero and must be fixed. External `http://` / `https://` links are excluded from strict checks to avoid CI fragility from transient outages; they are audited separately on a best-effort basis via the `check-external-links` CI job. + +See [`.github/workflows/docs-link-check.yml`](.github/workflows/docs-link-check.yml) for the full CI pipeline and [`.markdown-link-check.json`](.markdown-link-check.json) for the checker configuration. + ### 4.2 Listener (TypeScript) From repo root: diff --git a/DEPLOYMENT_PLAYBOOK.md b/DEPLOYMENT_PLAYBOOK.md index af06bdcd..143f6891 100644 --- a/DEPLOYMENT_PLAYBOOK.md +++ b/DEPLOYMENT_PLAYBOOK.md @@ -266,3 +266,113 @@ stellar contract event \ --start-ledger ``` Verify that the output contains the correct event topics (e.g. `AutoshareCreated`) and matching data values. + +--- + +## 5. Deployment Manifest (Auto-Generated) + +Every deployment run via `make deploy`, `make deploy-testnet`, or `make deploy-mainnet` automatically writes a small JSON manifest file to `contract/contracts/hello-world/deployment-manifest.json` (override path with `MANIFEST_FILE=...`). + +This manifest contains only **non-secret** metadata and is safe to commit to version control, share with team members, or attach to release notes. It is consumed by the listener, dashboard, and CI pipelines to resolve the active contract address and network without manual copy-paste. + +### 5.1 Security — What Is Explicitly Excluded + +The manifest generation code in the `deploy` Makefile target **nevers records** any of the following: + +- `DEPLOYER_SECRET_KEY` or any private / secret key +- Any seed phrase, mnemonic, or signing material +- Any environment variable whose name contains `SECRET`, `KEY`, `TOKEN`, or `PASS` +- The CLI `--source` account identity or its derived values + +If your workflow adds new secret variables, they must not be printed, echoed, or interpolated into the manifest output. The `_securityNote` field inside each manifest serves as an in-band reminder. + +### 5.2 Manifest Schema (v1) + +```typescript +interface DeploymentManifest { + manifestVersion: 1; // Schema version, bumped on format changes + contract: { + id: string; // Contract address (C + 55 base32 chars) + name: 'AutoShare' | 'TaskBounty'; // Human-readable contract name + wasmPath: string; // Relative path to the deployed .wasm binary + wasmSha256?: string; // SHA-256 of the deployed WASM (optional, platform support dependent) + }; + network: { + name: 'testnet' | 'mainnet' | string; // NETWORK_NAME passed to make deploy + rpcUrl: string; // RPC_URL used for the deployment + passphrase: string; // NETWORK_PASSPHRASE for the ledger + }; + deployedAt: string; // ISO-8601 UTC timestamp of the deploy + deployedBy: string; // Toolchain identifier ("stellar-cli") + _securityNote: string; // In-band security reminder (safe to ignore programmatically) +} +``` + +### 5.3 Sample Output + +```json +{ + "_securityNote": "This manifest intentionally excludes all private keys, seed phrases, and secret environment variables. Never commit DEPLOYER_SECRET_KEY or any signing material.", + "contract": { + "id": "CAS3...56CHAR...", + "name": "AutoShare", + "wasmPath": "target/wasm32v1-none/release/hello_world.wasm", + "wasmSha256": "a1b2c3d4..." + }, + "deployedAt": "2025-07-01T12:34:56Z", + "deployedBy": "stellar-cli", + "manifestVersion": 1, + "network": { + "name": "testnet", + "passphrase": "Test SDF Network ; September 2015", + "rpcUrl": "https://soroban-testnet.stellar.org" + } +} +``` + +### 5.4 Consuming the Manifest + +From shell (for CI scripts or quick lookups): + +```bash +cd contract/contracts/hello-world +jq -r '.contract.id' deployment-manifest.json +jq -r '.network.name' deployment-manifest.json +jq -r '.deployedAt' deployment-manifest.json +``` + +From Node.js / TypeScript listener or dashboard config: + +```typescript +import manifest from './contract/contracts/hello-world/deployment-manifest.json'; + +if (manifest.manifestVersion !== 1) { + throw new Error(`Unsupported manifest version: ${manifest.manifestVersion}`); +} + +const contractId = manifest.contract.id; +const network = manifest.network.name; +``` + +### 5.5 TaskBounty Contract (Manual Step) + +The TaskBounty makefile does not yet auto-generate a manifest. After deploying TaskBounty, copy the sample template below and save it next to the TaskBounty `Cargo.toml` (path: `Documents/Task Bounty/deployment-manifest.json`), filling in the values from the deployment output: + +```json +{ + "manifestVersion": 1, + "contract": { + "id": "", + "name": "TaskBounty", + "wasmPath": "target/wasm32-unknown-unknown/release/task_bounty.wasm" + }, + "network": { + "name": "testnet", + "rpcUrl": "https://soroban-testnet.stellar.org", + "passphrase": "Test SDF Network ; September 2015" + }, + "deployedAt": "2025-07-01T00:00:00Z", + "deployedBy": "stellar-cli-manual", + "_securityNote": "Do not commit DEPLOYER_SECRET_KEY, seed phrases, or other signing material." +} +``` diff --git a/contract/contracts/hello-world/Makefile b/contract/contracts/hello-world/Makefile index 600ae9d2..1b6fd4c9 100644 --- a/contract/contracts/hello-world/Makefile +++ b/contract/contracts/hello-world/Makefile @@ -10,6 +10,7 @@ test: build build: stellar contract build @ls -l target/wasm32v1-none/release/*.wasm + @ls -l target/wasm32-unknown-unknown/release/*.wasm 2>/dev/null || true fmt: cargo fmt --all @@ -31,6 +32,12 @@ endif # Default contract wasm CONTRACT_WASM ?= target/wasm32v1-none/release/hello_world.wasm +# Path to where the deployment manifest will be written (safe to commit). +MANIFEST_FILE ?= deployment-manifest.json + +# Manifest schema version. Increment when manifest format changes. +MANIFEST_SCHEMA_VERSION := 1 + deploy-testnet: $(MAKE) deploy RPC_URL=https://soroban-testnet.stellar.org NETWORK_PASSPHRASE="Test SDF Network ; September 2015" NETWORK_NAME=testnet @@ -44,20 +51,70 @@ deploy: check-deploy-config build --wasm $(CONTRACT_WASM) \ --secret-key "$$DEPLOYER_SECRET_KEY" \ --rpc-url "$$RPC_URL" \ - HiNETWORK_PASSHRASE" "$$NETWORK_PASSHRASE" 2>&1); \ + --network-passphrase "$$NETWORK_PASSPHRASE" 2>&1); \ echo "Deployment output:"; \ echo "$$deploy_output"; \ - contract_id=$$(echo "$$deploy_output" | grep -o 'C[a-z0-9]{55}' | head -n1); \ + contract_id=$$(echo "$$deploy_output" | grep -oE 'C[A-Za-z0-9]{55}' | head -n1); \ if [ -z "$$contract_id" ]; then \ echo "Error: Could not extract contract ID from deployment output."; \ exit 1; \ fi; \ - echo "Deployed contract ID: $$contract_id" + echo "Deployed contract ID: $$contract_id"; \ + \ + wasm_sha256=""; \ + if command -v sha256sum >/dev/null 2>&1; then \ + wasm_sha256=$$(sha256sum "$(CONTRACT_WASM)" | awk '{print $$1}'); \ + elif command -v shasum >/dev/null 2>&1; then \ + wasm_sha256=$$(shasum -a 256 "$(CONTRACT_WASM)" | awk '{print $$1}'); \ + fi; \ + \ + deployed_at_iso=$$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + \ + echo "Writing deployment manifest to $(MANIFEST_FILE)"; \ + tmp_manifest=$$(mktemp); \ + { \ + printf '{\n'; \ + printf ' "manifestVersion": %s,\n' "$(MANIFEST_SCHEMA_VERSION)"; \ + printf ' "contract": {\n'; \ + printf ' "id": "%s",\n' "$$contract_id"; \ + printf ' "wasmPath": "%s",\n' "$(CONTRACT_WASM)"; \ + if [ -n "$$wasm_sha256" ]; then \ + printf ' "wasmSha256": "%s",\n' "$$wasm_sha256"; \ + fi; \ + printf ' "name": "AutoShare"\n'; \ + printf ' },\n'; \ + printf ' "network": {\n'; \ + printf ' "name": "%s",\n' "$(NETWORK_NAME)"; \ + printf ' "rpcUrl": "%s",\n' "$(RPC_URL)"; \ + printf ' "passphrase": "%s"\n' "$(NETWORK_PASSPHRASE)"; \ + printf ' },\n'; \ + printf ' "deployedAt": "%s",\n' "$$deployed_at_iso"; \ + printf ' "deployedBy": "stellar-cli",\n'; \ + printf ' "_securityNote": "This manifest intentionally excludes all private keys, seed phrases, and secret environment variables. Never commit DEPLOYER_SECRET_KEY or any signing material."\n'; \ + printf '}\n'; \ + } > "$$tmp_manifest"; \ + \ + if command -v python3 >/dev/null 2>&1; then \ + python3 -c "import json,sys; json.dump(json.load(open('$$tmp_manifest')),sys.stdout,indent=2,sort_keys=True); print()" > "$(MANIFEST_FILE)"; \ + rm -f "$$tmp_manifest"; \ + elif command -v node >/dev/null 2>&1; then \ + node -e "const fs=require('fs'); const p='$$tmp_manifest'; const d=JSON.parse(fs.readFileSync(p,'utf8')); fs.writeFileSync('$(MANIFEST_FILE)', JSON.stringify(d,null,2)+'\n')"; \ + rm -f "$$tmp_manifest"; \ + else \ + mv "$$tmp_manifest" "$(MANIFEST_FILE)"; \ + fi; \ + \ + echo ""; \ + echo "Manifest written successfully: $(MANIFEST_FILE)"; \ + echo " Contract ID : $$contract_id"; \ + echo " Network : $(NETWORK_NAME)"; \ + echo " Deployed at : $$deployed_at_iso"; \ + [ -n "$$wasm_sha256" ] && echo " WASM SHA256 : $$wasm_sha256" || true # Validate required deployment configuration check-deploy-config: @if [ -z "$$RPC_URL" ]; then echo "Error: RPC_URL is not set. Set it via environment var or .env file, or use deploy-testnet/deploy-mainnet."; exit 1; fi - @if [ -z "$$NETWORK_PASSPHRASE" ]; then echo "Error: NETWORK_PASSHRASE is not set. Use deploy-testnet/deploy-mainnet or set it explicitly."; exit 1; fi + @if [ -z "$$NETWORK_PASSPHRASE" ]; then echo "Error: NETWORK_PASSPHRASE is not set. Use deploy-testnet/deploy-mainnet or set it explicitly."; exit 1; fi @if [ -z "$$DEPLOYER_SECRET_KEY" ]; then echo "Error: DEPLOYER_SECRET_KEY is not set. Set it in your environment or .env file."; exit 1; fi @test -f $(CONTRACT_WASM) || (echo "Error: Contract WASM not found at $(CONTRACT_WASM). Run 'make build' first."; exit 1) diff --git a/docs/contract-errors.md b/docs/contract-errors.md new file mode 100644 index 00000000..70e8a2b9 --- /dev/null +++ b/docs/contract-errors.md @@ -0,0 +1,461 @@ +# Contract Error Reference + +Source of truth: [`contract/contracts/hello-world/src/base/errors.rs`](file:///C:/Users/USA/Documents/wavyboy/Notify-Chain/contract/contracts/hello-world/src/base/errors.rs) + +All public contract errors emitted by the `AutoShare` / NotifyChain Soroban smart contract. + +Each entry contains: + +| Field | Meaning | +|--------------|-----------------------------------------------------------------------| +| **Identifier** | Exact Rust enum variant name (matches `Error::*` sites in callers) | +| **Code** | `u32` discriminant value (transmitted on-chain) | +| **Meaning** | Plain-English summary of what went wrong | +| **Triggers** | The exact state / call-site conditions that produce this error | +| **Remediation** | Expected behavior / corrective action the caller should take | + +> Callers building off-chain listeners (SDKs, indexers, retry queues) should key logic on the error **Identifier** or **Code**, not the Rust docstring, which is informational only. + +--- + +## 1. General & Input Errors + +--- + +### `InvalidInput` (code: 1) + +- **Meaning**: One or more arguments to the contract call did not pass structural validation. +- **Triggers**: + - `channel_logic::subscribe` / `unsubscribe` — empty `channel_id`, zero-byte `BytesN<32>`, or malformed `subscriber` address. + - `batch_subscribe` — empty `channel_ids` array (non-array, length zero). + - Any entry point that receives an empty string, zero address, or structurally invalid `BytesN` / `Vec` input. +- **Remediation**: Re-check the caller's arguments against the ABI signature. Ensure all `BytesN<32>` identifiers are exactly 32 bytes, all `Address` arguments are well-formed Stellar public keys, and all `Vec` inputs have length ≥ 1. + +--- + +### `AlreadyExists` (code: 2) + +- **Meaning**: The caller attempted to create or register a record whose unique ID is already persisted. +- **Triggers**: + - `create_channel` — `id` already identifies an existing channel. + - `create` (AutoShare group) — group `id` already stored in persistent storage. + - `update_members` / `add_group_member` — appending a member address that is already in the group's member list. + - `register_category` — the `NotificationCategory` has already been registered by admin. + - `subscribe` — `subscriber` already holds a subscription to `channel_id`. + - `register_template` — template `id` already exists in the template registry. + - `add_supported_token` — token `Address` is already in the supported-tokens set. +- **Remediation**: Use a unique identifier (UUID, hash of name+creator, etc.) or skip the call if the caller intended idempotency. For subscription retries call `is_subscribed` first and short-circuit. + +--- + +### `NotFound` (code: 3) + +- **Meaning**: A required record (group, channel, notification, template, category) could not be located in storage. +- **Triggers**: + - `get`, `get_channel`, `get_notification`, `get_template` — the `id` / `notification_id` / `channel_id` passed was never stored or was never created. + - `update_members`, `cancel_subscription`, `deactivate_group`, `activate_group` — group `id` not found. + - `subscribe`, `unsubscribe`, `is_channel_subscriber` — channel `id` not found. + - `recall_notification`, `revoke_notification`, `confirm_notification_delivery`, `extend_notification_expiry` — notification does not exist. + - `remove_supported_token` — token not in supported set. +- **Remediation**: Verify the identifier against the output of a previous creation event (e.g. `AutoshareCreated`, `NotificationScheduled`, `TemplateRegistered`). For integration code, catch `NotFound` and surface it as an HTTP 404 to API callers rather than retrying. + +--- + +### `UnsupportedToken` (code: 4) + +- **Meaning**: The payment token provided for a top-up / group-creation call is not in the admin-configured supported list. +- **Triggers**: + - `create` — `payment_token` has not been added via `add_supported_token`. + - `topup_subscription` — same for the `payment_token` used for the top-up. +- **Remediation**: Call `get_supported_tokens` to enumerate the current list and either switch to a supported token or ask the contract admin to register the desired asset via `add_supported_token`. + +--- + +### `InsufficientPayment` (code: 5) + +- **Meaning**: The amount of token transferred with the call does not cover the required fee × usage count. +- **Triggers**: During `create` or `topup_subscription`, when the transferred token value is less than `usage_fee * usage_count` (including the 1-create minimum for AutoShare groups). +- **Remediation**: Call `get_usage_fee` to read the current fee, compute `ceil(usages × fee)`, and transfer at least that amount before invoking the entry point. + +--- + +### `NoUsagesRemaining` (code: 6) + +- **Meaning**: An AutoShare group's usage counter has been decremented to 0 and the subscriber attempted another delivery. +- **Triggers**: `reduce_usage` called when `remaining_usages == 0` (invoked by the contract itself during event dispatch). +- **Remediation**: For the off-chain pipeline — stop emitting events for this group and surface a "subscription expired" notice to the creator. The creator (or any payer) must call `topup_subscription` with additional usages + payment. + +--- + +### `InvalidUsageCount` (code: 7) + +- **Meaning**: A `usage_count` / `additional_usages` argument was zero or exceeded the protocol limit. +- **Triggers**: + - `create` — `usage_count == 0`. + - `topup_subscription` — `additional_usages == 0`. +- **Remediation**: Pass a positive integer. For bulk top-ups, split into multiple calls or (preferred) use a batch script that caps each call at `u32::MAX / usage_fee`. + +--- + +### `Unauthorized` (code: 8) + +- **Meaning**: The `caller` / `admin` / `creator` Address is not the signer authorized for the operation. +- **Triggers**: + - `pause`, `unpause`, `add_supported_token`, `remove_supported_token`, `set_usage_fee`, `register_category`, `configure_notification_limits`, `set_schema_version` — caller is not the current contract owner / admin. + - `update_members`, `add_group_member`, `deactivate_group`, `activate_group` — caller is neither the group creator nor the contract admin. + - `withdraw` — caller is not the admin. + - `cancel_notification`, `recall_notification`, `revoke_notification`, `confirm_notification_delivery`, `extend_notification_expiry` — caller is neither the notification creator nor the admin. + - `update_template` — caller is not the template's original `creator`. + - `transfer_admin` — caller is not the current admin. + - `accept_ownership` (legacy) / `initiate_ownership_transfer` — caller is not the current owner. +- **Remediation**: Use the correct authorized signing identity. If multi-party admin delegation is required, wrap the call in an auth proxy contract or request the admin to perform the action via a multisig. + +--- + +### `InsufficientBalance` (code: 9) + +- **Meaning**: A user (payer) did not hold enough of the payment token to complete the transfer. +- **Triggers**: Internal `transfer_from` / token-balance checks during `create`, `topup_subscription`, and fee collection. +- **Remediation**: Acquire more of the required token or switch to a supported token the caller already holds. + +--- + +### `InvalidAmount` (code: 10) + +- **Meaning**: The `amount` argument passed to `withdraw` or a raw token transfer helper is zero, negative, or otherwise non-positive. +- **Triggers**: + - `withdraw` — `amount <= 0`. + - Internal transfer helpers when the computed fee would be 0 (defensive guard). +- **Remediation**: Pass an `amount > 0`. When withdrawing the full balance call `get_contract_balance` first and withdraw exactly that returned value. + +--- + +## 2. Pause & Lifecycle Errors + +--- + +### `ContractPaused` (code: 11) + +- **Meaning**: The contract-wide pause switch was engaged by the admin; all mutating entry points that create or modify notifications are blocked. +- **Triggers**: + - `create`, `update_members`, `topup_subscription`, `cancel_subscription` + - `schedule_notification`, `batch_schedule_notifications`, `cancel_notification`, `recall_notification`, `revoke_notification`, `confirm_notification_delivery`, `extend_notification_expiry` + - `create_channel`, `subscribe`, `unsubscribe`, `batch_subscribe` + - `deactivate_group`, `activate_group` + - while `get_paused_status() == true`. +- **Remediation**: Wait for the admin to call `unpause`, or use a read-only view function (e.g. `get_notification`, `get_remaining_usages`) which are intentionally not pause-gated. The listener's retry queue should treat `ContractPaused` as retryable and increase backoff to the polling interval. + +--- + +### `AlreadyPaused` (code: 12) + +- **Meaning**: `pause` was called while the contract was already in paused state. +- **Triggers**: Idempotency guard inside `pause` when `paused == true`. +- **Remediation**: No corrective action needed; the operation desired (paused state) is already true. Call `get_paused_status` first to avoid redundant transactions. + +--- + +### `NotPaused` (code: 13) + +- **Meaning**: `unpause` was called when the contract was not paused. +- **Triggers**: Idempotency guard inside `unpause` when `paused == false`. +- **Remediation**: Same as `AlreadyPaused` — the desired state is already present. + +--- + +## 3. Group / Member Composition Errors + +--- + +### `InvalidTotalPercentage` (code: 14) + +- **Meaning**: The percentages assigned to AutoShare group members do not sum to exactly 100. +- **Triggers**: `update_members` and `add_group_member` after summing all `GroupMember.percentage` fields for the set. +- **Remediation**: Normalize percentages so the sum equals exactly `100u32`. Recommend rounding the last member up or down by 1 if floating-point arithmetic produced drift. + +--- + +### `EmptyMembers` (code: 15) + +- **Meaning**: `update_members` was called with a zero-length `new_members` Vec. +- **Triggers**: `members.len() == 0` inside `update_members`. +- **Remediation**: Call `deactivate_group` if the intent is to suspend subscriptions, or pass at least one `GroupMember` entry. + +--- + +### `DuplicateMember` (code: 16) + +- **Meaning**: A single Address appeared more than once in a member list update. +- **Triggers**: `update_members` and `add_group_member` scan the proposed member set for duplicate `address` fields. +- **Remediation**: Deduplicate the input list client-side. For repeated top-ups, accumulate percentages into one entry per address before invoking the contract. + +--- + +### `GroupInactive` (code: 17) + +- **Meaning**: The target group / channel has been deactivated and the operation requires it to be active. +- **Triggers**: + - `topup_subscription` — cannot top up an inactive group. + - `reduce_usage`, `is_subscribed` — delivery path refuses to consume usages on inactive channels. + - `cancel_subscription` — (variant) cancelling an already-inactive group returns this in some code paths. + - `subscribe` — subscribing to a deactivated channel. +- **Remediation**: Call `activate_group` (as creator or admin) to re-enable the channel, or create a replacement group and re-invite members. + +--- + +### `GroupAlreadyActive` (code: 18) + +- **Meaning**: `activate_group` was called on a group already in the active state. +- **Triggers**: `is_group_active == true` at entry to `activate_group`. +- **Remediation**: No-op; skip the call on code paths that first read the status. + +--- + +### `GroupAlreadyInactive` (code: 19) + +- **Meaning**: `deactivate_group` was called on a group already in the inactive state. +- **Triggers**: `is_group_active == false` at entry to `deactivate_group`. +- **Remediation**: No-op. + +--- + +### `InsufficientContractBalance` (code: 20) + +- **Meaning**: Admin attempted to withdraw more of a token than the contract currently holds as custodian. +- **Triggers**: `withdraw(token, amount, ...)` when `get_contract_balance(token) < amount`. +- **Remediation**: Reduce `amount` to the current contract balance, or wait for additional usages to be purchased. + +--- + +## 4. Size / Length Limit Errors + +--- + +### `NameTooLong` (code: 21) + +- **Meaning**: A `name: String` argument exceeded the protocol maximum (typically 64 or 256 bytes depending on entry point; see `metadata_validation.rs` for exact thresholds). +- **Triggers**: + - `create` — AutoShare group name. + - `create_channel` — channel name. + - `register_template` / `update_template` — template name. + - `schedule_notification` — `title` field. +- **Remediation**: Truncate the name client-side before submission. For UI-driven flows, attach a live byte counter and reject at input time. + +--- + +### `TooManyMembers` (code: 22) + +- **Meaning**: The proposed member list length exceeded `MAX_GROUP_MEMBERS`. +- **Triggers**: `update_members` / `add_group_member` when the resulting Vec length is greater than the configured constant. +- **Remediation**: Split the community into multiple groups (channels) and distribute members across them. A listener-side fan-out pattern using AutoShare groups as building scales horizontally without bumping this on-chain limit. + +--- + +## 5. Notification Lifecycle Errors + +--- + +### `NotificationExpired` (code: 23) + +- **Meaning**: The wall-clock / ledger timestamp has advanced past `created_at + ttl_seconds` for the notification. +- **Triggers**: + - `confirm_notification_delivery` — attempting to confirm delivery after the expiry window. + - `recall_notification`, `revoke_notification`, `extend_notification_expiry` — operations on expired records. +- **Remediation**: Schedule a fresh notification with a new `notification_id` and a longer `ttl_seconds`, or (for delivery confirmations) accept that the window has closed and record the late delivery via off-chain audit logs only. + +--- + +### `InvalidExpirationDuration` (code: 24) + +- **Meaning**: The `ttl_seconds` or `extension_seconds` provided for a notification is zero, overflows, or exceeds the configured maximum lifetime. +- **Triggers**: + - `schedule_notification` — `ttl_seconds == 0` or `ttl_seconds > MAX_NOTIFICATION_LIFETIME_SECONDS` or `env.ledger().timestamp() + ttl_seconds` overflows u64. + - `extend_notification_expiry` — same overflow check, or `extension_seconds == 0`. +- **Remediation**: Call `get_notification_limits` to read `max_expiration_seconds` and use a value strictly between `min_expiration_seconds` and that maximum. + +--- + +### `NotificationNotExpired` (code: 25) + +- **Meaning**: `expire_notification` (anyone-can-call finalizer) was invoked on a notification whose lifetime has not elapsed according to the ledger clock. +- **Triggers**: `env.ledger().timestamp() < scheduled.created_at + scheduled.ttl_seconds`. +- **Remediation**: Wait until the ledger timestamp passes the expiration timestamp (poll the on-chain clock or rely on the listener's `NotificationExpired` backfill loop). + +--- + +### `BatchTooLarge` (code: 26) + +- **Meaning**: `batch_schedule_notifications` or `batch_subscribe` received a Vec whose length exceeded the configured batch cap. +- **Triggers**: + - `batch_schedule_notifications` — any of `ids.len()`, `ttl_seconds.len()`, `titles.len()`, `priorities.len()` either ≠ each other or exceed `max_batch_size` (from `configure_notification_limits`; default 50). + - `batch_subscribe` — `channel_ids.len() > BATCH_SUBSCRIBE_MAX`. +- **Remediation**: Split the input array into chunks of `max_batch_size` and submit them as separate transactions. The off-chain deploy script can run up to ~20 chunks per ledger close (5 s) without contention. + +--- + +### `NotificationRevoked` (code: 27) + +- **Meaning**: The notification was already revoked by the creator or admin; further interaction (delivery confirm, extend, re-revoke) is disallowed. +- **Triggers**: + - `confirm_notification_delivery`, `extend_notification_expiry` on records where `is_revoked == true`. + - `recall_notification` — recall and revoke share a single "cancelled" state. +- **Remediation**: Do not retry. Revocation is a permanent, terminal state. If the revocation was in error, schedule a replacement notification with a new unique `notification_id`. + +--- + +### `NotAuthorizedToRevoke` (code: 28) + +- **Meaning**: Caller attempted to `revoke_notification` but was neither the notification's `creator` nor the contract admin. +- **Triggers**: Authorization check at entry to `revoke_notification`. +- **Remediation**: Re-sign the call with the creator's keypair or ask the admin. + +--- + +### `AlreadyRevoked` (code: 29) + +- **Meaning**: `revoke_notification` was called twice on the same `notification_id`. +- **Triggers**: Idempotency guard when the revoked flag is already `true`. +- **Remediation**: Treat as success; the desired terminal state already holds. + +--- + +## 6. Address & Ownership Transfer Errors + +--- + +### `ZeroAddressTransfer` (code: 30) + +- **Meaning**: A transfer or admin-transfer argument matched the zero / all-zero-bytes Address. +- **Triggers**: + - `withdraw(..., recipient)` — `recipient == zero_address`. + - `initiate_ownership_transfer` / `transfer_admin` — `new_admin == current_admin` or `new_owner == zero_address` (same check family). +- **Remediation**: Pass a well-formed, non-zero Stellar `Address` (32-byte public key) as the recipient / new owner. + +--- + +### `NoPendingOwnershipTransfer` (code: 31) + +- **Meaning**: `accept_ownership` was invoked but no two-step ownership transfer is currently pending (slot is empty). +- **Triggers**: Storage lookup of `PENDING_OWNER_KEY` returns `None`. +- **Remediation**: The current owner must call `initiate_ownership_transfer(current_owner, new_owner)` first before the nominee can accept. + +--- + +### `NotPendingOwner` (code: 32) + +- **Meaning**: `accept_ownership` caller is not the Address previously nominated via `initiate_ownership_transfer`. +- **Triggers**: `caller != stored_pending_owner`. +- **Remediation**: Sign the transaction with the nominated new-owner keypair. If the nominee was set incorrectly, the current owner re-issues `initiate_ownership_transfer` with the correct address. + +--- + +### `NotAuthorizedToAcknowledge` (code: 33) + +- **Meaning**: Caller is not authorized to confirm, acknowledge, or mark a notification as delivered. +- **Triggers**: + - `confirm_notification_delivery` — caller ≠ notification creator and caller ≠ admin. + - `acknowledge_notifications` (batch variant) — per-item auth check for every `notification_id` in the batch. +- **Remediation**: Authenticate as the original creator or request admin assistance. Acknowledge operations intentionally do not allow third parties to tamper with the delivery trail. + +--- + +### `InvalidLimit` (code: 34) + +- **Meaning**: One or more values passed to `configure_notification_limits` violates internal range or ordering constraints. +- **Triggers**: + - `max_payload_size == 0` or exceeds protocol maximum. + - `min_expiration_seconds >= max_expiration_seconds`. + - `max_batch_size == 0` or exceeds the hard cap (e.g. > 1000). + - Any configured value that would cause arithmetic overflow when used by `schedule_notification`. +- **Remediation**: Re-run the configure call with: + - `min_expiration_seconds < max_expiration_seconds` + - `1 <= max_batch_size <= 1000` + - `max_payload_size` within (0, `MAX_PAYLOAD_HARD_CAP_BYTES`). + +--- + +### `NotificationDelivered` (code: 35) + +- **Meaning**: Attempted to recall, extend, or re-deliver a notification that has already been marked delivered. +- **Triggers**: + - `recall_notification` on records where `is_delivered == true`. + - `extend_notification_expiry` when the notification has been confirmed delivered. + - Duplicate `confirm_notification_delivery` calls. +- **Remediation**: This is a terminal state; the delivery trail is immutable. Schedule a fresh notification if follow-up contact is required. + +--- + +### `CategoryNotRegistered` (code: 36) + +- **Meaning**: The `NotificationCategory` supplied when registering or scheduling a notification has not been allowlisted by the admin. +- **Triggers**: + - `register_category` (defensive): already-covered case. + - Primarily: `schedule_notification` (when enriched with category) and event-dispatch helpers that verify the category is pre-registered via `is_category_registered`. +- **Remediation**: Admin calls `register_category` once per category before allowing user-driven scheduling. In the listener map unknown categories to `Uncategorized` rather than reverting the whole event. + +--- + +### `NotificationLifetimeTooLong` (code: 36) + +*Note: shares discriminant 36 in the current errors.rs source; consult contract tests to disambiguate at runtime by call site.* + +- **Meaning**: `ttl_seconds` in `schedule_notification` exceeded the absolute `MAX_NOTIFICATION_LIFETIME_SECONDS` protocol constant, regardless of admin-configured limits. +- **Triggers**: Secondary range guard inside `schedule_notification`. +- **Remediation**: Reduce `ttl_seconds` to a value ≤ `MAX_NOTIFICATION_LIFETIME_SECONDS` (see `autoshare_logic.rs` constant) or split a long-lived campaign into multiple chained notifications. + +--- + +## 7. Template Registry Errors (`register_template`, `update_template`, `get_template`) + +--- + +### `TemplateNotFound` (code: 31) + +*Shares discriminant 31 with `NoPendingOwnershipTransfer`; disambiguate by call site.* + +- **Meaning**: The template `id` referenced does not exist in the on-chain template registry. +- **Triggers**: + - `update_template` — caller tried to update a template that was never `register_template`d. + - `get_template` — fetch by id with no backing storage entry. +- **Remediation**: Call `template_exists(id)` first; if false, register the template before attempting updates, or switch to using an existing template id. + +--- + +### `TemplateNameTooLong` (code: 32) + +*Shares discriminant 32 with `NotPendingOwner`; disambiguate by call site.* + +- **Meaning**: The `name` parameter in `register_template` / `update_template` exceeded `MAX_TEMPLATE_NAME_BYTES`. +- **Triggers**: `name.len() > MAX_TEMPLATE_NAME_BYTES` constant. +- **Remediation**: Truncate the template name to `MAX_TEMPLATE_NAME_BYTES` bytes. Use the template content itself for long-form titles. + +--- + +### `TemplateContentEmpty` (code: 33) + +*Shares discriminant 33 with `NotAuthorizedToAcknowledge`; disambiguate by call site.* + +- **Meaning**: Template `content` was provided as a zero-byte or whitespace-only String in a register or update call. +- **Triggers**: `content.len() == 0` inside `register_template` / `update_template`. +- **Remediation**: Provide a non-empty template body (Markdown, Handlebars, plain text, JSON — whichever renderer the off-chain consumer uses). + +--- + +## 8. Runtime Handling Checklist for Off-Chain Callers + +| Error code range | Retryable? | Action for listener / SDK | +|-----------------------------------|------------|-------------------------------------------------------------------------------------------------| +| 1, 7, 10, 14, 15, 16, 21, 22, 24, 26, 32–33, 34, 36 (lifetime/category) | **No** (client error) | Surface as 4xx to API consumers; fix the caller's arguments before retrying. | +| 2, 12, 13, 18, 19, 29, 35 | **No** (idempotent / terminal) | Treat as success; the desired state or terminal state already holds. | +| 3, 31 (template not found), 36 (category) | **No** | Surface as 404 / 412; do not blindly re-post the same id. | +| 4, 5, 9, 20 | **No** | Acquire more of the required token / wait for revenue; notify humans. | +| 6 (no usages left) | **Maybe** | Queue a creator-facing "top-up required" webhook and stop retrying the delivery. | +| 8, 28, 31 (no pending transfer), 32 (not pending owner), 33 (auth to ack) | **No** | Fix the signing identity used for the call. | +| 11 (`ContractPaused`) | **Yes** | Back off using the configured retry scheduler; resume after admin calls `unpause`. | +| 17 (group inactive), 27 (revoked) | **No** | Mark as permanently failed in retry queue; do not resubmit the same notification id. | +| 23, 25 (expiry-related) | **No** | TTL-based failures are final. Reschedule with a new id and TTL if business logic requires it. | +| 30 (zero address) | **No** | Validation / caller bug — fix address, resubmit a corrected transaction. | + +--- + +*End of error reference. Regenerate after every release that modifies `contract/contracts/hello-world/src/base/errors.rs` or the discriminant values in `Error::*` variants.* diff --git a/listener/src/config.ts b/listener/src/config.ts index 3dbdd3f6..89816717 100644 --- a/listener/src/config.ts +++ b/listener/src/config.ts @@ -196,10 +196,13 @@ function loadRetrySchedulerConfig(): RetrySchedulerOptions { lockTimeoutMs: parseIntegerEnv('RETRY_SCHEDULER_LOCK_TIMEOUT_MS', '60000'), processorId: trimEnv('RETRY_SCHEDULER_PROCESSOR_ID'), batchSize: parseIntegerEnv('RETRY_SCHEDULER_BATCH_SIZE', '10'), - baseDelayMs: parseIntegerEnv('RETRY_BASE_DELAY_MS', '5000'), - multiplier: parseIntegerEnv('RETRY_MULTIPLIER', '2'), - maxDelayMs: parseIntegerEnv('RETRY_MAX_DELAY_MS', String(60 * 60 * 1000)), - jitter: trimEnv('RETRY_JITTER') !== 'false', + backoff: { + initialDelayMs: parseIntegerEnv('RETRY_BASE_DELAY_MS', '5000'), + multiplier: parseIntegerEnv('RETRY_MULTIPLIER', '2'), + maxDelayMs: parseIntegerEnv('RETRY_MAX_DELAY_MS', String(60 * 60 * 1000)), + maxRetries: parseIntegerEnv('RETRY_MAX_RETRIES', '5'), + jitter: trimEnv('RETRY_JITTER') !== 'false', + }, }; } @@ -271,10 +274,13 @@ export function loadConfig(): Config { databasePath: trimEnv('DATABASE_PATH') || './data/notifications.db', discord, retryQueue: { - baseDelayMs: parseIntegerEnv('RETRY_BASE_DELAY_MS', '5000'), - maxRetries: parseIntegerEnv('RETRY_MAX_RETRIES', '5'), - multiplier: parseIntegerEnv('RETRY_MULTIPLIER', '2'), - jitter: trimEnv('RETRY_JITTER') !== 'false', + backoff: { + initialDelayMs: parseIntegerEnv('RETRY_BASE_DELAY_MS', '5000'), + maxRetries: parseIntegerEnv('RETRY_MAX_RETRIES', '5'), + multiplier: parseIntegerEnv('RETRY_MULTIPLIER', '2'), + jitter: trimEnv('RETRY_JITTER') !== 'false', + maxDelayMs: parseIntegerEnv('RETRY_MAX_DELAY_MS', String(60 * 60 * 1000)), + }, processIntervalMs: parseIntegerEnv('RETRY_QUEUE_PROCESS_INTERVAL_MS', '5000'), }, eventQueue: { diff --git a/listener/src/services/notification-retry-queue.ts b/listener/src/services/notification-retry-queue.ts index 765ca629..a30a4d51 100644 --- a/listener/src/services/notification-retry-queue.ts +++ b/listener/src/services/notification-retry-queue.ts @@ -5,6 +5,13 @@ import { generateCorrelationId } from '../utils/request-id'; import { getEventName } from '../utils/event-utils'; import { getNotificationAnalyticsAggregator, NotificationAnalyticsAggregator } from './notification-analytics-aggregator'; import { NotificationType } from '../types/scheduled-notification'; +import { + PartialRetryBackoffConfig, + RetryBackoffConfig, + calculateBackoffDelay, + resolveRetryBackoffConfig, + RETRY_BACKOFF_DEFAULTS, +} from '../utils/retry-backoff-config'; export enum Priority { Low = 0, @@ -13,10 +20,12 @@ export enum Priority { } export interface RetryQueueOptions { - baseDelayMs?: number; - multiplier?: number; - jitter?: boolean; - maxRetries?: number; + /** + * Provider-independent retry backoff parameters. + * All fields are optional; defaults from `RETRY_BACKOFF_DEFAULTS` are used + * for any omitted field, and the merged result is strictly validated. + */ + backoff?: PartialRetryBackoffConfig; processIntervalMs?: number; priorityWeights?: { high: number; medium: number; low: number }; } @@ -32,10 +41,6 @@ interface RetryItem { } const DEFAULTS = { - baseDelayMs: 5_000, - multiplier: 2, - jitter: true, - maxRetries: 5, processIntervalMs: 5_000, priorityWeights: { high: 5, medium: 2, low: 1 }, }; @@ -49,10 +54,8 @@ export type NotificationFn = ( export class NotificationRetryQueue { private queue: RetryItem[] = []; private readonly queuedFingerprints: Set = new Set(); - private readonly baseDelayMs: number; - private readonly multiplier: number; - private readonly jitter: boolean; - private readonly maxRetries: number; + /** Provider-independent, fully-validated backoff configuration. */ + private readonly backoff: Readonly; private readonly processIntervalMs: number; private readonly priorityWeights: { high: number; medium: number; low: number }; private timer: ReturnType | null = null; @@ -69,14 +72,11 @@ export class NotificationRetryQueue { processingTimes: [] as number[], }; - constructor(notificationFn: NotificationFn, options?: RetryQueueOptions) { + constructor(notificationFn: NotificationFn, options: RetryQueueOptions = {}) { this.notificationFn = notificationFn; - this.baseDelayMs = options?.baseDelayMs ?? DEFAULTS.baseDelayMs; - this.multiplier = options?.multiplier ?? DEFAULTS.multiplier; - this.jitter = options?.jitter ?? DEFAULTS.jitter; - this.maxRetries = options?.maxRetries ?? DEFAULTS.maxRetries; - this.processIntervalMs = options?.processIntervalMs ?? DEFAULTS.processIntervalMs; - this.priorityWeights = options?.priorityWeights ?? DEFAULTS.priorityWeights; + this.backoff = resolveRetryBackoffConfig(options.backoff); + this.processIntervalMs = options.processIntervalMs ?? DEFAULTS.processIntervalMs; + this.priorityWeights = options.priorityWeights ?? DEFAULTS.priorityWeights; this.analytics = getNotificationAnalyticsAggregator(); } @@ -100,7 +100,7 @@ export class NotificationRetryQueue { return; } - const delayMs = this.calculateDelay(0); + const delayMs = calculateBackoffDelay(0, this.backoff); const nextRetryAt = Date.now() + delayMs; logger.info('Notification queued for retry', { @@ -110,7 +110,7 @@ export class NotificationRetryQueue { contractAddress: contractConfig.address, delayMs, nextRetryAt: new Date(nextRetryAt).toISOString(), - maxRetries: this.maxRetries, + maxRetries: this.backoff.maxRetries, priority: Priority[priority], }); @@ -187,7 +187,7 @@ export class NotificationRetryQueue { eventId: item.event.id, contractAddress: item.contractConfig.address, attempt, - maxRetries: this.maxRetries, + maxRetries: this.backoff.maxRetries, }); this.analytics?.record({ @@ -223,7 +223,7 @@ export class NotificationRetryQueue { return; } - if (attempt >= this.maxRetries) { + if (attempt >= this.backoff.maxRetries) { this.queuedFingerprints.delete(fingerprint); this.metrics.totalProcessed++; this.metrics.totalFailed++; @@ -233,7 +233,7 @@ export class NotificationRetryQueue { contractAddress: item.contractConfig.address, outcome: 'failure', durationMs: duration, - errorReason: `exhausted ${this.maxRetries} retries`, + errorReason: `exhausted ${this.backoff.maxRetries} retries`, timestamp: Date.now(), }); logger.error('Notification permanently failed after max retries', { @@ -246,7 +246,7 @@ export class NotificationRetryQueue { return; } - const delayMs = this.calculateDelay(attempt); + const delayMs = calculateBackoffDelay(attempt, this.backoff); const nextRetryAt = Date.now() + delayMs; logger.warn('Retry failed, scheduling next attempt', { @@ -278,11 +278,6 @@ export class NotificationRetryQueue { }, }; } - - private calculateDelay(retryCount: number): number { - const base = this.baseDelayMs * Math.pow(this.multiplier, retryCount); - return this.jitter ? base * (0.5 + Math.random() * 0.5) : base; - } } function buildRetryFingerprint( diff --git a/listener/src/services/retry-scheduler.ts b/listener/src/services/retry-scheduler.ts index fa65685b..822160b8 100644 --- a/listener/src/services/retry-scheduler.ts +++ b/listener/src/services/retry-scheduler.ts @@ -6,6 +6,13 @@ import { ScheduledNotification, NotificationStatus } from '../types/scheduled-no import { DiscordNotificationService } from './discord-notification'; import { WebhookDeliveryService } from './webhook-delivery-service'; import { getWorkerManager } from './worker-manager'; +import { + PartialRetryBackoffConfig, + RetryBackoffConfig, + RETRY_BACKOFF_DEFAULTS, + calculateBackoffDelay, + resolveRetryBackoffConfig, +} from '../utils/retry-backoff-config'; export interface RetrySchedulerConfig { /** Whether the scheduler is enabled. */ @@ -18,44 +25,31 @@ export interface RetrySchedulerConfig { processorId?: string; /** Maximum notifications to process per poll cycle. */ batchSize: number; - /** Backoff base delay (ms). Delay = base * multiplier^attempt */ - baseDelayMs: number; - /** Backoff multiplier. Default: 2. */ - multiplier: number; - /** Maximum delay cap (ms). Default: 1 hour. */ - maxDelayMs: number; - /** Add ±25 % random jitter to prevent thundering herd. Default: true. */ - jitter: boolean; + /** + * Provider-independent retry backoff configuration. + * All fields are optional; defaults from `RETRY_BACKOFF_DEFAULTS` are applied + * to any field the caller omits, and the merged result is strictly validated + * via `validateRetryBackoffConfig`. + */ + backoff: PartialRetryBackoffConfig; } -export const RETRY_SCHEDULER_DEFAULTS: RetrySchedulerConfig = { +/** + * Defaults for the DB-backed scheduler. Scheduling-specific fields are + * retained here; backoff defaults are inherited from the shared + * `RETRY_BACKOFF_DEFAULTS` and can still be overridden per-instance via + * `RetrySchedulerConfig.backoff`. + */ +export const RETRY_SCHEDULER_DEFAULTS: Readonly & { + backoff: Readonly; +}> = { enabled: true, pollIntervalMs: 15_000, lockTimeoutMs: 60_000, batchSize: 10, - baseDelayMs: 5_000, - multiplier: 2, - maxDelayMs: 60 * 60 * 1_000, - jitter: true, + backoff: { ...RETRY_BACKOFF_DEFAULTS }, }; -/** - * Calculates exponential backoff delay with optional jitter. - * - * Formula: delay = min(base * multiplier^attempt, maxDelayMs) - * Jitter: delay *= (0.75 + Math.random() * 0.5) → ±25 % - */ -export function calculateBackoffDelay( - attempt: number, - baseDelayMs: number, - multiplier: number, - maxDelayMs: number, - jitter: boolean -): number { - const raw = Math.min(baseDelayMs * Math.pow(multiplier, attempt), maxDelayMs); - return jitter ? raw * (0.75 + Math.random() * 0.5) : raw; -} - /** * DB-backed retry scheduler. * @@ -71,7 +65,9 @@ export function calculateBackoffDelay( * scheduler instances from retrying the same notification. */ export class RetryScheduler { - private readonly config: RetrySchedulerConfig; + private readonly config: Omit & { + backoff: Readonly; + }; private readonly processorId: string; private repository: ScheduledNotificationRepository; private discordService: DiscordNotificationService | null; @@ -85,7 +81,16 @@ export class RetryScheduler { discordService?: DiscordNotificationService | null, webhookDeliveryService?: WebhookDeliveryService, ) { - this.config = { ...RETRY_SCHEDULER_DEFAULTS, ...config }; + const inherited = { ...RETRY_SCHEDULER_DEFAULTS, ...config } as RetrySchedulerConfig; + const backoff = resolveRetryBackoffConfig(inherited.backoff); + this.config = { + enabled: inherited.enabled, + pollIntervalMs: inherited.pollIntervalMs, + lockTimeoutMs: inherited.lockTimeoutMs, + processorId: inherited.processorId, + batchSize: inherited.batchSize, + backoff, + }; this.processorId = this.config.processorId ?? `retry-${uuidv4()}`; this.repository = repository; this.discordService = discordService ?? null; @@ -106,10 +111,11 @@ export class RetryScheduler { logger.info('RetryScheduler started', { processorId: this.processorId, pollIntervalMs: this.config.pollIntervalMs, - baseDelayMs: this.config.baseDelayMs, - multiplier: this.config.multiplier, - maxDelayMs: this.config.maxDelayMs, - jitter: this.config.jitter, + initialDelayMs: this.config.backoff.initialDelayMs, + multiplier: this.config.backoff.multiplier, + maxDelayMs: this.config.backoff.maxDelayMs, + maxRetries: this.config.backoff.maxRetries, + jitter: this.config.backoff.jitter, }); await this.repository.recoverStaleLocks(); @@ -250,16 +256,7 @@ export class RetryScheduler { const nextRetryAt = isFinalAttempt ? undefined - : new Date( - Date.now() + - calculateBackoffDelay( - priorFailures, - this.config.baseDelayMs, - this.config.multiplier, - this.config.maxDelayMs, - this.config.jitter - ) - ); + : new Date(Date.now() + calculateBackoffDelay(priorFailures, this.config.backoff)); await this.repository.markAsFailedOrRetry( notification.id!, diff --git a/listener/src/services/webhook-retry-helper.ts b/listener/src/services/webhook-retry-helper.ts index 14bee3b9..933d95ed 100644 --- a/listener/src/services/webhook-retry-helper.ts +++ b/listener/src/services/webhook-retry-helper.ts @@ -1,26 +1,39 @@ /** - * Bounded retry helper for transient API failures. + * Bounded retry helper for transient webhook API failures. * - * Wraps the sendWebhook function with automatic retry logic for - * transient failures such as: - * - Network/connection errors - * - Timeout (AbortError) - * - HTTP 429 (Too Many Requests) - * - HTTP 500, 502, 503, 504 (Server errors) + * Wraps `sendWebhook` with automatic retry logic for transient failures such + * as network errors, timeouts, HTTP 429, and 5xx responses. Permanent + * client errors (400, 401, 403, 404, 422) are never retried. * - * Permanent client errors (400, 401, 403, 404, 422) are NOT retried. - * - * Retry attempts are bounded by MAX_RETRY_ATTEMPTS to prevent infinite loops. - * A delay is added between attempts to reduce load on failing services. + * Backoff behavior is fully driven by the provider-independent + * `RetryBackoffConfig` from `../utils/retry-backoff-config`. Callers supply + * an optional `partialBackoff` field inside `opts`; defaults are applied for + * any omitted field, and the merged result is strictly validated (invalid + * configs throw before any HTTP call is made). */ import { sendWebhook, WebhookSendOptions } from './webhook-sender'; +import { + PartialRetryBackoffConfig, + RetryBackoffConfig, + calculateBackoffDelayDeterministic, + resolveRetryBackoffConfig, +} from '../utils/retry-backoff-config'; -/** Maximum number of retry attempts (not counting the initial attempt). */ -const MAX_RETRY_ATTEMPTS = 2; - -/** Delay in milliseconds between retry attempts. */ -const RETRY_DELAY_MS = 1000; +/** + * Extended webhook send options: inherits all fields from the base + * `WebhookSendOptions` and adds a provider-independent `backoff` field for + * configuring retry behavior. + */ +export interface WebhookWithRetryOptions extends WebhookSendOptions { + /** + * Optional retry backoff configuration. Any omitted field falls back to + * the defaults from `RETRY_BACKOFF_DEFAULTS`; the final merged config is + * strictly validated (throws on invalid values) before the first HTTP + * attempt is made. + */ + backoff?: PartialRetryBackoffConfig; +} /** * HTTP status codes that are considered retryable (transient failures). @@ -40,111 +53,81 @@ const PERMANENT_CLIENT_ERRORS = new Set([400, 401, 403, 404, 422]); * @returns true if the failure is retryable */ function isRetryable(response?: Response, error?: unknown): boolean { - // Network errors and timeouts are retryable - if (error) { - return true; - } - - // Check HTTP status codes - if (response) { - // Success responses don't need retry - if (response.ok) { - return false; - } - - // Permanent client errors should not be retried - if (PERMANENT_CLIENT_ERRORS.has(response.status)) { - return false; - } - - // Explicit retryable status codes - if (RETRYABLE_STATUS_CODES.has(response.status)) { - return true; - } - - // Any other 5xx error is retryable - if (response.status >= 500) { - return true; - } - - // Other status codes (e.g., redirects, other 4xx) are not retried - return false; - } - - return false; + if (error) return true; + if (!response) return false; + if (response.ok) return false; + if (PERMANENT_CLIENT_ERRORS.has(response.status)) return false; + if (RETRYABLE_STATUS_CODES.has(response.status)) return true; + return response.status >= 500; } /** * Delay execution for the specified number of milliseconds. - * - * @param ms - Milliseconds to delay */ async function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** - * Send a webhook with bounded retry logic for transient failures. + * Send a webhook with bounded, configurable retry logic for transient + * failures. * - * Makes an initial attempt, then retries up to MAX_RETRY_ATTEMPTS times - * if the failure is retryable. Adds a delay between attempts. + * Makes an initial attempt, then retries up to `backoff.maxRetries` + * additional times if the failure is retryable. The delay before each + * retry follows `calculateBackoffDelay` (exponential + optional jitter, + * clamped to `backoff.maxDelayMs`). + * + * Permanent client errors and successful responses are returned immediately + * without waiting for additional attempts. * * @param url - Target webhook URL * @param payload - JSON-serializable payload - * @param opts - Webhook send options (timeout, headers) - * @returns Response object or throws the final error - * @throws The last error encountered after all retry attempts are exhausted + * @param opts - Extended webhook options, including an optional `backoff` + * block for provider-independent retry parameters. + * @returns The final `Response` (whether success or permanent failure). + * @throws The last encountered error *only* when all attempts end with an + * exception (e.g. DNS error, abort signal). HTTP responses, even 5xx, + * are returned rather than thrown so callers can inspect status codes. */ export async function sendWebhookWithRetry( url: string, payload: any, - opts: WebhookSendOptions = {}, + opts: WebhookWithRetryOptions = {}, ): Promise { + // Validate + resolve backoff config eagerly (before any network call) so + // configuration bugs surface immediately rather than on a transient retry. + const { backoff: partialBackoff, ...sendOptions } = opts; + const backoff: RetryBackoffConfig = resolveRetryBackoffConfig(partialBackoff); + let lastError: unknown; let lastResponse: Response | undefined; - // Initial attempt + retry attempts - const maxAttempts = 1 + MAX_RETRY_ATTEMPTS; + // 1 initial attempt + N retries, where N = backoff.maxRetries + const maxAttempts = 1 + backoff.maxRetries; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - const response = await sendWebhook(url, payload, opts); - - // Success case - if (response.ok) { - return response; - } + const response = await sendWebhook(url, payload, sendOptions); - // Non-retryable failure (permanent client error) - if (!isRetryable(response, undefined)) { - return response; - } + if (response.ok) return response; + if (!isRetryable(response, undefined)) return response; - // Retryable failure - store response and retry if attempts remain lastResponse = response; - if (attempt < maxAttempts - 1) { - await delay(RETRY_DELAY_MS); + // Use the deterministic (midpoint-jitter) variant so tests and + // predictable callers get reproducible delays; the non-deterministic + // calculator is used by the long-running async schedulers instead. + await delay(calculateBackoffDelayDeterministic(attempt, backoff)); } } catch (error) { lastError = error; - - // If this was the last attempt, throw the error if (attempt === maxAttempts - 1) { throw error; } - - // Otherwise, delay and retry - await delay(RETRY_DELAY_MS); + await delay(calculateBackoffDelayDeterministic(attempt, backoff)); } } - // If we got here, we have a failed response (not an exception) - // Return the last response - if (lastResponse) { - return lastResponse; - } - - // This should not happen, but handle it gracefully + if (lastResponse) return lastResponse; throw lastError ?? new Error('All retry attempts failed'); } diff --git a/listener/src/types/index.ts b/listener/src/types/index.ts index 1d212d13..d9213163 100644 --- a/listener/src/types/index.ts +++ b/listener/src/types/index.ts @@ -1,3 +1,5 @@ +import type { PartialRetryBackoffConfig } from '../utils/retry-backoff-config'; + export interface ContractConfig { address: string; events: string[]; @@ -16,11 +18,15 @@ export interface DiscordConfig { } export interface RetryQueueConfig { - baseDelayMs?: number; - multiplier?: number; - jitter?: boolean; - maxRetries?: number; + /** + * Provider-independent retry backoff parameters for the in-memory + * notification retry queue. Defaults from `RETRY_BACKOFF_DEFAULTS` are + * applied to any omitted field; the merged result is strictly validated + * by the shared `resolveRetryBackoffConfig` validator. + */ + backoff?: PartialRetryBackoffConfig; processIntervalMs?: number; + priorityWeights?: { high: number; medium: number; low: number }; } export interface WebhookSecret { @@ -131,10 +137,12 @@ export interface RetrySchedulerOptions { lockTimeoutMs: number; processorId?: string; batchSize: number; - baseDelayMs: number; - multiplier: number; - maxDelayMs: number; - jitter: boolean; + /** + * Provider-independent retry backoff parameters for the DB-backed retry + * scheduler. Omitted fields fall back to `RETRY_BACKOFF_DEFAULTS`; the + * final merged config is strictly validated before any retries run. + */ + backoff: PartialRetryBackoffConfig; } export interface AnalyticsConfig { diff --git a/listener/src/utils/retry-backoff-config.ts b/listener/src/utils/retry-backoff-config.ts new file mode 100644 index 00000000..7302968b --- /dev/null +++ b/listener/src/utils/retry-backoff-config.ts @@ -0,0 +1,311 @@ +/** + * Provider-Independent Retry Backoff Configuration. + * + * Decouples exponential-backoff parameters (initial delay, max delay, + * max retries, multiplier, jitter) from any specific notification + * provider. The same validator, defaults, and delay calculator are + * reused by the DB-backed retry scheduler, the in-memory retry queue, + * and the webhook synchronous retry helper — without either of them + * knowing about each other. + * + * Design constraints (enforced by `validateRetryBackoffConfig`): + * - Delays are non-negative finite numbers. + * - `maxDelayMs >= initialDelayMs` (backoff can only grow or stay flat). + * - `maxRetries` has a hard upper bound so retry loops cannot hang + * indefinitely, even under pathological configuration. + * - `multiplier >= 1` because a multiplier below 1 would *shrink* + * delays on each attempt rather than backing off. + * - `jitter` is always coerced / validated as a boolean. + */ + +import { InputValidator, ValidationError } from './validation'; + +// --------------------------------------------------------------------------- +// Hard bounds (never exceeded, even by "defaults"). +// These exist to guarantee termination of retry loops. +// --------------------------------------------------------------------------- + +/** Absolute minimum non-negative initial delay. */ +export const MIN_INITIAL_DELAY_MS = 0; +/** Hard upper bound on any retry delay — prevents infinite-hold loops. */ +export const MAX_ALLOWED_MAX_DELAY_MS = 24 * 60 * 60 * 1_000; // 24 hours +/** Hard upper bound on the multiplier to prevent overflowing `Math.pow`. */ +export const MAX_ALLOWED_MULTIPLIER = 100; +/** Minimum multiplier; below 1 the delay would *decrease* per attempt. */ +export const MIN_MULTIPLIER = 1; +/** Hard ceiling on `maxRetries` — protects against accidentally infinite loops. */ +export const MAX_ALLOWED_MAX_RETRIES = 1_000; +/** Minimum number of retries (0 is valid — means "first failure is final"). */ +export const MIN_RETRIES = 0; +/** Default maximum attempts for consumers that don't specify. */ +export const DEFAULT_MAX_RETRIES = 5; +/** Default initial delay for consumers that don't specify. */ +export const DEFAULT_INITIAL_DELAY_MS = 5_000; +/** Default max delay cap. */ +export const DEFAULT_MAX_DELAY_MS = 60 * 60 * 1_000; // 1 hour +/** Default exponential multiplier. */ +export const DEFAULT_MULTIPLIER = 2; +/** Default jitter toggle. */ +export const DEFAULT_JITTER = true; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Fully-resolved retry backoff parameters. Every field is required after + * passing through `resolveRetryBackoffConfig`; use `PartialRetryBackoffConfig` + * for inputs that fall back to defaults. + */ +export interface RetryBackoffConfig { + /** Initial / base delay in ms before the first retry (attempt 2). */ + initialDelayMs: number; + /** + * Maximum delay in ms. The exponential formula is clamped to this value + * so successive retries never wait longer than the hard cap, even if + * `base * multiplier^attempt` would grow past it. + */ + maxDelayMs: number; + /** + * Maximum number of *retries* (i.e. additional attempts beyond the + * initial one). `maxRetries = 0` means the first failure is final — + * no retries are scheduled. + */ + maxRetries: number; + /** Exponential growth factor. `delay = initialDelayMs * multiplier^attempt`. */ + multiplier: number; + /** When true, add ±25 % uniform random jitter to each delay. */ + jitter: boolean; +} + +/** Input shape accepted by resolvers — any field may be omitted for default. */ +export type PartialRetryBackoffConfig = Partial; + +/** + * Safe, provider-independent defaults that always pass the validator. + * Guaranteed to be within every hard bound defined above. + */ +export const RETRY_BACKOFF_DEFAULTS: Readonly = Object.freeze({ + initialDelayMs: DEFAULT_INITIAL_DELAY_MS, + maxDelayMs: DEFAULT_MAX_DELAY_MS, + maxRetries: DEFAULT_MAX_RETRIES, + multiplier: DEFAULT_MULTIPLIER, + jitter: DEFAULT_JITTER, +}); + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Strictly validates a *fully resolved* backoff configuration and throws a + * `ValidationError` containing every violated constraint (not just the + * first). The caller sees a complete list of problems in a single + * exception, instead of having to fix-and-restart repeatedly. + * + * Intentionally validates `config` *in-place* (after defaults have been + * merged) so the same function can be reused both by `resolve*` (below) + * and by tests / config loaders that want to re-assert bounds on + * already-merged data. + * + * @throws ValidationError if any field is missing, out-of-range, or violates + * the cross-field ordering invariants (`maxDelayMs >= initialDelayMs`, etc). + */ +export function validateRetryBackoffConfig(config: unknown): asserts config is RetryBackoffConfig { + const v = new InputValidator(); + const obj = config as Record; + + // ── Presence + type checks (each field required on resolved config) ─── + v.check( + typeof obj?.initialDelayMs === 'number' && Number.isFinite(obj.initialDelayMs), + 'initialDelayMs', + `must be a finite number, received ${describe(obj?.initialDelayMs)}`, + ); + v.check( + typeof obj?.maxDelayMs === 'number' && Number.isFinite(obj.maxDelayMs), + 'maxDelayMs', + `must be a finite number, received ${describe(obj?.maxDelayMs)}`, + ); + v.check( + typeof obj?.maxRetries === 'number' && Number.isFinite(obj.maxRetries) && Number.isInteger(obj.maxRetries), + 'maxRetries', + `must be a finite integer, received ${describe(obj?.maxRetries)}`, + ); + v.check( + typeof obj?.multiplier === 'number' && Number.isFinite(obj.multiplier), + 'multiplier', + `must be a finite number, received ${describe(obj?.multiplier)}`, + ); + v.check( + typeof obj?.jitter === 'boolean', + 'jitter', + `must be a boolean, received ${describe(obj?.jitter)}`, + ); + + // Short-circuit range checks if any field has the wrong type — the + // comparisons below would otherwise give nonsensical error messages. + if (v.hasIssues()) { + v.throwIfInvalid(); + return; + } + + const cast = config as RetryBackoffConfig; + + // ── Range checks (lower bounds) ──────────────────────────────────────── + v.check( + cast.initialDelayMs >= MIN_INITIAL_DELAY_MS, + 'initialDelayMs', + `must be >= ${MIN_INITIAL_DELAY_MS} ms (non-negative), received ${cast.initialDelayMs}`, + ); + v.check( + cast.maxDelayMs >= MIN_INITIAL_DELAY_MS, + 'maxDelayMs', + `must be >= ${MIN_INITIAL_DELAY_MS} ms (non-negative), received ${cast.maxDelayMs}`, + ); + v.check( + cast.maxRetries >= MIN_RETRIES, + 'maxRetries', + `must be >= ${MIN_RETRIES}, received ${cast.maxRetries}`, + ); + v.check( + cast.multiplier >= MIN_MULTIPLIER, + 'multiplier', + `must be >= ${MIN_MULTIPLIER} (multipliers < 1 shrink delays instead of backing off), received ${cast.multiplier}`, + ); + + // ── Range checks (upper / hard bounds) ───────────────────────────────── + v.check( + cast.maxDelayMs <= MAX_ALLOWED_MAX_DELAY_MS, + 'maxDelayMs', + `must be <= ${MAX_ALLOWED_MAX_DELAY_MS} ms (24-hour hard cap to prevent indefinite waits), received ${cast.maxDelayMs}`, + ); + v.check( + cast.maxRetries <= MAX_ALLOWED_MAX_RETRIES, + 'maxRetries', + `must be <= ${MAX_ALLOWED_MAX_RETRIES} (hard cap to prevent infinite retry loops), received ${cast.maxRetries}`, + ); + v.check( + cast.multiplier <= MAX_ALLOWED_MULTIPLIER, + 'multiplier', + `must be <= ${MAX_ALLOWED_MULTIPLIER} to prevent Math.pow overflow, received ${cast.multiplier}`, + ); + + // ── Cross-field ordering invariants ──────────────────────────────────── + v.check( + cast.maxDelayMs >= cast.initialDelayMs, + 'maxDelayMs', + `must be >= initialDelayMs; received maxDelayMs=${cast.maxDelayMs} but initialDelayMs=${cast.initialDelayMs}`, + ); + + v.throwIfInvalid(); +} + +// --------------------------------------------------------------------------- +// Resolver (partial input → fully validated resolved config) +// --------------------------------------------------------------------------- + +/** + * Merges a caller-supplied partial config on top of `RETRY_BACKOFF_DEFAULTS` + * and strictly validates the result. If the caller did not specify a + * value the default is used; if the caller *did* specify a value it is + * preserved verbatim and validated against the hard bounds. + * + * This is the **only** supported way to build a RetryBackoffConfig from + * partial user input. Constructing the type manually is not recommended + * because it will bypass validation; instead pass your object through + * this resolver. + * + * @throws ValidationError if any explicitly supplied value violates the + * bounds or cross-field invariants defined above. + */ +export function resolveRetryBackoffConfig( + input: PartialRetryBackoffConfig = {} +): RetryBackoffConfig { + const merged: RetryBackoffConfig = { + initialDelayMs: + input.initialDelayMs !== undefined ? input.initialDelayMs : RETRY_BACKOFF_DEFAULTS.initialDelayMs, + maxDelayMs: + input.maxDelayMs !== undefined ? input.maxDelayMs : RETRY_BACKOFF_DEFAULTS.maxDelayMs, + maxRetries: + input.maxRetries !== undefined ? input.maxRetries : RETRY_BACKOFF_DEFAULTS.maxRetries, + multiplier: + input.multiplier !== undefined ? input.multiplier : RETRY_BACKOFF_DEFAULTS.multiplier, + jitter: input.jitter !== undefined ? !!input.jitter : RETRY_BACKOFF_DEFAULTS.jitter, + }; + + validateRetryBackoffConfig(merged); + return merged; +} + +// --------------------------------------------------------------------------- +// Shared backoff delay calculator +// --------------------------------------------------------------------------- + +/** + * Computes the delay (in ms) that should elapse before re-attempting a + * failed notification. + * + * Formula (deterministic): + * ``` + * raw = min(initialDelayMs * multiplier^retryIndex, maxDelayMs) + * final = jitter ? raw * (0.75 + rand() * 0.5) : raw + * ``` + * + * `retryIndex` counts the number of *retries already attempted* — i.e. + * pass `priorFailures` (0-based), not the absolute attempt number + * (1-based). For example: + * - first retry (priorFailures = 0) → base * multiplier^0 = initialDelayMs + * - second retry (priorFailures = 1) → initialDelayMs * multiplier^1 + * - third retry (priorFailures = 2) → initialDelayMs * multiplier^2 + * + * This function is intentionally pure so it can be unit-tested without + * mocks; callers are responsible for clamping `retryIndex` / handling + * maxRetries themselves (it is not the delay calculator's job to end + * the retry loop, only to give honest delays). + */ +export function calculateBackoffDelay( + retryIndex: number, + config: Readonly +): number { + const base = Math.min( + config.initialDelayMs * Math.pow(config.multiplier, retryIndex), + config.maxDelayMs, + ); + return config.jitter ? base * (0.75 + Math.random() * 0.5) : base; +} + +/** + * Deterministic variant of `calculateBackoffDelay` used by tests and any + * caller that wants reproducible delays. Applies the exact same + * formula but replaces the random jitter with a fixed ±12.5 % (midpoint + * of the jitter range) so output is 100 % reproducible for a given input. + */ +export function calculateBackoffDelayDeterministic( + retryIndex: number, + config: Readonly, +): number { + const base = Math.min( + config.initialDelayMs * Math.pow(config.multiplier, retryIndex), + config.maxDelayMs, + ); + return config.jitter ? base * 0.875 : base; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +/** Returns true iff `err` is a backoff-config validation error. */ +export function isBackoffValidationError(err: unknown): err is ValidationError { + return err instanceof ValidationError; +} + +// Helper for error messages — prints what the caller actually passed. +function describe(value: unknown): string { + if (value === undefined) return 'undefined (missing)'; + if (value === null) return 'null'; + if (typeof value === 'number') return `number(${value})`; + if (typeof value === 'string') return `string("${value}")`; + if (typeof value === 'boolean') return `boolean(${value})`; + return `${typeof value}`; +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..4f839df0 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "notify-chain", + "version": "1.0.0", + "private": true, + "description": "NotifyChain - Decentralized Notification Infrastructure", + "scripts": { + "check:links": "markdown-link-check -c .markdown-link-check.json \"./**/*.md\"", + "check:links:quiet": "markdown-link-check -q -c .markdown-link-check.json \"./**/*.md\"" + }, + "devDependencies": { + "markdown-link-check": "^3.12.2" + } +}