From 93079e023420773bc1173a4394a677f1404ee225 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:54:38 +0000 Subject: [PATCH 01/31] Initial plan From e28672482603921e6671722b8db95269fb0b4183 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:56:23 +0000 Subject: [PATCH 02/31] Initial plan for ecoBridge API integration Co-authored-by: ecoTokenJames <93841642+ecoTokenJames@users.noreply.github.com> --- package-lock.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package-lock.json b/package-lock.json index 479423a..0bdd09d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1476,6 +1476,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1829,6 +1830,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz", "integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -2526,6 +2528,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -2557,6 +2560,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 788b5e62bff4f8f25a1b1cedaf7500cbcd650126 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:02:49 +0000 Subject: [PATCH 03/31] Add ecoBridge integration for cross-chain credit retirement payments Co-authored-by: ecoTokenJames <93841642+ecoTokenJames@users.noreply.github.com> --- .env.example | 8 + CLAUDE.md | 21 ++- README.md | 97 +++++++++++- src/config.ts | 13 ++ src/index.ts | 290 +++++++++++++++++++++++++++++++++++- src/services/ecobridge.ts | 304 ++++++++++++++++++++++++++++++++++++++ src/tools/retire.ts | 8 + 7 files changed, 735 insertions(+), 6 deletions(-) create mode 100644 src/services/ecobridge.ts diff --git a/.env.example b/.env.example index 876c45f..ea76327 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,11 @@ REGEN_DEFAULT_JURISDICTION=US # Optional: Stripe (future - requires Regen team integration) # STRIPE_SECRET_KEY= # STRIPE_WEBHOOK_SECRET= + +# --- ecoBridge Integration --- +# Enable cross-chain token acceptance via bridge.eco +# When enabled, users can pay for credit retirement with USDC, USDT, ETH, etc. +# across Ethereum, Polygon, Arbitrum, Base, Celo, Optimism, Solana, and more. +ECOBRIDGE_API_URL=https://api.bridge.eco +ECOBRIDGE_ENABLED=true +ECOBRIDGE_CACHE_TTL_MS=60000 diff --git a/CLAUDE.md b/CLAUDE.md index 45aa338..8980dbf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Read `docs/analysis.md` for the full business analysis. Key points: - Regen Ledger REST API (credit classes, projects, batches, sell orders) - Regen Indexer GraphQL (`api.regen.network/indexer/v1/graphql`) — retirement certificates, aggregations - Regen Marketplace (`app.regen.network`) — purchase flow links + - ecoBridge API (`api.bridge.eco`) — cross-chain token support, real-time prices, widget deep links ## Project Structure @@ -43,6 +44,7 @@ src/ │ ├── estimator.ts # Footprint estimation heuristics │ ├── wallet.ts # Cosmos wallet init, sign+broadcast (singleton) │ ├── order-selector.ts # Best-price sell order routing (cheapest-first greedy fill) +│ ├── ecobridge.ts # ecoBridge API client (registry, tokens, chains, widget URLs) │ └── payment/ │ ├── types.ts # PaymentProvider interface (authorize → capture two-phase) │ ├── crypto.ts # CryptoPaymentProvider (balance check, no-op capture) @@ -51,16 +53,17 @@ src/ ## MCP Features -- **Server instructions**: Detailed guidance for when/why to use this server, injected into model system prompt. Adapts based on wallet configuration. +- **Server instructions**: Detailed guidance for when/why to use this server, injected into model system prompt. Adapts based on wallet configuration and ecoBridge enabled state. - **Tool annotations**: Read-only tools stay `readOnlyHint: true`. `retire_credits` becomes `destructiveHint: true` when wallet is configured (executes real transactions). -- **Prompt templates**: `offset_my_session` (footprint → browse → retire workflow), `show_regen_impact` (network stats) +- **Prompt templates**: `offset_my_session` (footprint → browse → retire workflow), `show_regen_impact` (network stats), `retire_with_any_token` (ecoBridge cross-chain workflow) - **Live data**: Marketplace snapshot computed from real sell orders, not hardcoded - **Two-mode retirement**: `retire_credits` executes on-chain when `REGEN_WALLET_MNEMONIC` is set, otherwise returns marketplace link (fully backward compatible) +- **ecoBridge integration**: `browse_ecobridge_tokens` and `retire_via_ecobridge` tools enable payment with 50+ tokens across 10+ blockchains; conditionally registered based on `ECOBRIDGE_ENABLED` ## Build Phases - **Phase 1** (complete): Read-only MCP server. Footprint estimation, credit browsing, certificate retrieval, marketplace purchase links. -- **Phase 1.5** (current): Direct on-chain retirement. Wallet signing, best-price order routing, `MsgBuyDirect` with auto-retire, `PaymentProvider` interface for Stripe. +- **Phase 1.5** (current): Direct on-chain retirement. Wallet signing, best-price order routing, `MsgBuyDirect` with auto-retire, `PaymentProvider` interface for Stripe. ecoBridge integration for cross-chain payment (USDC, ETH, etc. on Ethereum, Polygon, Arbitrum, Base, Celo, Optimism, Solana, and more). - **Phase 2**: Stripe subscription pool. Monthly batch retirements with fractional attribution. - **Phase 3**: CosmWasm smart contract for on-chain pool aggregation and REGEN burn. - **Phase 4**: Enterprise API, platform partnerships, credit supply development. @@ -86,6 +89,18 @@ src/ - Indexer GraphQL supports `condition` arg (not `filter`) for field-level queries - `txByHash` returns null — use `allRetirements(condition: { txHash: ... })` instead +## ecoBridge API Notes + +- Base URL: `https://api.bridge.eco` (configurable via `ECOBRIDGE_API_URL`) +- Docs: https://docs.bridge.eco/docs/guides/integration/ +- Registry: `GET /registry` — all active projects, supported tokens with USD prices, chain/token details +- Version: `GET /registry/version` — lightweight poll; returns `{ version, lastUpdated }` for cache invalidation +- OpenAPI spec: `GET /openapi.json` +- Widget deep-linking: https://docs.bridge.eco/docs/guides/deep-linking/ — query params: `chain`, `token`, `project`, `amount`, `beneficiary`, `reason`, `jurisdiction` +- Prices updated ~every 60 seconds via CoinGecko Pro; registry is cached with `ECOBRIDGE_CACHE_TTL_MS` (default 60000ms) +- Integration enabled/disabled via `ECOBRIDGE_ENABLED` env var; tools are conditionally registered when enabled +- Supported chains include: Ethereum, Polygon, Arbitrum, Base, Optimism, Celo, Solana, World Chain, Unichain, Ink, Sonic, 0G, and more + ## Tech Stack (Phase 1.5 additions) - **@cosmjs/proto-signing** + **@cosmjs/stargate**: Cosmos SDK wallet and transaction signing diff --git a/README.md b/README.md index a983855..8cb8672 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Regen Compute Credits MCP Server ├── Browses available credits on Regen Marketplace ├── Retires credits directly on-chain (with wallet) │ OR links to credit card purchase (without wallet) + │ OR bridges any token via ecoBridge (USDC, ETH, etc.) └── Returns verifiable retirement certificate │ ▼ @@ -160,6 +161,8 @@ Retires ecocredits on Regen Network. Operates in two modes: - **With wallet configured** (`REGEN_WALLET_MNEMONIC` set): Executes a `MsgBuyDirect` on-chain, purchasing and retiring credits in a single transaction. Returns a retirement certificate. - **Without wallet**: Returns a marketplace link for credit card purchase (no crypto wallet needed). +When `ECOBRIDGE_ENABLED=true`, the fallback message also suggests `retire_via_ecobridge` as a cross-chain payment alternative. + **Parameters:** | Parameter | Description | @@ -172,6 +175,79 @@ Retires ecocredits on Regen Network. Operates in two modes: **When it's used:** The user wants to take action and actually fund ecological regeneration. +### `browse_ecobridge_tokens` + +Lists all tokens and chains supported by ecoBridge for cross-chain credit retirement payments. + +**When it's used:** The user wants to pay for credit retirement using tokens from other chains (USDC on Ethereum, ETH on Arbitrum, etc.) rather than native REGEN tokens. + +**Parameters:** + +| Parameter | Description | +|-----------|-------------| +| `chain` | Filter by chain name (e.g., 'ethereum', 'polygon'). Optional. | + +**Example output:** +``` +## ecoBridge Supported Tokens + +### Ethereum +| Token | Symbol | Price (USD) | +|-------|--------|------------| +| USD Coin | USDC | $1.00 | +| Tether | USDT | $1.00 | +| Ether | ETH | $3,200.00 | + +### Polygon +| Token | Symbol | Price (USD) | +|-------|--------|------------| +| USD Coin | USDC | $1.00 | +| MATIC | MATIC | $0.75 | +``` + +### `retire_via_ecobridge` + +Generates an ecoBridge payment link to retire ecocredits using any supported token on any supported chain. + +**When it's used:** The user wants to pay with tokens like USDC, USDT, ETH on Ethereum, Polygon, Arbitrum, Base, or other chains instead of native REGEN tokens. + +**Parameters:** + +| Parameter | Description | +|-----------|-------------| +| `chain` | The blockchain to pay from (e.g., 'ethereum', 'polygon', 'arbitrum', 'base'). Required. | +| `token` | The token to pay with (e.g., 'USDC', 'USDT', 'ETH'). Required. | +| `credit_class` | Credit class to retire (e.g., 'C01', 'BT01'). Optional. | +| `quantity` | Number of credits to retire. Optional (defaults to 1). | +| `beneficiary_name` | Name for the retirement certificate. Optional. | +| `jurisdiction` | Retirement jurisdiction (ISO 3166-1). Optional. | +| `reason` | Reason for retiring credits. Optional. | + +**Example output:** +``` +## Retire Ecocredits via ecoBridge + +Pay with **USDC** on **Ethereum** to retire ecocredits on Regen Network. + +| Field | Value | +|-------|-------| +| Chain | Ethereum | +| Token | USDC | +| Quantity | 1 credit | +| Token Price | $1.00 USD | + +### Payment Link + +**[Open ecoBridge Widget](https://app.bridge.eco?chain=ethereum&token=USDC&amount=1)** + +**How it works:** +1. Click the link above to open the ecoBridge payment widget +2. Connect your wallet on Ethereum +3. The widget will pre-select USDC and the credit retirement details +4. Confirm the transaction — ecoBridge bridges your tokens and retires credits on Regen Network +5. You'll receive a verifiable on-chain retirement certificate +``` + ## Direct On-Chain Retirement To enable direct retirement (no marketplace visit needed), set `REGEN_WALLET_MNEMONIC` in your environment. The MCP server will: @@ -191,6 +267,21 @@ export REGEN_RPC_URL=https://mainnet.regen.network:26657 export REGEN_CHAIN_ID=regen-1 ``` +## Cross-Chain Payment via ecoBridge + +To pay for credit retirements using USDC, ETH, or other tokens on Ethereum, Polygon, Arbitrum, Base, Celo, Optimism, Solana, and more, use the ecoBridge tools: + +```bash +# ecoBridge is enabled by default. To disable: +export ECOBRIDGE_ENABLED=false + +# Optional: custom API URL or cache TTL +export ECOBRIDGE_API_URL=https://api.bridge.eco +export ECOBRIDGE_CACHE_TTL_MS=60000 +``` + +Use `browse_ecobridge_tokens` to see all available tokens and chains, then `retire_via_ecobridge` to generate a payment link. + See `.env.example` for all configuration options. ## MCP Prompts @@ -201,12 +292,13 @@ The server also provides prompt templates for common workflows: |--------|-------------| | `offset_my_session` | Estimate footprint + browse credits + get retirement link | | `show_regen_impact` | Pull live network stats and summarize ecological impact | +| `retire_with_any_token` | Browse ecoBridge tokens + select chain/token + generate payment link | ## Key Concepts - **Regenerative contribution, not carbon offset.** We fund verified ecological regeneration. We do not claim carbon neutrality. - **On-chain and immutable.** Every retirement is recorded on Regen Ledger — verifiable, non-reversible. -- **Two modes:** With a wallet, credits are retired directly on-chain. Without a wallet, purchase via credit card on Regen Marketplace. +- **Three payment modes:** (1) Direct on-chain with a REGEN wallet, (2) credit card via Regen Marketplace, or (3) any token on any chain via ecoBridge (USDC, ETH, etc. on Ethereum, Polygon, Arbitrum, Base, and more). - **Multiple credit types.** Carbon, biodiversity, marine, soil, and species stewardship credits. - **Graceful fallback.** If direct retirement fails for any reason, a marketplace link is returned instead. @@ -217,6 +309,7 @@ The server also provides prompt templates for common workflows: | [Regen Ledger REST](https://lcd-regen.keplr.app) | Credit classes, projects, batches, sell orders | | [Regen Indexer GraphQL](https://api.regen.network/indexer/v1/graphql) | Retirement certificates, marketplace orders, aggregate stats | | [Regen Marketplace](https://app.regen.network) | Credit card purchase flow, project pages | +| [ecoBridge API](https://api.bridge.eco) | Cross-chain token support, real-time USD prices, widget deep links | ## Development @@ -235,7 +328,7 @@ npm run typecheck # Type checking | Phase | Description | Status | |-------|-------------|--------| | **Phase 1** | MCP server — footprint estimation, credit browsing, marketplace links, certificates | Complete | -| **Phase 1.5** | Direct on-chain retirement — wallet signing, best-price order routing, payment provider interface | Complete | +| **Phase 1.5** | Direct on-chain retirement — wallet signing, best-price order routing, payment provider interface; ecoBridge cross-chain payment integration | Complete | | **Phase 2** | Subscription pool — Stripe, monthly batch retirements, fractional attribution | Planned | | **Phase 3** | CosmWasm pool contract — on-chain aggregation, automated retirement, REGEN burn | Planned | | **Phase 4** | Scale — enterprise API, platform partnerships, credit supply development | Planned | diff --git a/src/config.ts b/src/config.ts index 31424b8..f943a1c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,11 @@ export interface Config { walletMnemonic: string | undefined; paymentProvider: "crypto" | "stripe"; defaultJurisdiction: string; + + // ecoBridge integration (Phase 1.5) + ecoBridgeApiUrl: string; + ecoBridgeEnabled: boolean; + ecoBridgeCacheTtlMs: number; } let _config: Config | undefined; @@ -40,6 +45,14 @@ export function loadConfig(): Config { paymentProvider: (process.env.REGEN_PAYMENT_PROVIDER as "crypto" | "stripe") || "crypto", defaultJurisdiction: process.env.REGEN_DEFAULT_JURISDICTION || "US", + + ecoBridgeApiUrl: + process.env.ECOBRIDGE_API_URL || "https://api.bridge.eco", + ecoBridgeEnabled: process.env.ECOBRIDGE_ENABLED !== "false", + ecoBridgeCacheTtlMs: parseInt( + process.env.ECOBRIDGE_CACHE_TTL_MS || "60000", + 10 + ), }; return _config; diff --git a/src/index.ts b/src/index.ts index a1d6198..85cf36b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,19 @@ import { getRetirementCertificate } from "./tools/certificates.js"; import { getImpactSummary } from "./tools/impact.js"; import { retireCredits } from "./tools/retire.js"; import { loadConfig, isWalletConfigured } from "./config.js"; +import { + fetchRegistry, + getSupportedTokens, + getTokenPrice, + buildRetirementUrl, +} from "./services/ecobridge.js"; // Load config early so isWalletConfigured() is available for annotations loadConfig(); const walletMode = isWalletConfigured(); +const config = loadConfig(); +const ecoBridgeEnabled = config.ecoBridgeEnabled; const server = new McpServer( { @@ -29,6 +37,11 @@ const server = new McpServer( "- The user wants to retire ecocredits or fund ecological regeneration", "- The user asks about Regen Network's impact, projects, or retirement certificates", "- The user mentions sustainability, climate action, or regenerative ecology in the context of AI", + ...(ecoBridgeEnabled + ? [ + "- The user wants to pay for credit retirement using tokens from other blockchains (USDC, ETH, etc. on Ethereum, Polygon, etc.)", + ] + : []), "", "KEY CONCEPTS:", "- This is 'regenerative contribution,' NOT 'carbon offset.' We fund verified ecological regeneration.", @@ -41,6 +54,12 @@ const server = new McpServer( : [ "- No crypto wallet needed. Purchase via credit card on Regen Marketplace.", ]), + ...(ecoBridgeEnabled + ? [ + "- ecoBridge integration enables payment with 50+ tokens across 10+ blockchains for credit retirement.", + "- ecoBridge enables cross-chain payment for all Regen credit types.", + ] + : []), "- Credit types: Carbon (C), Biodiversity/Terrasos (BT), Kilo-Sheep-Hour (KSH), Marine Biodiversity (MBS), Umbrella Species Stewardship (USS).", "", "TYPICAL WORKFLOW:", @@ -54,6 +73,14 @@ const server = new McpServer( "3. retire_credits — get a purchase link to retire credits via credit card", ]), "4. get_retirement_certificate — verify an on-chain retirement", + ...(ecoBridgeEnabled + ? [ + "", + "CROSS-CHAIN PAYMENT WORKFLOW (via ecoBridge):", + "1. browse_ecobridge_tokens — list all supported tokens and chains", + "2. retire_via_ecobridge — generate a payment link using USDC, ETH, or any supported token", + ] + : []), "", ...(walletMode ? [ @@ -196,6 +223,225 @@ server.tool( } ); +// Tools: ecoBridge cross-chain payment (conditionally registered) +if (ecoBridgeEnabled) { + // Tool: Browse all tokens/chains supported by ecoBridge + server.tool( + "browse_ecobridge_tokens", + "Lists all tokens and chains supported by ecoBridge for retiring credits on Regen Network. Use this when the user wants to pay for credit retirement using tokens from other chains (e.g., USDC on Ethereum, ETH on Arbitrum, etc.) rather than native REGEN tokens.", + { + chain: z + .string() + .optional() + .describe( + "Filter by chain name (e.g., 'ethereum', 'polygon', 'arbitrum')" + ), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + async ({ chain }) => { + try { + const tokens = await getSupportedTokens(chain); + + if (tokens.length === 0) { + const msg = chain + ? `No tokens found for chain "${chain}". Use \`browse_ecobridge_tokens\` without a chain filter to see all supported chains.` + : "No tokens found in the ecoBridge registry. The service may be temporarily unavailable."; + return { content: [{ type: "text" as const, text: msg }] }; + } + + // Group by chain for display + const byChain = new Map< + string, + Array<(typeof tokens)[0]> + >(); + for (const t of tokens) { + const key = t.chainName || t.chainId; + if (!byChain.has(key)) byChain.set(key, []); + byChain.get(key)!.push(t); + } + + const lines: string[] = [ + `## ecoBridge Supported Tokens`, + ``, + `Pay for Regen Network credit retirements using any of the tokens below.`, + `Use \`retire_via_ecobridge\` to generate a payment link.`, + ``, + ]; + + for (const [chainName, chainTokens] of byChain) { + lines.push(`### ${chainName}`); + lines.push(`| Token | Symbol | Price (USD) |`); + lines.push(`|-------|--------|------------|`); + for (const t of chainTokens) { + const price = + t.priceUsd != null ? `$${t.priceUsd.toFixed(2)}` : "—"; + lines.push(`| ${t.name} | ${t.symbol} | ${price} |`); + } + lines.push(``); + } + + lines.push( + `*Prices updated approximately every 60 seconds via CoinGecko Pro.*` + ); + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text" as const, + text: `Failed to fetch ecoBridge token list: ${errMsg}`, + }, + ], + }; + } + } + ); + + // Tool: Generate an ecoBridge payment link for credit retirement + server.tool( + "retire_via_ecobridge", + "Generates an ecoBridge payment link to retire ecocredits on Regen Network using any supported token on any supported chain. Use this when the user wants to pay with tokens like USDC, USDT, ETH on Ethereum, Polygon, Arbitrum, Base, or other chains instead of native REGEN tokens.", + { + chain: z + .string() + .describe( + "The blockchain to pay from (e.g., 'ethereum', 'polygon', 'arbitrum', 'base')" + ), + token: z + .string() + .describe("The token to pay with (e.g., 'USDC', 'USDT', 'ETH')"), + credit_class: z + .string() + .optional() + .describe("Credit class to retire (e.g., 'C01', 'BT01')"), + quantity: z + .number() + .optional() + .describe("Number of credits to retire (defaults to 1)"), + beneficiary_name: z + .string() + .optional() + .describe("Name for the retirement certificate"), + jurisdiction: z + .string() + .optional() + .describe("Retirement jurisdiction (ISO 3166-1)"), + reason: z + .string() + .optional() + .describe("Reason for retiring credits"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + async ({ + chain, + token, + credit_class, + quantity, + beneficiary_name, + jurisdiction, + reason, + }) => { + try { + const qty = quantity ?? 1; + + // Validate chain/token via registry + const tokens = await getSupportedTokens(chain); + const matchedToken = tokens.find( + (t) => t.symbol.toLowerCase() === token.toLowerCase() + ); + + if (!matchedToken) { + // Provide helpful list of available tokens for this chain + const available = tokens.map((t) => t.symbol).join(", "); + const msg = available + ? `Token "${token}" is not supported on "${chain}". Available tokens: ${available}.\n\nUse \`browse_ecobridge_tokens\` to see all supported chains and tokens.` + : `Chain "${chain}" is not supported by ecoBridge, or no tokens are available.\n\nUse \`browse_ecobridge_tokens\` to see all supported chains and tokens.`; + return { content: [{ type: "text" as const, text: msg }] }; + } + + // Get current token price for cost estimate + const priceUsd = await getTokenPrice(token, chain); + + // Build deep-linked widget URL + const widgetUrl = buildRetirementUrl({ + chain, + token, + amount: qty, + beneficiaryName: beneficiary_name, + retirementReason: + reason || "Regenerative contribution via Regen Compute Credits", + jurisdiction, + }); + + const lines: string[] = [ + `## Retire Ecocredits via ecoBridge`, + ``, + `Pay with **${token}** on **${matchedToken.chainName}** to retire ecocredits on Regen Network.`, + ``, + `| Field | Value |`, + `|-------|-------|`, + `| Chain | ${matchedToken.chainName} |`, + `| Token | ${token} |`, + `| Quantity | ${qty} credit${qty !== 1 ? "s" : ""} |`, + ]; + + if (credit_class) lines.push(`| Credit Class | ${credit_class} |`); + if (beneficiary_name) + lines.push(`| Beneficiary | ${beneficiary_name} |`); + if (jurisdiction) lines.push(`| Jurisdiction | ${jurisdiction} |`); + + if (priceUsd != null) { + lines.push(`| Token Price | $${priceUsd.toFixed(2)} USD |`); + } + + lines.push( + ``, + `### Payment Link`, + ``, + `**[Open ecoBridge Widget](${widgetUrl})**`, + ``, + `**How it works:**`, + `1. Click the link above to open the ecoBridge payment widget`, + `2. Connect your wallet on ${matchedToken.chainName}`, + `3. The widget will pre-select ${token} and the credit retirement details`, + `4. Confirm the transaction — ecoBridge bridges your tokens and retires credits on Regen Network`, + `5. You'll receive a verifiable on-chain retirement certificate`, + ``, + `After retiring, use \`get_retirement_certificate\` to retrieve your verifiable certificate.` + ); + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text" as const, + text: `Failed to generate ecoBridge retirement link: ${errMsg}\n\nUse \`browse_ecobridge_tokens\` to verify chain and token support.`, + }, + ], + }; + } + } + ); +} + // Prompt: Offset my AI session server.prompt( "offset_my_session", @@ -266,11 +512,53 @@ server.prompt( } ); +// Prompt: Retire with any token via ecoBridge +if (ecoBridgeEnabled) { + server.prompt( + "retire_with_any_token", + "Explore cross-chain payment options and retire ecocredits using any supported token via ecoBridge.", + { + chain: z + .string() + .optional() + .describe("Preferred blockchain (e.g., 'ethereum', 'polygon')"), + token: z + .string() + .optional() + .describe("Preferred token (e.g., 'USDC', 'ETH')"), + }, + ({ chain, token }) => { + const chainNote = chain ? ` on ${chain}` : ""; + const tokenNote = token ? ` using ${token}` : ""; + return { + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: [ + `I'd like to retire ecocredits on Regen Network${tokenNote}${chainNote}.`, + ``, + `Please:`, + `1. Use browse_ecobridge_tokens${chain ? ` with chain="${chain}"` : ""} to show me available tokens and their current prices`, + `2. Help me choose a token and chain that works for me`, + `3. Use retire_via_ecobridge to generate a payment link with my chosen token`, + ``, + `Frame this as funding ecological regeneration across chains.`, + ].join("\n"), + }, + }, + ], + }; + } + ); +} + async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error( - `Regen Compute Credits MCP server running (wallet mode: ${walletMode ? "enabled" : "disabled"})` + `Regen Compute Credits MCP server running (wallet mode: ${walletMode ? "enabled" : "disabled"}, ecoBridge: ${ecoBridgeEnabled ? "enabled" : "disabled"})` ); } diff --git a/src/services/ecobridge.ts b/src/services/ecobridge.ts new file mode 100644 index 0000000..90d255a --- /dev/null +++ b/src/services/ecobridge.ts @@ -0,0 +1,304 @@ +/** + * ecoBridge API client + * + * Connects to bridge.eco to list supported tokens/chains and build + * deep-linked retirement widget URLs. Enables payment for Regen Network + * credit retirements using any token on any supported chain. + * + * API base: https://api.bridge.eco + * Docs: https://docs.bridge.eco/docs/guides/integration/ + */ + +import { loadConfig } from "../config.js"; + +// --- Type definitions --- + +export interface EcoBridgeToken { + symbol: string; + name: string; + address: string | null; + decimals: number; + logoUrl: string | null; + priceUsd: number | null; +} + +export interface EcoBridgeChain { + id: string; + name: string; + logoUrl: string | null; + tokens: EcoBridgeToken[]; +} + +export interface EcoBridgeProject { + id: string; + name: string; + description: string | null; + creditClass: string | null; + registryUrl: string | null; +} + +export interface EcoBridgeRegistry { + chains: EcoBridgeChain[]; + projects: EcoBridgeProject[]; +} + +export interface EcoBridgeRegistryVersion { + version: string; + lastUpdated: string; +} + +// --- In-memory cache --- + +interface CacheEntry { + data: EcoBridgeRegistry; + version: string; + fetchedAt: number; +} + +let _cache: CacheEntry | null = null; + +function getApiUrl(): string { + return loadConfig().ecoBridgeApiUrl; +} + +function getCacheTtlMs(): number { + return loadConfig().ecoBridgeCacheTtlMs; +} + +async function fetchJSON(path: string): Promise { + const response = await fetch(`${getApiUrl()}${path}`); + if (!response.ok) { + throw new Error( + `ecoBridge API error: ${response.status} ${response.statusText}` + ); + } + return response.json() as Promise; +} + +/** + * Fetch the current registry version without downloading the full registry. + * Used for efficient cache invalidation. + */ +export async function fetchRegistryVersion(): Promise { + return fetchJSON("/registry/version"); +} + +/** + * Fetch the full ecoBridge registry (projects, chains, tokens, prices). + * Results are cached in memory with a TTL matching bridge.eco's ~60s update cadence. + */ +export async function fetchRegistry(): Promise { + const now = Date.now(); + const ttl = getCacheTtlMs(); + + // Return cached data if still fresh + if (_cache && now - _cache.fetchedAt < ttl) { + return _cache.data; + } + + // Check version before re-fetching when cache exists but has expired + if (_cache) { + try { + const versionInfo = await fetchRegistryVersion(); + if (versionInfo.version === _cache.version) { + // Same version — extend TTL without re-fetching + _cache.fetchedAt = now; + return _cache.data; + } + } catch { + // Version check failed — fall through to full fetch + } + } + + const raw = await fetchJSON("/registry"); + const registry = parseRegistry(raw); + + _cache = { + data: registry, + version: await getVersionString(), + fetchedAt: now, + }; + + return registry; +} + +async function getVersionString(): Promise { + try { + const v = await fetchRegistryVersion(); + return v.version; + } catch { + return String(Date.now()); + } +} + +/** + * Parse the raw API response into our typed EcoBridgeRegistry format. + * The bridge.eco registry schema may evolve; we parse defensively. + */ +function parseRegistry(raw: unknown): EcoBridgeRegistry { + const obj = raw as Record; + + // Parse chains and their tokens + const chainsRaw = Array.isArray(obj.chains) ? obj.chains : []; + const chains: EcoBridgeChain[] = chainsRaw.map((c: unknown) => { + const chain = c as Record; + const tokensRaw = Array.isArray(chain.tokens) ? chain.tokens : []; + const tokens: EcoBridgeToken[] = tokensRaw.map((t: unknown) => { + const tok = t as Record; + return { + symbol: String(tok.symbol ?? ""), + name: String(tok.name ?? tok.symbol ?? ""), + address: tok.address ? String(tok.address) : null, + decimals: typeof tok.decimals === "number" ? tok.decimals : 18, + logoUrl: tok.logoUrl ? String(tok.logoUrl) : null, + priceUsd: + typeof tok.priceUsd === "number" + ? tok.priceUsd + : typeof tok.price === "number" + ? tok.price + : null, + }; + }); + return { + id: String(chain.id ?? chain.chainId ?? ""), + name: String(chain.name ?? chain.id ?? ""), + logoUrl: chain.logoUrl ? String(chain.logoUrl) : null, + tokens, + }; + }); + + // Parse projects + const projectsRaw = Array.isArray(obj.projects) ? obj.projects : []; + const projects: EcoBridgeProject[] = projectsRaw.map((p: unknown) => { + const proj = p as Record; + return { + id: String(proj.id ?? ""), + name: String(proj.name ?? proj.id ?? ""), + description: proj.description ? String(proj.description) : null, + creditClass: proj.creditClass + ? String(proj.creditClass) + : proj.credit_class + ? String(proj.credit_class) + : null, + registryUrl: proj.registryUrl + ? String(proj.registryUrl) + : proj.registry_url + ? String(proj.registry_url) + : null, + }; + }); + + return { chains, projects }; +} + +/** + * Return all supported chains from the registry. + */ +export async function getSupportedChains(): Promise { + const registry = await fetchRegistry(); + return registry.chains; +} + +/** + * Return supported tokens, optionally filtered by chain id or name. + */ +export async function getSupportedTokens( + chainFilter?: string +): Promise> { + const registry = await fetchRegistry(); + const result: Array = + []; + + for (const chain of registry.chains) { + if (chainFilter) { + const cf = chainFilter.toLowerCase(); + if ( + chain.id.toLowerCase() !== cf && + chain.name.toLowerCase() !== cf && + !chain.name.toLowerCase().includes(cf) && + !chain.id.toLowerCase().includes(cf) + ) { + continue; + } + } + for (const token of chain.tokens) { + result.push({ ...token, chainId: chain.id, chainName: chain.name }); + } + } + + return result; +} + +/** + * Get the current USD price for a specific token on a specific chain. + * Returns null if the token/chain is not found in the registry. + */ +export async function getTokenPrice( + tokenSymbol: string, + chainFilter: string +): Promise { + const tokens = await getSupportedTokens(chainFilter); + const token = tokens.find( + (t) => t.symbol.toLowerCase() === tokenSymbol.toLowerCase() + ); + return token?.priceUsd ?? null; +} + +/** + * Build a deep-linked ecoBridge widget URL. + * + * See: https://docs.bridge.eco/docs/guides/deep-linking/ + */ +export interface RetirementUrlParams { + chain?: string; + token?: string; + projectId?: string; + amount?: number; + beneficiaryName?: string; + retirementReason?: string; + jurisdiction?: string; +} + +export function buildRetirementUrl(params: RetirementUrlParams): string { + const config = loadConfig(); + const apiUrl = config.ecoBridgeApiUrl; + + // Derive the widget (app) URL from the API URL. + // Canonical: https://api.bridge.eco → https://app.bridge.eco + // For custom deployments, fall back to the api URL itself. + let widgetBase: string; + try { + const apiParsed = new URL(apiUrl); + if (apiParsed.hostname.startsWith("api.")) { + apiParsed.hostname = "app." + apiParsed.hostname.slice("api.".length); + } + widgetBase = apiParsed.toString().replace(/\/$/, ""); + } catch { + widgetBase = apiUrl.replace(/\/$/, ""); + } + + let url: URL; + try { + url = new URL(widgetBase); + } catch { + throw new Error( + `ecoBridge: could not build widget URL from configured API URL "${apiUrl}". ` + + `Check ECOBRIDGE_API_URL in your environment.` + ); + } + + // Deep-link query params per bridge.eco widget spec + if (params.chain) url.searchParams.set("chain", params.chain); + if (params.token) url.searchParams.set("token", params.token); + if (params.projectId) url.searchParams.set("project", params.projectId); + if (params.amount != null) + url.searchParams.set("amount", String(params.amount)); + if (params.beneficiaryName) + url.searchParams.set("beneficiary", params.beneficiaryName); + if (params.retirementReason) + url.searchParams.set("reason", params.retirementReason); + if (params.jurisdiction) + url.searchParams.set("jurisdiction", params.jurisdiction); + + return url.toString(); +} diff --git a/src/tools/retire.ts b/src/tools/retire.ts index 1520ada..f0f10ad 100644 --- a/src/tools/retire.ts +++ b/src/tools/retire.ts @@ -57,6 +57,14 @@ function marketplaceFallback( `5. Credits are permanently retired — verifiable, immutable, non-reversible`, ``, `Use \`browse_available_credits\` to see current pricing and availability.`, + ...(loadConfig().ecoBridgeEnabled + ? [ + ``, + `**Cross-chain option:** Use \`retire_via_ecobridge\` to pay with tokens from other chains`, + `(USDC, USDT, ETH, etc. on Ethereum, Polygon, Arbitrum, Base, and more).`, + `Use \`browse_ecobridge_tokens\` to see all supported chains and tokens.`, + ] + : []), ``, `After retiring, use \`get_retirement_certificate\` to retrieve your verifiable certificate.` ); From 699d131e571ab49a18679d49df53e5a5ccff2ce1 Mon Sep 17 00:00:00 2001 From: Christian Shearer Date: Tue, 24 Feb 2026 07:48:29 -0600 Subject: [PATCH 04/31] Fix ecoBridge registry parser to handle supportedTokens API format The ecoBridge API returns tokens as { supportedTokens: { chainName: [...] } } but the parser only looked for { chains: [...] }, resulting in 0 chains/tokens. Now handles both formats. Co-Authored-By: Claude Opus 4.6 --- src/services/ecobridge.ts | 52 +++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/services/ecobridge.ts b/src/services/ecobridge.ts index 90d255a..8ebbe15 100644 --- a/src/services/ecobridge.ts +++ b/src/services/ecobridge.ts @@ -138,12 +138,9 @@ async function getVersionString(): Promise { function parseRegistry(raw: unknown): EcoBridgeRegistry { const obj = raw as Record; - // Parse chains and their tokens - const chainsRaw = Array.isArray(obj.chains) ? obj.chains : []; - const chains: EcoBridgeChain[] = chainsRaw.map((c: unknown) => { - const chain = c as Record; - const tokensRaw = Array.isArray(chain.tokens) ? chain.tokens : []; - const tokens: EcoBridgeToken[] = tokensRaw.map((t: unknown) => { + // Helper to parse an array of token objects + function parseTokens(raw: unknown[]): EcoBridgeToken[] { + return raw.map((t: unknown) => { const tok = t as Record; return { symbol: String(tok.symbol ?? ""), @@ -159,13 +156,42 @@ function parseRegistry(raw: unknown): EcoBridgeRegistry { : null, }; }); - return { - id: String(chain.id ?? chain.chainId ?? ""), - name: String(chain.name ?? chain.id ?? ""), - logoUrl: chain.logoUrl ? String(chain.logoUrl) : null, - tokens, - }; - }); + } + + // Parse chains and their tokens. + // The API may return either: + // - "chains": [...] (array of chain objects with nested tokens) + // - "supportedTokens": { chainName: [tokens...], ... } (object keyed by chain) + let chains: EcoBridgeChain[]; + + if (Array.isArray(obj.chains) && obj.chains.length > 0) { + // Format: chains array with nested tokens + chains = obj.chains.map((c: unknown) => { + const chain = c as Record; + const tokensRaw = Array.isArray(chain.tokens) ? chain.tokens : []; + return { + id: String(chain.id ?? chain.chainId ?? ""), + name: String(chain.name ?? chain.id ?? ""), + logoUrl: chain.logoUrl ? String(chain.logoUrl) : null, + tokens: parseTokens(tokensRaw), + }; + }); + } else if ( + obj.supportedTokens && + typeof obj.supportedTokens === "object" && + !Array.isArray(obj.supportedTokens) + ) { + // Format: supportedTokens object keyed by chain name + const st = obj.supportedTokens as Record; + chains = Object.entries(st).map(([chainName, tokensRaw]) => ({ + id: chainName, + name: chainName.charAt(0).toUpperCase() + chainName.slice(1), + logoUrl: null, + tokens: Array.isArray(tokensRaw) ? parseTokens(tokensRaw) : [], + })); + } else { + chains = []; + } // Parse projects const projectsRaw = Array.isArray(obj.projects) ? obj.projects : []; From fe7099c2068ed198d63e4cbf8c70d38abad76330 Mon Sep 17 00:00:00 2001 From: Christian Shearer Date: Tue, 24 Feb 2026 09:02:06 -0600 Subject: [PATCH 05/31] Rename product from regen-compute-credits to regen-for-ai Product name is now "Regen for AI"; the category it creates is "Regenerative AI." Updated package name, bin, version (0.3.0), MCP server name, all user-facing strings, README install commands, GitHub URLs, and supporting docs. Build passes clean. Co-Authored-By: Claude Opus 4.6 --- .env.example | 2 +- .github/CONTRIBUTING.md | 4 +- CLAUDE.md | 4 +- README.md | 18 +-- docs/architecture.md | 2 +- package-lock.json | 135 ++++++++++++++++++++-- package.json | 20 ++-- src/config.ts | 10 +- src/index.ts | 225 ++++++++++++++++++++----------------- src/services/ecobridge.ts | 127 +++++++++++++++++++-- src/services/evm-wallet.ts | 132 ++++++++++++++++++++++ src/tools/retire.ts | 2 +- 12 files changed, 536 insertions(+), 145 deletions(-) create mode 100644 src/services/evm-wallet.ts diff --git a/.env.example b/.env.example index ea76327..82f0f6f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Regen Compute Credits - Environment Configuration +# Regen for AI - Environment Configuration # Copy this file to .env and fill in your values # Regen Network Indexer GraphQL endpoint diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 55a5a00..1ad7a42 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,11 +1,11 @@ -# Contributing to Regen Compute Credits +# Contributing to Regen for AI Thank you for your interest in contributing! This project connects AI compute usage to verified ecological regeneration through Regen Network. ## Getting Started 1. Fork the repository -2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/regen-compute-credits.git` +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/regen-for-ai.git` 3. Install dependencies: `npm install` 4. Copy environment config: `cp .env.example .env` 5. Start development: `npm run dev` diff --git a/CLAUDE.md b/CLAUDE.md index 8980dbf..ad704f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ -# CLAUDE.md — Regen Compute Credits +# CLAUDE.md — Regen for AI This file provides context to Claude Code when working on this project. ## Project Overview -Regen Compute Credits is an MCP (Model Context Protocol) server that connects AI compute usage to verified ecological credit retirement on Regen Network. Users connect it to Claude Code or Cursor, and it provides tools to estimate their AI session's ecological footprint, browse available credits, and retire them via Regen Marketplace's existing credit card flow. +Regen for AI is an MCP (Model Context Protocol) server that connects AI compute usage to verified ecological credit retirement on Regen Network. The product name is **Regen for AI**; the category it creates is **Regenerative AI**. Users connect it to Claude Code or Cursor, and it provides tools to estimate their AI session's ecological footprint, browse available credits, and retire them via Regen Marketplace's existing credit card flow. ## Strategic Context diff --git a/README.md b/README.md index 8cb8672..f8c8586 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ -# Regen Compute Credits +# Regen for AI -**An MCP server that funds verified ecological regeneration from AI compute usage via Regen Network.** +**Regenerative AI — fund verified ecological regeneration from your AI sessions via Regen Network.** -Every AI session consumes energy. Regen Compute Credits turns that consumption into a funding mechanism for verified ecological regeneration — retiring real ecocredits on-chain through Regen Network's marketplace, with immutable proof of impact. +Every AI session consumes energy. Regen for AI turns that consumption into a funding mechanism for verified ecological regeneration — retiring real ecocredits on-chain through Regen Network's marketplace, with immutable proof of impact. ## Quick Start ### Install via npx (recommended) ```bash -claude mcp add -s user regen-compute-credits -- npx regen-compute-credits +claude mcp add -s user regen-for-ai -- npx regen-for-ai ``` That's it. The server is now available in all your Claude Code sessions. @@ -21,10 +21,10 @@ Add to your Claude Code config (`~/.claude.json`): ```json { "mcpServers": { - "regen-compute-credits": { + "regen-for-ai": { "type": "stdio", "command": "npx", - "args": ["regen-compute-credits"] + "args": ["regen-for-ai"] } } } @@ -36,7 +36,7 @@ Add to your Claude Code config (`~/.claude.json`): AI Session (Claude Code, Cursor, etc.) │ ▼ -Regen Compute Credits MCP Server +Regen for AI MCP Server │ ├── Estimates session ecological footprint ├── Browses available credits on Regen Marketplace @@ -314,8 +314,8 @@ The server also provides prompt templates for common workflows: ## Development ```bash -git clone https://github.com/CShear/regen-compute-credits.git -cd regen-compute-credits +git clone https://github.com/CShear/regen-for-ai.git +cd regen-for-ai npm install cp .env.example .env npm run dev # Watch mode with hot reload diff --git a/docs/architecture.md b/docs/architecture.md index c88aa1e..2e70822 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ │ MCP Protocol (stdio) ▼ ┌─────────────────────────────────────────────────────────────┐ -│ Regen Compute Credits MCP Server │ +│ Regen for AI MCP Server │ │ │ │ Tools: │ │ ┌─────────────────────┐ ┌────────────────────────┐ │ diff --git a/package-lock.json b/package-lock.json index 0bdd09d..65aa35c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,22 @@ { - "name": "regen-compute-credits", - "version": "0.2.0", + "name": "regen-for-ai", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "regen-compute-credits", - "version": "0.2.0", + "name": "regen-for-ai", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@cosmjs/proto-signing": "^0.32.4", "@cosmjs/stargate": "^0.32.4", "@modelcontextprotocol/sdk": "^1.0.0", - "@regen-network/api": "^1.0.0-alpha8" + "@regen-network/api": "^1.0.0-alpha8", + "ethers": "^6.16.0" }, "bin": { - "regen-compute-credits": "dist/index.js" + "regen-for-ai": "dist/index.js" }, "devDependencies": { "@types/node": "^20.0.0", @@ -26,6 +27,12 @@ "node": ">=20.0.0" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, "node_modules/@confio/ics23": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@confio/ics23/-/ics23-0.6.8.tgz", @@ -675,6 +682,30 @@ } } }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -990,6 +1021,12 @@ "node": ">= 0.6" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -1450,6 +1487,82 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -1476,7 +1589,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1830,7 +1942,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz", "integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -2430,6 +2541,12 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2528,7 +2645,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -2560,7 +2676,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 63d5203..7dfbcc1 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { - "name": "regen-compute-credits", - "version": "0.2.0", - "description": "MCP server that connects AI compute usage to verified ecological credit retirement on Regen Network. Estimate your session footprint, browse carbon and biodiversity credits, and retire them on-chain via credit card.", + "name": "regen-for-ai", + "version": "0.3.0", + "description": "Regenerative AI — fund verified ecological regeneration from your AI sessions via Regen Network. Estimate your footprint, browse carbon and biodiversity credits, and retire them on-chain via credit card.", "type": "module", "main": "dist/index.js", "bin": { - "regen-compute-credits": "dist/index.js" + "regen-for-ai": "dist/index.js" }, "files": [ "dist", @@ -29,7 +29,8 @@ "ecocredits", "carbon-credits", "biodiversity-credits", - "carbon-offset", + "regenerative-ai", + "regen-for-ai", "ai-compute", "sustainability", "climate", @@ -40,13 +41,13 @@ ], "author": "CShear", "license": "Apache-2.0", - "homepage": "https://github.com/CShear/regen-compute-credits#readme", + "homepage": "https://github.com/CShear/regen-for-ai#readme", "repository": { "type": "git", - "url": "git+https://github.com/CShear/regen-compute-credits.git" + "url": "git+https://github.com/CShear/regen-for-ai.git" }, "bugs": { - "url": "https://github.com/CShear/regen-compute-credits/issues" + "url": "https://github.com/CShear/regen-for-ai/issues" }, "engines": { "node": ">=20.0.0" @@ -55,7 +56,8 @@ "@cosmjs/proto-signing": "^0.32.4", "@cosmjs/stargate": "^0.32.4", "@modelcontextprotocol/sdk": "^1.0.0", - "@regen-network/api": "^1.0.0-alpha8" + "@regen-network/api": "^1.0.0-alpha8", + "ethers": "^6.16.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/src/config.ts b/src/config.ts index f943a1c..460186f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ /** - * Centralized configuration for Regen Compute Credits. + * Centralized configuration for Regen for AI. * * Reads all environment variables once and exports a typed config object. * The key gate is `isWalletConfigured()` — when true, the server can @@ -23,6 +23,10 @@ export interface Config { ecoBridgeApiUrl: string; ecoBridgeEnabled: boolean; ecoBridgeCacheTtlMs: number; + + // ecoBridge EVM wallet (for sending tokens on Base/Ethereum/etc.) + ecoBridgeEvmMnemonic: string | undefined; + ecoBridgeEvmDerivationPath: string; } let _config: Config | undefined; @@ -53,6 +57,10 @@ export function loadConfig(): Config { process.env.ECOBRIDGE_CACHE_TTL_MS || "60000", 10 ), + + ecoBridgeEvmMnemonic: process.env.ECOBRIDGE_EVM_MNEMONIC || undefined, + ecoBridgeEvmDerivationPath: + process.env.ECOBRIDGE_EVM_DERIVATION_PATH || "m/44'/60'/0'/0/0", }; return _config; diff --git a/src/index.ts b/src/index.ts index 85cf36b..c35760d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,8 +11,16 @@ import { fetchRegistry, getSupportedTokens, getTokenPrice, + getProject, + getProjects, + pollTransaction, buildRetirementUrl, } from "./services/ecobridge.js"; +import { + sendUsdc, + isEvmWalletConfigured, + getEvmAddress, +} from "./services/evm-wallet.js"; // Load config early so isWalletConfigured() is available for annotations loadConfig(); @@ -23,12 +31,12 @@ const ecoBridgeEnabled = config.ecoBridgeEnabled; const server = new McpServer( { - name: "regen-compute-credits", - version: "0.2.0", + name: "regen-for-ai", + version: "0.3.0", }, { instructions: [ - "Regen Compute Credits connects AI compute usage to verified ecological credit retirement on Regen Network.", + "Regen for AI connects AI compute usage to verified ecological credit retirement on Regen Network.", "", "USE THIS SERVER WHEN:", "- The user asks about the environmental or ecological impact of their AI usage", @@ -306,136 +314,153 @@ if (ecoBridgeEnabled) { } ); - // Tool: Generate an ecoBridge payment link for credit retirement + // Tool: Retire credits via ecoBridge by sending tokens to project wallet server.tool( "retire_via_ecobridge", - "Generates an ecoBridge payment link to retire ecocredits on Regen Network using any supported token on any supported chain. Use this when the user wants to pay with tokens like USDC, USDT, ETH on Ethereum, Polygon, Arbitrum, Base, or other chains instead of native REGEN tokens.", + isEvmWalletConfigured() + ? "Sends USDC on an EVM chain (Base, Ethereum, etc.) to an ecoBridge project wallet to retire ecocredits on Regen Network. Executes a real on-chain token transfer and polls bridge.eco until the retirement is confirmed. This is a destructive action — tokens are spent permanently." + : "Lists ecoBridge projects available for credit retirement. An EVM wallet must be configured (ECOBRIDGE_EVM_MNEMONIC) to execute transactions.", { + project_id: z + .union([z.string(), z.number()]) + .describe("Project ID (number) or partial name match (e.g., 'mongolia', 'kasigau')"), chain: z .string() - .describe( - "The blockchain to pay from (e.g., 'ethereum', 'polygon', 'arbitrum', 'base')" - ), - token: z - .string() - .describe("The token to pay with (e.g., 'USDC', 'USDT', 'ETH')"), - credit_class: z - .string() - .optional() - .describe("Credit class to retire (e.g., 'C01', 'BT01')"), - quantity: z + .default("base") + .describe("Chain to send payment from (default: 'base')"), + amount_usdc: z .number() + .describe("Amount of USDC to send (e.g., 0.1 for a test, 1.5 for 1 tCO2e of Inner Mongolia)"), + wait_for_retirement: z + .boolean() .optional() - .describe("Number of credits to retire (defaults to 1)"), - beneficiary_name: z - .string() - .optional() - .describe("Name for the retirement certificate"), - jurisdiction: z - .string() - .optional() - .describe("Retirement jurisdiction (ISO 3166-1)"), - reason: z - .string() - .optional() - .describe("Reason for retiring credits"), + .default(true) + .describe("If true, polls bridge.eco API until retirement is confirmed (up to 5 min). If false, returns immediately after tx is sent."), }, { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, + readOnlyHint: !isEvmWalletConfigured(), + destructiveHint: isEvmWalletConfigured(), + idempotentHint: false, openWorldHint: true, }, - async ({ - chain, - token, - credit_class, - quantity, - beneficiary_name, - jurisdiction, - reason, - }) => { + async ({ project_id, chain, amount_usdc, wait_for_retirement }) => { try { - const qty = quantity ?? 1; - - // Validate chain/token via registry - const tokens = await getSupportedTokens(chain); - const matchedToken = tokens.find( - (t) => t.symbol.toLowerCase() === token.toLowerCase() - ); + // 1. Look up the project + const project = await getProject(project_id); + if (!project) { + const allProjects = await getProjects(); + const list = allProjects + .map((p) => ` ${p.id}: ${p.name} ($${p.price}/${p.unit || "unit"}) — ${p.location}`) + .join("\n"); + return { + content: [{ + type: "text" as const, + text: `Project "${project_id}" not found.\n\nAvailable projects:\n${list}`, + }], + }; + } - if (!matchedToken) { - // Provide helpful list of available tokens for this chain - const available = tokens.map((t) => t.symbol).join(", "); - const msg = available - ? `Token "${token}" is not supported on "${chain}". Available tokens: ${available}.\n\nUse \`browse_ecobridge_tokens\` to see all supported chains and tokens.` - : `Chain "${chain}" is not supported by ecoBridge, or no tokens are available.\n\nUse \`browse_ecobridge_tokens\` to see all supported chains and tokens.`; - return { content: [{ type: "text" as const, text: msg }] }; + if (!project.evmWallet) { + return { + content: [{ + type: "text" as const, + text: `Project "${project.name}" does not have an EVM wallet configured. Cannot send payment.`, + }], + }; } - // Get current token price for cost estimate - const priceUsd = await getTokenPrice(token, chain); + // 2. Check wallet is configured + if (!isEvmWalletConfigured()) { + return { + content: [{ + type: "text" as const, + text: `EVM wallet not configured. Set ECOBRIDGE_EVM_MNEMONIC in .env to enable cross-chain retirement.\n\nProject: ${project.name}\nEVM Wallet: ${project.evmWallet}\nPrice: $${project.price}/${project.unit || "unit"}`, + }], + }; + } - // Build deep-linked widget URL - const widgetUrl = buildRetirementUrl({ - chain, - token, - amount: qty, - beneficiaryName: beneficiary_name, - retirementReason: - reason || "Regenerative contribution via Regen Compute Credits", - jurisdiction, - }); + const fromAddress = getEvmAddress(); + const estimatedCredits = project.price + ? (amount_usdc / project.price).toFixed(4) + : "unknown"; const lines: string[] = [ - `## Retire Ecocredits via ecoBridge`, - ``, - `Pay with **${token}** on **${matchedToken.chainName}** to retire ecocredits on Regen Network.`, + `## ecoBridge Retirement: ${project.name}`, ``, `| Field | Value |`, `|-------|-------|`, - `| Chain | ${matchedToken.chainName} |`, - `| Token | ${token} |`, - `| Quantity | ${qty} credit${qty !== 1 ? "s" : ""} |`, + `| Project | ${project.name} |`, + `| Location | ${project.location || "—"} |`, + `| Type | ${project.type || "—"} |`, + `| Price | $${project.price}/${project.unit || "unit"} |`, + `| Payment | ${amount_usdc} USDC on ${chain} |`, + `| Est. Credits | ~${estimatedCredits} ${project.unit || "units"} |`, + `| From | ${fromAddress} |`, + `| To | ${project.evmWallet} |`, + ``, ]; - if (credit_class) lines.push(`| Credit Class | ${credit_class} |`); - if (beneficiary_name) - lines.push(`| Beneficiary | ${beneficiary_name} |`); - if (jurisdiction) lines.push(`| Jurisdiction | ${jurisdiction} |`); - - if (priceUsd != null) { - lines.push(`| Token Price | $${priceUsd.toFixed(2)} USD |`); - } - + // 3. Send USDC + lines.push(`### Sending USDC...`); + const result = await sendUsdc(chain, project.evmWallet, amount_usdc); lines.push( ``, - `### Payment Link`, - ``, - `**[Open ecoBridge Widget](${widgetUrl})**`, - ``, - `**How it works:**`, - `1. Click the link above to open the ecoBridge payment widget`, - `2. Connect your wallet on ${matchedToken.chainName}`, - `3. The widget will pre-select ${token} and the credit retirement details`, - `4. Confirm the transaction — ecoBridge bridges your tokens and retires credits on Regen Network`, - `5. You'll receive a verifiable on-chain retirement certificate`, + `**Transaction sent!**`, + `| Field | Value |`, + `|-------|-------|`, + `| Tx Hash | \`${result.txHash}\` |`, + `| Amount | ${result.amountUsdc} USDC |`, + `| Chain | ${result.chain} |`, ``, - `After retiring, use \`get_retirement_certificate\` to retrieve your verifiable certificate.` ); + // 4. Optionally poll for retirement + if (wait_for_retirement) { + lines.push(`### Polling bridge.eco for retirement status...`); + try { + const tx = await pollTransaction(result.txHash, 60, 5000); + lines.push( + ``, + `**Retirement status: ${tx.status}**`, + ``, + ); + if (tx.status === "RETIRED" || tx.status === "RWI_MINTED" || tx.status === "FEE_CALCULATED") { + lines.push( + `Credits successfully retired on Regen Network!`, + ``, + `Use \`get_retirement_certificate\` with the transaction hash to retrieve your verifiable certificate.`, + ); + } + if (tx.retirementDetails) { + lines.push(``, `**Retirement details:** ${JSON.stringify(tx.retirementDetails, null, 2)}`); + } + } catch (pollErr) { + const pollMsg = pollErr instanceof Error ? pollErr.message : String(pollErr); + lines.push( + ``, + `Polling timed out: ${pollMsg}`, + ``, + `The transaction was sent successfully. bridge.eco may still be processing it.`, + `Check status manually: \`GET https://api.bridge.eco/transactions/${result.txHash}\``, + ); + } + } else { + lines.push( + `Transaction sent. To check retirement status later:`, + `\`GET https://api.bridge.eco/transactions/${result.txHash}\``, + ); + } + return { content: [{ type: "text" as const, text: lines.join("\n") }], }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); return { - content: [ - { - type: "text" as const, - text: `Failed to generate ecoBridge retirement link: ${errMsg}\n\nUse \`browse_ecobridge_tokens\` to verify chain and token support.`, - }, - ], + content: [{ + type: "text" as const, + text: `ecoBridge retirement failed: ${errMsg}`, + }], }; } } @@ -558,7 +583,7 @@ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error( - `Regen Compute Credits MCP server running (wallet mode: ${walletMode ? "enabled" : "disabled"}, ecoBridge: ${ecoBridgeEnabled ? "enabled" : "disabled"})` + `Regen for AI MCP server running (wallet mode: ${walletMode ? "enabled" : "disabled"}, ecoBridge: ${ecoBridgeEnabled ? "enabled" : "disabled"})` ); } diff --git a/src/services/ecobridge.ts b/src/services/ecobridge.ts index 8ebbe15..413473e 100644 --- a/src/services/ecobridge.ts +++ b/src/services/ecobridge.ts @@ -35,6 +35,13 @@ export interface EcoBridgeProject { description: string | null; creditClass: string | null; registryUrl: string | null; + evmWallet: string | null; + solanaWallet: string | null; + price: number | null; + unit: string | null; + location: string | null; + type: string | null; + batch: string | null; } export interface EcoBridgeRegistry { @@ -211,6 +218,13 @@ function parseRegistry(raw: unknown): EcoBridgeRegistry { : proj.registry_url ? String(proj.registry_url) : null, + evmWallet: proj.evmWallet ? String(proj.evmWallet) : null, + solanaWallet: proj.solanaWallet ? String(proj.solanaWallet) : null, + price: typeof proj.price === "number" ? proj.price : null, + unit: proj.unit ? String(proj.unit) : null, + location: proj.location ? String(proj.location) : null, + type: proj.type ? String(proj.type) : null, + batch: proj.batch ? String(proj.batch) : null, }; }); @@ -289,14 +303,15 @@ export function buildRetirementUrl(params: RetirementUrlParams): string { const config = loadConfig(); const apiUrl = config.ecoBridgeApiUrl; - // Derive the widget (app) URL from the API URL. - // Canonical: https://api.bridge.eco → https://app.bridge.eco + // Derive the widget URL from the API URL. + // Canonical: https://api.bridge.eco → https://bridge.eco + // The widget lives at the root domain, not an "app." subdomain. // For custom deployments, fall back to the api URL itself. let widgetBase: string; try { const apiParsed = new URL(apiUrl); if (apiParsed.hostname.startsWith("api.")) { - apiParsed.hostname = "app." + apiParsed.hostname.slice("api.".length); + apiParsed.hostname = apiParsed.hostname.slice("api.".length); } widgetBase = apiParsed.toString().replace(/\/$/, ""); } catch { @@ -314,17 +329,111 @@ export function buildRetirementUrl(params: RetirementUrlParams): string { } // Deep-link query params per bridge.eco widget spec + // See: https://docs.bridge.eco/docs/guides/deep-linking/ + // The "impact" tab handles credit retirement funding. + url.searchParams.set("tab", "impact"); if (params.chain) url.searchParams.set("chain", params.chain); if (params.token) url.searchParams.set("token", params.token); if (params.projectId) url.searchParams.set("project", params.projectId); if (params.amount != null) url.searchParams.set("amount", String(params.amount)); - if (params.beneficiaryName) - url.searchParams.set("beneficiary", params.beneficiaryName); - if (params.retirementReason) - url.searchParams.set("reason", params.retirementReason); - if (params.jurisdiction) - url.searchParams.set("jurisdiction", params.jurisdiction); return url.toString(); } + +/** + * Get a specific project from the registry by ID or partial name match. + */ +export async function getProject( + idOrName: string | number +): Promise { + const registry = await fetchRegistry(); + // Try numeric ID first + const numId = typeof idOrName === "number" ? idOrName : parseInt(String(idOrName), 10); + if (!isNaN(numId)) { + const byId = registry.projects.find((p) => String(p.id) === String(numId)); + if (byId) return byId; + } + // Partial name match + const needle = String(idOrName).toLowerCase(); + return ( + registry.projects.find((p) => p.name.toLowerCase().includes(needle)) ?? + null + ); +} + +/** + * Get all projects from the registry. + */ +export async function getProjects(): Promise { + const registry = await fetchRegistry(); + return registry.projects; +} + +// --- Transaction tracking --- + +export interface EcoBridgeTransaction { + txHash: string; + status: string; + blockchain: string; + amount: number | null; + tokenSymbol: string | null; + projectName: string | null; + retirementDetails: unknown | null; + createdAt: string | null; + updatedAt: string | null; +} + +/** + * Poll a transaction on bridge.eco until it reaches a terminal state. + * States: PENDING → DETECTED → CONVERTED → CALCULATED → RETIRED → FEE_CALCULATED → RWI_MINTED + */ +export async function pollTransaction( + txHash: string, + maxAttempts = 60, + intervalMs = 5000 +): Promise { + const terminalStates = new Set(["RETIRED", "RWI_MINTED", "FEE_CALCULATED", "FAILED", "ERROR"]); + + for (let i = 0; i < maxAttempts; i++) { + try { + const data = await fetchJSON>( + `/transactions/${txHash}` + ); + const status = String(data.status ?? "UNKNOWN"); + const tx: EcoBridgeTransaction = { + txHash, + status, + blockchain: String(data.blockchain ?? data.chain ?? ""), + amount: typeof data.amount === "number" ? data.amount : null, + tokenSymbol: data.tokenSymbol ? String(data.tokenSymbol) : null, + projectName: data.projectName ? String(data.projectName) : null, + retirementDetails: data.retirementDetails ?? data.retirement ?? null, + createdAt: data.createdAt ? String(data.createdAt) : null, + updatedAt: data.updatedAt ? String(data.updatedAt) : null, + }; + + if (terminalStates.has(status)) { + return tx; + } + + // Log progress + console.error( + `[ecoBridge] tx ${txHash.slice(0, 10)}... status: ${status} (attempt ${i + 1}/${maxAttempts})` + ); + } catch (err) { + // Transaction may not be indexed yet — keep polling + if (i > 5) { + console.error( + `[ecoBridge] tx ${txHash.slice(0, 10)}... not found yet (attempt ${i + 1}/${maxAttempts})` + ); + } + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + throw new Error( + `ecoBridge transaction ${txHash} did not reach terminal state after ${maxAttempts} attempts (${(maxAttempts * intervalMs) / 1000}s).` + ); +} diff --git a/src/services/evm-wallet.ts b/src/services/evm-wallet.ts new file mode 100644 index 0000000..2b2bb1e --- /dev/null +++ b/src/services/evm-wallet.ts @@ -0,0 +1,132 @@ +/** + * EVM wallet service for ecoBridge payments. + * + * Sends ERC-20 tokens (USDC, etc.) on Base or other EVM chains + * to ecoBridge project wallets for credit retirement. + */ + +import { ethers } from "ethers"; +import { loadConfig } from "../config.js"; + +// Well-known USDC contract addresses per chain +const USDC_ADDRESSES: Record = { + base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + polygon: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + optimism: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + celo: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", +}; + +// Public RPC endpoints per chain +const RPC_URLS: Record = { + base: "https://mainnet.base.org", + ethereum: "https://eth.llamarpc.com", + polygon: "https://polygon-rpc.com", + arbitrum: "https://arb1.arbitrum.io/rpc", + optimism: "https://mainnet.optimism.io", + celo: "https://forno.celo.org", +}; + +// Minimal ERC-20 ABI for transfer + balanceOf +const ERC20_ABI = [ + "function transfer(address to, uint256 amount) returns (bool)", + "function balanceOf(address account) view returns (uint256)", + "function decimals() view returns (uint8)", + "function symbol() view returns (string)", +]; + +let _wallet: ethers.HDNodeWallet | null = null; + +function getWallet(): ethers.HDNodeWallet { + if (_wallet) return _wallet; + const config = loadConfig(); + if (!config.ecoBridgeEvmMnemonic) { + throw new Error( + "ECOBRIDGE_EVM_MNEMONIC not configured. Set it in .env to enable cross-chain retirement." + ); + } + _wallet = ethers.HDNodeWallet.fromPhrase( + config.ecoBridgeEvmMnemonic, + "", + config.ecoBridgeEvmDerivationPath + ); + return _wallet; +} + +export function getEvmAddress(): string { + return getWallet().address; +} + +export function isEvmWalletConfigured(): boolean { + return !!loadConfig().ecoBridgeEvmMnemonic; +} + +function getProvider(chain: string): ethers.JsonRpcProvider { + const rpcUrl = RPC_URLS[chain.toLowerCase()]; + if (!rpcUrl) { + throw new Error( + `No RPC URL configured for chain "${chain}". Supported: ${Object.keys(RPC_URLS).join(", ")}` + ); + } + return new ethers.JsonRpcProvider(rpcUrl); +} + +function getUsdcAddress(chain: string): string { + const addr = USDC_ADDRESSES[chain.toLowerCase()]; + if (!addr) { + throw new Error( + `No USDC address known for chain "${chain}". Supported: ${Object.keys(USDC_ADDRESSES).join(", ")}` + ); + } + return addr; +} + +export interface SendUsdcResult { + txHash: string; + from: string; + to: string; + amountUsdc: string; + chain: string; +} + +/** + * Send USDC to a recipient on the specified chain. + * Returns the transaction hash once the tx is mined. + */ +export async function sendUsdc( + chain: string, + toAddress: string, + amountUsdc: number +): Promise { + const provider = getProvider(chain); + const wallet = getWallet().connect(provider); + const usdcAddress = getUsdcAddress(chain); + + const usdc = new ethers.Contract(usdcAddress, ERC20_ABI, wallet); + + // USDC has 6 decimals + const decimals = 6; + const rawAmount = BigInt(Math.round(amountUsdc * 10 ** decimals)); + + // Check balance first + const balance: bigint = await usdc.balanceOf(wallet.address); + if (balance < rawAmount) { + const balanceUsdc = Number(balance) / 10 ** decimals; + throw new Error( + `Insufficient USDC balance on ${chain}. Have: ${balanceUsdc.toFixed(2)} USDC, need: ${amountUsdc} USDC` + ); + } + + // Send the transfer + const tx = await usdc.transfer(toAddress, rawAmount); + const receipt = await tx.wait(); + + return { + txHash: receipt.hash, + from: wallet.address, + to: toAddress, + amountUsdc: amountUsdc.toString(), + chain, + }; +} diff --git a/src/tools/retire.ts b/src/tools/retire.ts index f0f10ad..8f778a9 100644 --- a/src/tools/retire.ts +++ b/src/tools/retire.ts @@ -96,7 +96,7 @@ export async function retireCredits( const config = loadConfig(); const retireJurisdiction = jurisdiction || config.defaultJurisdiction; const retireReason = - reason || "Regenerative contribution via Regen Compute Credits MCP server"; + reason || "Regenerative contribution via Regen for AI"; const retireQuantity = quantity || 1; try { From 32c47bc8d7ba696bcde181bb67af465afce47cf9 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:10:48 -0800 Subject: [PATCH 06/31] feat: ship units 0-9 foundation, services, and tests --- .env.example | 29 +- .github/workflows/ci.yml | 44 + README.md | 81 ++ package-lock.json | 988 ++++++++++++++++++- package.json | 5 +- src/index.ts | 359 ++++++- src/services/attribution/dashboard.ts | 243 +++++ src/services/batch-retirement/attribution.ts | 122 +++ src/services/batch-retirement/executor.ts | 351 +++++++ src/services/batch-retirement/planner.ts | 162 +++ src/services/batch-retirement/store.ts | 57 ++ src/services/batch-retirement/types.ts | 99 ++ src/services/identity.ts | 162 +++ src/services/payment/stripe-stub.ts | 286 +++++- src/services/pool-accounting/service.ts | 275 ++++++ src/services/pool-accounting/store.ts | 52 + src/services/pool-accounting/types.ts | 85 ++ src/services/subscription/stripe.ts | 411 ++++++++ src/services/subscription/tiers.ts | 57 ++ src/services/subscription/types.ts | 38 + src/tools/attribution-dashboard.ts | 154 +++ src/tools/certificates.ts | 27 +- src/tools/monthly-batch-retirement.ts | 102 ++ src/tools/pool-accounting.ts | 143 +++ src/tools/retire.ts | 108 +- src/tools/subscriptions.ts | 147 +++ tests/attribution-dashboard.test.ts | 203 ++++ tests/certificates.test.ts | 51 + tests/estimator.foundation.test.ts | 54 + tests/fractional-attribution.test.ts | 102 ++ tests/identity.test.ts | 57 ++ tests/monthly-batch-executor.test.ts | 187 ++++ tests/order-selector.test.ts | 138 +++ tests/pool-accounting.test.ts | 169 ++++ tests/retire-credits.test.ts | 359 +++++++ tests/stripe-provider.test.ts | 111 +++ tests/subscription-service.test.ts | 155 +++ 37 files changed, 6091 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/services/attribution/dashboard.ts create mode 100644 src/services/batch-retirement/attribution.ts create mode 100644 src/services/batch-retirement/executor.ts create mode 100644 src/services/batch-retirement/planner.ts create mode 100644 src/services/batch-retirement/store.ts create mode 100644 src/services/batch-retirement/types.ts create mode 100644 src/services/identity.ts create mode 100644 src/services/pool-accounting/service.ts create mode 100644 src/services/pool-accounting/store.ts create mode 100644 src/services/pool-accounting/types.ts create mode 100644 src/services/subscription/stripe.ts create mode 100644 src/services/subscription/tiers.ts create mode 100644 src/services/subscription/types.ts create mode 100644 src/tools/attribution-dashboard.ts create mode 100644 src/tools/monthly-batch-retirement.ts create mode 100644 src/tools/pool-accounting.ts create mode 100644 src/tools/subscriptions.ts create mode 100644 tests/attribution-dashboard.test.ts create mode 100644 tests/certificates.test.ts create mode 100644 tests/estimator.foundation.test.ts create mode 100644 tests/fractional-attribution.test.ts create mode 100644 tests/identity.test.ts create mode 100644 tests/monthly-batch-executor.test.ts create mode 100644 tests/order-selector.test.ts create mode 100644 tests/pool-accounting.test.ts create mode 100644 tests/retire-credits.test.ts create mode 100644 tests/stripe-provider.test.ts create mode 100644 tests/subscription-service.test.ts diff --git a/.env.example b/.env.example index 82f0f6f..b79664e 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,7 @@ REGEN_RPC_URL=http://mainnet.regen.network:26657 # Regen chain ID (regen-1 for mainnet, regen-redwood-1 for testnet) REGEN_CHAIN_ID=regen-1 -# Payment provider: "crypto" (wallet balance) or "stripe" (coming soon) +# Payment provider: "crypto" (wallet balance) or "stripe" (card authorization + capture) REGEN_PAYMENT_PROVIDER=crypto # Default retirement jurisdiction (ISO 3166-1 alpha-2, e.g., US, DE, BR) @@ -34,14 +34,25 @@ REGEN_DEFAULT_JURISDICTION=US # OAUTH_CLIENT_ID= # OAUTH_CLIENT_SECRET= -# Optional: Stripe (future - requires Regen team integration) +# Optional: Stripe payment intent settings (required if REGEN_PAYMENT_PROVIDER=stripe) # STRIPE_SECRET_KEY= +# STRIPE_PAYMENT_METHOD_ID= +# STRIPE_CUSTOMER_ID= # STRIPE_WEBHOOK_SECRET= -# --- ecoBridge Integration --- -# Enable cross-chain token acceptance via bridge.eco -# When enabled, users can pay for credit retirement with USDC, USDT, ETH, etc. -# across Ethereum, Polygon, Arbitrum, Base, Celo, Optimism, Solana, and more. -ECOBRIDGE_API_URL=https://api.bridge.eco -ECOBRIDGE_ENABLED=true -ECOBRIDGE_CACHE_TTL_MS=60000 +# Optional: Stripe recurring subscription price IDs for $1/$3/$5 tiers +# (required for manage_subscription action=subscribe) +# STRIPE_PRICE_ID_STARTER=price_... +# STRIPE_PRICE_ID_GROWTH=price_... +# STRIPE_PRICE_ID_IMPACT=price_... + +# Optional: ecoBridge integration +# ECOBRIDGE_API_URL=https://api.bridge.eco +# ECOBRIDGE_ENABLED=true +# ECOBRIDGE_CACHE_TTL_MS=60000 + +# Optional: pool accounting ledger file path (default: ./data/pool-accounting-ledger.json) +# REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json + +# Optional: monthly batch execution history file path (default: ./data/monthly-batch-executions.json) +# REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1214ae8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + pull_request: + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run typecheck + run: npm run typecheck + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test diff --git a/README.md b/README.md index f8c8586..fa3f676 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,65 @@ Shows aggregate ecological impact statistics from Regen Network — live on-chai | Kilo-Sheep-Hour (KSH) | Grazing-based stewardship | ``` +### `list_subscription_tiers` + +Lists recurring contribution tiers and configuration status for Stripe price mappings. + +**When it's used:** The user wants to see available monthly plans (`$1/$3/$5`) before subscribing. + +**Example output:** +``` +## Subscription Tiers + +| Tier | Name | Monthly Price | Description | Stripe Price Config | +|------|------|---------------|-------------|---------------------| +| starter | Starter | $1/month | Entry tier for monthly ecological contributions. | Configured | +| growth | Growth | $3/month | Balanced recurring contribution tier. | Configured | +| impact | Impact | $5/month | Highest monthly contribution tier. | Configured | +``` + +### `manage_subscription` + +Creates, updates, checks, or cancels Stripe subscription state for a customer. + +**Actions:** `subscribe`, `status`, `cancel` + +**Parameters:** + +| Parameter | Description | +|-----------|-------------| +| `action` | One of `subscribe`, `status`, `cancel`. Required. | +| `tier` | Plan ID for subscribe: `starter` ($1), `growth` ($3), `impact` ($5). | +| `email` | Customer email for lookup/creation. | +| `customer_id` | Existing Stripe customer ID (alternative to email). | +| `full_name` | Name to use when creating a Stripe customer. | +| `payment_method_id` | Stripe PaymentMethod to set as default when subscribing. | + +### `record_pool_contribution` + +Records a contribution event in the pool ledger for per-user accounting and monthly aggregation. + +**When it's used:** Internal/admin workflows that ingest paid subscription events into the retirement pool ledger. + +### `get_pool_accounting_summary` + +Returns either: +- user-level contribution summary (lifetime + by-month totals), or +- month-level pool aggregate summary (total amount + contributors). + +**When it's used:** Preparing monthly batch retirement inputs and reconciling contributor balances. + +### `run_monthly_batch_retirement` + +Executes the monthly pooled retirement batch from the contribution ledger. + +Supports: +- `dry_run=true` planning (default, no on-chain transaction), +- real execution with `dry_run=false`, +- duplicate-run protection per month (override with `force=true`). + +**When it's used:** Running the monthly pooled buy-and-retire process from aggregated subscription funds. + ### `retire_credits` Retires ecocredits on Regen Network. Operates in two modes: @@ -170,10 +229,14 @@ When `ECOBRIDGE_ENABLED=true`, the fallback message also suggests `retire_via_ec | `credit_class` | Credit class to retire (e.g., 'C01', 'BT01'). Optional. | | `quantity` | Number of credits to retire. Optional (defaults to 1). | | `beneficiary_name` | Name for the retirement certificate. Optional. | +| `beneficiary_email` | Email for retirement attribution metadata. Optional. | +| `auth_provider` | OAuth provider for identity attribution (e.g., `google`, `github`). Optional (requires `auth_subject`). | +| `auth_subject` | OAuth user subject/ID for attribution metadata. Optional (requires `auth_provider`). | | `jurisdiction` | Retirement jurisdiction (ISO 3166-1, e.g., 'US', 'DE'). Optional. | | `reason` | Reason for retiring credits (recorded on-chain). Optional. | **When it's used:** The user wants to take action and actually fund ecological regeneration. +When provided, identity attribution fields are embedded into retirement reason metadata so `get_retirement_certificate` can display user attribution details later. ### `browse_ecobridge_tokens` @@ -266,6 +329,24 @@ export REGEN_WALLET_MNEMONIC="your 24 word mnemonic here" export REGEN_RPC_URL=https://mainnet.regen.network:26657 export REGEN_CHAIN_ID=regen-1 ``` +Stripe-backed authorization/capture is also supported: + +```bash +export REGEN_PAYMENT_PROVIDER=stripe +export STRIPE_SECRET_KEY=sk_live_... +export STRIPE_PAYMENT_METHOD_ID=pm_... +# optional if your payment method is attached to a customer +export STRIPE_CUSTOMER_ID=cus_... +export STRIPE_PRICE_ID_STARTER=price_... +export STRIPE_PRICE_ID_GROWTH=price_... +export STRIPE_PRICE_ID_IMPACT=price_... +# optional custom ledger path for pool accounting service +export REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json +# optional custom history path for monthly batch executions +export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json +``` + +Note: Stripe mode currently requires USDC-denominated sell orders (`uusdc`) so fiat charges map cleanly to on-chain pricing. ## Cross-Chain Payment via ecoBridge diff --git a/package-lock.json b/package-lock.json index 65aa35c..5601676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "devDependencies": { "@types/node": "^20.0.0", "tsx": "^4.0.0", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^4.0.18" }, "engines": { "node": ">=20.0.0" @@ -642,6 +643,13 @@ "hono": "^4" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -983,29 +991,522 @@ "follow-redirects": "^1.14.0" } }, - "node_modules/@regen-network/api/node_modules/cosmjs-types": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/cosmjs-types/-/cosmjs-types-0.8.0.tgz", - "integrity": "sha512-Q2Mj95Fl0PYMWEhA2LuGEIhipF7mQwd9gTQ85DdP9jjjopeoGaDxvmPa5nakNzsq7FnO1DMTatXTAx6bxMH7Lg==", - "license": "Apache-2.0", + "node_modules/@regen-network/api/node_modules/cosmjs-types": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cosmjs-types/-/cosmjs-types-0.8.0.tgz", + "integrity": "sha512-Q2Mj95Fl0PYMWEhA2LuGEIhipF7mQwd9gTQ85DdP9jjjopeoGaDxvmPa5nakNzsq7FnO1DMTatXTAx6bxMH7Lg==", + "license": "Apache-2.0", + "dependencies": { + "long": "^4.0.0", + "protobufjs": "~6.11.2" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", "dependencies": { - "long": "^4.0.0", - "protobufjs": "~6.11.2" + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@types/node": { - "version": "20.19.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", - "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/accepts": { @@ -1060,6 +1561,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1177,6 +1688,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1403,6 +1924,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1478,6 +2006,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1584,6 +2122,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1667,6 +2215,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2069,6 +2635,16 @@ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", "license": "Apache-2.0" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2142,6 +2718,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2181,6 +2776,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2230,6 +2836,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2239,6 +2872,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/protobufjs": { "version": "6.11.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", @@ -2348,6 +3010,51 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2514,6 +3221,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2523,6 +3254,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/symbol-observable": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-2.0.3.tgz", @@ -2532,6 +3270,50 @@ "node": ">=0.10" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2619,6 +3401,159 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2634,6 +3569,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 7dfbcc1..0922368 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build" }, @@ -62,6 +64,7 @@ "devDependencies": { "@types/node": "^20.0.0", "tsx": "^4.0.0", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^4.0.18" } } diff --git a/src/index.ts b/src/index.ts index c35760d..ccb96d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,19 @@ import { browseAvailableCredits } from "./tools/credits.js"; import { getRetirementCertificate } from "./tools/certificates.js"; import { getImpactSummary } from "./tools/impact.js"; import { retireCredits } from "./tools/retire.js"; +import { + listSubscriptionTiersTool, + manageSubscriptionTool, +} from "./tools/subscriptions.js"; +import { + getPoolAccountingSummaryTool, + recordPoolContributionTool, +} from "./tools/pool-accounting.js"; +import { runMonthlyBatchRetirementTool } from "./tools/monthly-batch-retirement.js"; +import { + getSubscriberAttributionCertificateTool, + getSubscriberImpactDashboardTool, +} from "./tools/attribution-dashboard.js"; import { loadConfig, isWalletConfigured } from "./config.js"; import { fetchRegistry, @@ -89,14 +102,22 @@ const server = new McpServer( "2. retire_via_ecobridge — generate a payment link using USDC, ETH, or any supported token", ] : []), + "5. list_subscription_tiers / manage_subscription — manage $1/$3/$5 recurring contribution plans", + "6. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", + "7. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", + "8. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", "", ...(walletMode ? [ "The retire_credits tool executes real on-chain transactions. Credits are permanently retired.", ] : [ - "All tools in this server are read-only and safe to call at any time.", + "Without a wallet, retire_credits returns marketplace links instead of broadcasting on-chain transactions.", ]), + "The manage_subscription tool can create, update, or cancel Stripe subscriptions.", + "Pool accounting tools support per-user contribution tracking and monthly aggregation summaries.", + "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", + "Subscriber dashboard tools expose fractional attribution and impact history per user.", ].join("\n"), } ); @@ -188,12 +209,310 @@ server.tool( } ); +// Tool: List available subscription tiers +server.tool( + "list_subscription_tiers", + "Lists recurring contribution tiers for the Regen membership plans ($1/$3/$5 monthly) and shows whether Stripe price IDs are configured for each tier.", + {}, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async () => { + return listSubscriptionTiersTool(); + } +); + +// Tool: Create/update/check/cancel Stripe subscription state +server.tool( + "manage_subscription", + "Manages customer subscription state for Regen contribution plans. Actions: subscribe to a tier, check current status, or cancel at period end.", + { + action: z + .enum(["subscribe", "status", "cancel"]) + .describe("Operation to perform: subscribe, status, or cancel"), + tier: z + .enum(["starter", "growth", "impact"]) + .optional() + .describe("Tier ID for subscribe: starter ($1), growth ($3), impact ($5)"), + email: z + .string() + .optional() + .describe("Customer email used for lookup or creation"), + customer_id: z + .string() + .optional() + .describe("Existing Stripe customer ID (optional alternative to email)"), + full_name: z + .string() + .optional() + .describe("Customer display name (used when creating Stripe customer)"), + payment_method_id: z + .string() + .optional() + .describe("Stripe PaymentMethod ID to set as default for subscriptions"), + }, + { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + async ({ action, tier, email, customer_id, full_name, payment_method_id }) => { + return manageSubscriptionTool( + action, + tier, + email, + customer_id, + full_name, + payment_method_id + ); + } +); + +// Tool: Record a contribution entry in the pool accounting ledger +server.tool( + "record_pool_contribution", + "Records a contribution event for subscription pool accounting. Tracks per-user contribution ledger entries for monthly aggregation and batch retirement planning.", + { + user_id: z + .string() + .optional() + .describe("Internal stable user ID (optional if customer_id or email is provided)"), + email: z + .string() + .optional() + .describe("User email (optional alternative identifier)"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID (optional alternative identifier)"), + subscription_id: z + .string() + .optional() + .describe("Stripe subscription ID associated with this contribution"), + tier: z + .enum(["starter", "growth", "impact"]) + .optional() + .describe("Tier ID; if amount is omitted this determines default amount ($1/$3/$5)"), + amount_usd: z + .number() + .optional() + .describe("Contribution amount in USD (decimal), e.g. 3 or 2.5"), + amount_usd_cents: z + .number() + .int() + .optional() + .describe("Contribution amount in USD cents"), + contributed_at: z + .string() + .optional() + .describe("ISO timestamp of the contribution event"), + source: z + .enum(["subscription", "manual", "adjustment"]) + .optional() + .describe("Contribution source type"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async ({ + user_id, + email, + customer_id, + subscription_id, + tier, + amount_usd, + amount_usd_cents, + contributed_at, + source, + }) => { + return recordPoolContributionTool({ + userId: user_id, + email, + customerId: customer_id, + subscriptionId: subscription_id, + tierId: tier, + amountUsd: amount_usd, + amountUsdCents: amount_usd_cents, + contributedAt: contributed_at, + source, + }); + } +); + +// Tool: Query per-user or monthly pool accounting summaries +server.tool( + "get_pool_accounting_summary", + "Returns pool accounting summaries for either a user or a month. Use user identifiers for lifetime/by-month user totals, or pass month for aggregate pool totals.", + { + month: z + .string() + .optional() + .describe("Month in YYYY-MM format for monthly pool summary"), + user_id: z + .string() + .optional() + .describe("Internal user ID for user-specific summary"), + email: z + .string() + .optional() + .describe("Email for user-specific summary"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID for user-specific summary"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async ({ month, user_id, email, customer_id }) => { + return getPoolAccountingSummaryTool(month, user_id, email, customer_id); + } +); + +// Tool: Execute monthly pooled retirement run +server.tool( + "run_monthly_batch_retirement", + "Executes a monthly pooled retirement batch using recorded pool contributions. Supports dry-run planning and real on-chain execution.", + { + month: z + .string() + .describe("Target month in YYYY-MM format, e.g. 2026-03"), + credit_type: z + .enum(["carbon", "biodiversity"]) + .optional() + .describe("Optional credit type filter for the batch retirement"), + max_budget_usd: z + .number() + .optional() + .describe("Optional max budget in USD for this run"), + dry_run: z + .boolean() + .optional() + .default(true) + .describe("If true, plans the batch without broadcasting a transaction"), + force: z + .boolean() + .optional() + .default(false) + .describe("If true, allows rerunning a month even if a prior success exists"), + reason: z + .string() + .optional() + .describe("Optional retirement reason override"), + jurisdiction: z + .string() + .optional() + .describe("Optional retirement jurisdiction override"), + }, + { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + async ({ + month, + credit_type, + max_budget_usd, + dry_run, + force, + reason, + jurisdiction, + }) => { + return runMonthlyBatchRetirementTool( + month, + credit_type, + max_budget_usd, + dry_run, + force, + reason, + jurisdiction + ); + } +); + +// Tool: User-facing fractional impact dashboard +server.tool( + "get_subscriber_impact_dashboard", + "Returns a user-facing dashboard of pooled contribution history and fractional retirement attribution impact.", + { + user_id: z + .string() + .optional() + .describe("Internal user ID"), + email: z + .string() + .optional() + .describe("User email"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async ({ user_id, email, customer_id }) => { + return getSubscriberImpactDashboardTool(user_id, email, customer_id); + } +); + +// Tool: User-facing per-month attribution certificate +server.tool( + "get_subscriber_attribution_certificate", + "Returns a user-facing certificate for a subscriber's fractional attribution in a specific monthly pooled retirement batch.", + { + month: z + .string() + .describe("Target month in YYYY-MM format"), + user_id: z + .string() + .optional() + .describe("Internal user ID"), + email: z + .string() + .optional() + .describe("User email"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async ({ month, user_id, email, customer_id }) => { + return getSubscriberAttributionCertificateTool( + month, + user_id, + email, + customer_id + ); + } +); + // Tool: Retire credits — either direct on-chain execution or marketplace link server.tool( "retire_credits", walletMode - ? "Purchases and retires ecocredits directly on-chain on Regen Network. Use this when the user wants to take action — offset their footprint, fund ecological regeneration, or retire credits. Credits are permanently retired on-chain in a single transaction. Returns a retirement certificate with on-chain proof." - : "Generates a link to retire ecocredits on Regen Network marketplace via credit card. Use this when the user wants to take action — offset their footprint, fund ecological regeneration, or retire credits for any reason. Credits are permanently retired on-chain with the user's name as beneficiary. No crypto wallet needed. Returns a direct marketplace link and step-by-step instructions.", + ? "Purchases and retires ecocredits directly on-chain on Regen Network. Use this when the user wants to take action — offset their footprint, fund ecological regeneration, or retire credits. Credits are permanently retired on-chain in a single transaction. Supports beneficiary attribution via name/email/OAuth metadata and returns a retirement certificate with on-chain proof." + : "Generates a link to retire ecocredits on Regen Network marketplace via credit card. Use this when the user wants to take action — offset their footprint, fund ecological regeneration, or retire credits for any reason. Credits are permanently retired on-chain with optional identity attribution metadata. No crypto wallet needed. Returns a direct marketplace link and step-by-step instructions.", { credit_class: z .string() @@ -209,6 +528,18 @@ server.tool( .string() .optional() .describe("Name to appear on the retirement certificate"), + beneficiary_email: z + .string() + .optional() + .describe("Email to attribute to the retirement certificate"), + auth_provider: z + .string() + .optional() + .describe("OAuth provider name for identity attribution (e.g., google, github)"), + auth_subject: z + .string() + .optional() + .describe("OAuth subject/user ID for identity attribution"), jurisdiction: z .string() .optional() @@ -226,8 +557,26 @@ server.tool( idempotentHint: !walletMode, openWorldHint: walletMode, }, - async ({ credit_class, quantity, beneficiary_name, jurisdiction, reason }) => { - return retireCredits(credit_class, quantity, beneficiary_name, jurisdiction, reason); + async ({ + credit_class, + quantity, + beneficiary_name, + beneficiary_email, + auth_provider, + auth_subject, + jurisdiction, + reason, + }) => { + return retireCredits( + credit_class, + quantity, + beneficiary_name, + jurisdiction, + reason, + beneficiary_email, + auth_provider, + auth_subject + ); } ); diff --git a/src/services/attribution/dashboard.ts b/src/services/attribution/dashboard.ts new file mode 100644 index 0000000..117afb7 --- /dev/null +++ b/src/services/attribution/dashboard.ts @@ -0,0 +1,243 @@ +import { PoolAccountingService } from "../pool-accounting/service.js"; +import { JsonFileBatchExecutionStore } from "../batch-retirement/store.js"; +import type { + BatchExecutionRecord, + BatchExecutionStore, + ContributorAttribution, +} from "../batch-retirement/types.js"; + +const MICRO_FACTOR = 1_000_000n; + +function parseQuantityToMicro(quantity: string): bigint { + const [wholePart, fracPart = ""] = quantity.split("."); + const whole = BigInt(wholePart || "0"); + const fracPadded = fracPart.padEnd(6, "0").slice(0, 6); + const frac = BigInt(fracPadded || "0"); + return whole * MICRO_FACTOR + frac; +} + +function microToQuantity(micro: bigint): string { + const whole = micro / MICRO_FACTOR; + const frac = (micro % MICRO_FACTOR).toString().padStart(6, "0"); + return `${whole.toString()}.${frac}`; +} + +function normalize(value?: string): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeEmail(email?: string): string | undefined { + const value = normalize(email); + return value ? value.toLowerCase() : undefined; +} + +function findAttribution( + execution: BatchExecutionRecord, + userId: string +): ContributorAttribution | undefined { + return execution.attributions?.find((item) => item.userId === userId); +} + +export interface SubscriberAttributionEntry { + month: string; + executionId: string; + executionStatus: BatchExecutionRecord["status"]; + executedAt: string; + reason: string; + creditType?: "carbon" | "biodiversity"; + retirementId?: string; + txHash?: string; + sharePpm: number; + attributedBudgetUsdCents: number; + attributedCostMicro: string; + paymentDenom: string; + attributedQuantity: string; +} + +export interface SubscriberImpactDashboard { + userId: string; + email?: string; + customerId?: string; + contributionCount: number; + totalContributedUsdCents: number; + totalContributedUsd: number; + totalAttributedBudgetUsdCents: number; + totalAttributedBudgetUsd: number; + totalAttributedQuantity: string; + attributionCount: number; + byMonth: Array<{ + month: string; + contributionUsdCents: number; + contributionUsd: number; + attributedBudgetUsdCents: number; + attributedBudgetUsd: number; + attributedQuantity: string; + }>; + attributions: SubscriberAttributionEntry[]; +} + +export interface SubscriberAttributionCertificate { + userId: string; + email?: string; + customerId?: string; + month: string; + contributionUsdCents: number; + contributionUsd: number; + execution: SubscriberAttributionEntry; +} + +export interface AttributionDashboardDeps { + poolAccounting: Pick; + executionStore: BatchExecutionStore; +} + +export class AttributionDashboardService { + private readonly deps: AttributionDashboardDeps; + + constructor(deps?: Partial) { + this.deps = { + poolAccounting: deps?.poolAccounting || new PoolAccountingService(), + executionStore: deps?.executionStore || new JsonFileBatchExecutionStore(), + }; + } + + async getSubscriberDashboard(identifier: { + userId?: string; + email?: string; + customerId?: string; + }): Promise { + const summary = await this.deps.poolAccounting.getUserSummary({ + userId: normalize(identifier.userId), + email: normalizeEmail(identifier.email), + customerId: normalize(identifier.customerId), + }); + if (!summary) return null; + + const state = await this.deps.executionStore.readState(); + const attributions = state.executions + .filter((execution) => execution.status === "success") + .flatMap((execution) => { + const attribution = findAttribution(execution, summary.userId); + if (!attribution) return []; + + const entry: SubscriberAttributionEntry = { + month: execution.month, + executionId: execution.id, + executionStatus: execution.status, + executedAt: execution.executedAt, + reason: execution.reason, + sharePpm: attribution.sharePpm, + attributedBudgetUsdCents: attribution.attributedBudgetUsdCents, + attributedCostMicro: attribution.attributedCostMicro, + paymentDenom: attribution.paymentDenom, + attributedQuantity: attribution.attributedQuantity, + }; + + if (execution.creditType) entry.creditType = execution.creditType; + if (execution.retirementId) entry.retirementId = execution.retirementId; + if (execution.txHash) entry.txHash = execution.txHash; + + return [entry]; + }) + .sort((a, b) => b.month.localeCompare(a.month)); + + const totalAttributedBudgetUsdCents = attributions.reduce( + (sum, item) => sum + item.attributedBudgetUsdCents, + 0 + ); + const totalAttributedQuantityMicro = attributions.reduce( + (sum, item) => sum + parseQuantityToMicro(item.attributedQuantity), + 0n + ); + + const byMonthMap = new Map< + string, + { + month: string; + contributionUsdCents: number; + attributedBudgetUsdCents: number; + attributedQuantityMicro: bigint; + } + >(); + + for (const monthContribution of summary.byMonth) { + byMonthMap.set(monthContribution.month, { + month: monthContribution.month, + contributionUsdCents: monthContribution.totalUsdCents, + attributedBudgetUsdCents: 0, + attributedQuantityMicro: 0n, + }); + } + + for (const entry of attributions) { + const existing = byMonthMap.get(entry.month) || { + month: entry.month, + contributionUsdCents: 0, + attributedBudgetUsdCents: 0, + attributedQuantityMicro: 0n, + }; + existing.attributedBudgetUsdCents += entry.attributedBudgetUsdCents; + existing.attributedQuantityMicro += parseQuantityToMicro( + entry.attributedQuantity + ); + byMonthMap.set(entry.month, existing); + } + + const byMonth = [...byMonthMap.values()] + .sort((a, b) => b.month.localeCompare(a.month)) + .map((month) => ({ + month: month.month, + contributionUsdCents: month.contributionUsdCents, + contributionUsd: month.contributionUsdCents / 100, + attributedBudgetUsdCents: month.attributedBudgetUsdCents, + attributedBudgetUsd: month.attributedBudgetUsdCents / 100, + attributedQuantity: microToQuantity(month.attributedQuantityMicro), + })); + + return { + userId: summary.userId, + email: summary.email, + customerId: summary.customerId, + contributionCount: summary.contributionCount, + totalContributedUsdCents: summary.totalUsdCents, + totalContributedUsd: summary.totalUsd, + totalAttributedBudgetUsdCents, + totalAttributedBudgetUsd: totalAttributedBudgetUsdCents / 100, + totalAttributedQuantity: microToQuantity(totalAttributedQuantityMicro), + attributionCount: attributions.length, + byMonth, + attributions, + }; + } + + async getSubscriberCertificateForMonth(input: { + month: string; + userId?: string; + email?: string; + customerId?: string; + }): Promise { + const dashboard = await this.getSubscriberDashboard(input); + if (!dashboard) return null; + + const execution = dashboard.attributions.find( + (item) => item.month === input.month + ); + if (!execution) return null; + + const monthContribution = dashboard.byMonth.find( + (item) => item.month === input.month + ); + + return { + userId: dashboard.userId, + email: dashboard.email, + customerId: dashboard.customerId, + month: input.month, + contributionUsdCents: monthContribution?.contributionUsdCents || 0, + contributionUsd: (monthContribution?.contributionUsdCents || 0) / 100, + execution, + }; + } +} diff --git a/src/services/batch-retirement/attribution.ts b/src/services/batch-retirement/attribution.ts new file mode 100644 index 0000000..9fad7ca --- /dev/null +++ b/src/services/batch-retirement/attribution.ts @@ -0,0 +1,122 @@ +import type { MonthlyContributorAggregate } from "../pool-accounting/types.js"; +import type { ContributorAttribution } from "./types.js"; + +const MICRO_FACTOR = 1_000_000n; + +function parseQuantityToMicro(quantity: string): bigint { + const [wholePart, fracPart = ""] = quantity.split("."); + const whole = BigInt(wholePart || "0"); + const fracPadded = fracPart.padEnd(6, "0").slice(0, 6); + const frac = BigInt(fracPadded || "0"); + return whole * MICRO_FACTOR + frac; +} + +function microToQuantity(micro: bigint): string { + const whole = micro / MICRO_FACTOR; + const frac = (micro % MICRO_FACTOR).toString().padStart(6, "0"); + return `${whole.toString()}.${frac}`; +} + +function assertSafeInteger(value: bigint, label: string): number { + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${label} exceeds JS safe integer range`); + } + return Number(value); +} + +function allocateProportionally(total: bigint, weights: bigint[]): bigint[] { + if (total <= 0n || weights.length === 0) { + return weights.map(() => 0n); + } + + const sumWeights = weights.reduce((acc, item) => acc + item, 0n); + if (sumWeights <= 0n) { + return weights.map(() => 0n); + } + + const entries = weights.map((weight, index) => { + const raw = total * weight; + const base = raw / sumWeights; + const remainder = raw % sumWeights; + return { index, weight, base, remainder }; + }); + + let allocated = entries.reduce((acc, item) => acc + item.base, 0n); + let remainderUnits = total - allocated; + + entries.sort((a, b) => { + if (a.remainder > b.remainder) return -1; + if (a.remainder < b.remainder) return 1; + if (a.weight > b.weight) return -1; + if (a.weight < b.weight) return 1; + return a.index - b.index; + }); + + for (let i = 0; i < entries.length && remainderUnits > 0n; i += 1) { + entries[i].base += 1n; + remainderUnits -= 1n; + } + + const result = new Array(weights.length).fill(0n); + for (const item of entries) { + result[item.index] = item.base; + } + + allocated = result.reduce((acc, item) => acc + item, 0n); + if (allocated !== total) { + throw new Error("Proportional allocation mismatch"); + } + + return result; +} + +export interface BuildAttributionsInput { + contributors: MonthlyContributorAggregate[]; + totalContributionUsdCents: number; + appliedBudgetUsdCents: number; + totalCostMicro: bigint; + retiredQuantity: string; + paymentDenom: string; +} + +export function buildContributorAttributions( + input: BuildAttributionsInput +): ContributorAttribution[] { + if (!input.contributors.length) return []; + if (input.totalContributionUsdCents <= 0) return []; + + const weights = input.contributors.map((item) => BigInt(item.totalUsdCents)); + const sumWeights = weights.reduce((acc, item) => acc + item, 0n); + if (sumWeights <= 0n) return []; + + const quantityMicroTotal = parseQuantityToMicro(input.retiredQuantity); + const budgetAllocations = allocateProportionally( + BigInt(input.appliedBudgetUsdCents), + weights + ); + const costAllocations = allocateProportionally(input.totalCostMicro, weights); + const quantityAllocations = allocateProportionally(quantityMicroTotal, weights); + + return input.contributors.map((contributor, index) => { + const weight = weights[index] || 0n; + const sharePpm = + sumWeights > 0n + ? assertSafeInteger((weight * 1_000_000n) / sumWeights, "sharePpm") + : 0; + + return { + userId: contributor.userId, + email: contributor.email, + customerId: contributor.customerId, + sharePpm, + contributionUsdCents: contributor.totalUsdCents, + attributedBudgetUsdCents: assertSafeInteger( + budgetAllocations[index] || 0n, + "attributedBudgetUsdCents" + ), + attributedCostMicro: (costAllocations[index] || 0n).toString(), + attributedQuantity: microToQuantity(quantityAllocations[index] || 0n), + paymentDenom: input.paymentDenom, + }; + }); +} diff --git a/src/services/batch-retirement/executor.ts b/src/services/batch-retirement/executor.ts new file mode 100644 index 0000000..8387a11 --- /dev/null +++ b/src/services/batch-retirement/executor.ts @@ -0,0 +1,351 @@ +import { randomUUID } from "node:crypto"; +import { loadConfig, isWalletConfigured } from "../../config.js"; +import { waitForRetirement } from "../indexer.js"; +import { signAndBroadcast, initWallet } from "../wallet.js"; +import { PoolAccountingService } from "../pool-accounting/service.js"; +import { buildContributorAttributions } from "./attribution.js"; +import { selectOrdersForBudget } from "./planner.js"; +import { JsonFileBatchExecutionStore } from "./store.js"; +import type { + BatchExecutionRecord, + BatchExecutionStore, + BudgetOrderSelection, + RunMonthlyBatchInput, + RunMonthlyBatchResult, +} from "./types.js"; + +const MONTH_REGEX = /^\d{4}-\d{2}$/; + +export interface MonthlyBatchExecutorDeps { + poolAccounting: Pick; + executionStore: BatchExecutionStore; + selectOrdersForBudget: typeof selectOrdersForBudget; + isWalletConfigured: typeof isWalletConfigured; + initWallet: typeof initWallet; + signAndBroadcast: typeof signAndBroadcast; + waitForRetirement: typeof waitForRetirement; + loadConfig: typeof loadConfig; +} + +function usdToCents(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + throw new Error("maxBudgetUsd must be a positive number"); + } + return Math.round(value * 100); +} + +function toBudgetMicro(paymentDenom: "USDC" | "uusdc", usdCents: number): bigint { + if (paymentDenom === "USDC" || paymentDenom === "uusdc") { + // 1 USD = 1,000,000 micro USDC = 100 cents * 10,000 + return BigInt(usdCents) * 10_000n; + } + throw new Error(`Unsupported payment denom for batch retirement: ${paymentDenom}`); +} + +function buildExecutionRecord( + status: "success" | "failed" | "dry_run", + input: { + month: string; + creditType?: "carbon" | "biodiversity"; + reason: string; + budgetUsdCents: number; + selection: BudgetOrderSelection; + attributions?: BatchExecutionRecord["attributions"]; + txHash?: string; + blockHeight?: number; + retirementId?: string; + error?: string; + dryRun: boolean; + } +): BatchExecutionRecord { + return { + id: `batch_${randomUUID()}`, + month: input.month, + creditType: input.creditType, + dryRun: input.dryRun, + status, + reason: input.reason, + budgetUsdCents: input.budgetUsdCents, + spentMicro: input.selection.totalCostMicro.toString(), + spentDenom: input.selection.paymentDenom, + retiredQuantity: input.selection.totalQuantity, + attributions: input.attributions, + txHash: input.txHash, + blockHeight: input.blockHeight, + retirementId: input.retirementId, + error: input.error, + executedAt: new Date().toISOString(), + }; +} + +export class MonthlyBatchRetirementExecutor { + private readonly deps: MonthlyBatchExecutorDeps; + + constructor(deps?: Partial) { + this.deps = { + poolAccounting: deps?.poolAccounting || new PoolAccountingService(), + executionStore: deps?.executionStore || new JsonFileBatchExecutionStore(), + selectOrdersForBudget: deps?.selectOrdersForBudget || selectOrdersForBudget, + isWalletConfigured: deps?.isWalletConfigured || isWalletConfigured, + initWallet: deps?.initWallet || initWallet, + signAndBroadcast: deps?.signAndBroadcast || signAndBroadcast, + waitForRetirement: deps?.waitForRetirement || waitForRetirement, + loadConfig: deps?.loadConfig || loadConfig, + }; + } + + private async hasSuccessfulExecution( + month: string, + creditType?: "carbon" | "biodiversity" + ): Promise { + const state = await this.deps.executionStore.readState(); + return state.executions.some( + (item) => + item.status === "success" && + item.month === month && + item.creditType === creditType + ); + } + + private async appendExecution(record: BatchExecutionRecord): Promise { + const state = await this.deps.executionStore.readState(); + state.executions.push(record); + state.executions.sort((a, b) => a.executedAt.localeCompare(b.executedAt)); + await this.deps.executionStore.writeState(state); + } + + async runMonthlyBatch(input: RunMonthlyBatchInput): Promise { + if (!MONTH_REGEX.test(input.month)) { + throw new Error("month must be in YYYY-MM format"); + } + + if (!input.force) { + const alreadyExecuted = await this.hasSuccessfulExecution( + input.month, + input.creditType + ); + if (alreadyExecuted) { + return { + status: "already_executed", + month: input.month, + creditType: input.creditType, + budgetUsdCents: 0, + plannedQuantity: "0.000000", + plannedCostMicro: 0n, + plannedCostDenom: input.paymentDenom || "USDC", + message: + "A successful monthly batch retirement already exists for this month and credit type. Use force=true to re-run.", + }; + } + } + + const monthlySummary = await this.deps.poolAccounting.getMonthlySummary( + input.month + ); + if (monthlySummary.contributionCount === 0 || monthlySummary.totalUsdCents <= 0) { + return { + status: "no_contributions", + month: input.month, + creditType: input.creditType, + budgetUsdCents: 0, + plannedQuantity: "0.000000", + plannedCostMicro: 0n, + plannedCostDenom: input.paymentDenom || "USDC", + message: `No pool contributions found for ${input.month}.`, + }; + } + + const totalBudgetUsdCents = input.maxBudgetUsd + ? Math.min(monthlySummary.totalUsdCents, usdToCents(input.maxBudgetUsd)) + : monthlySummary.totalUsdCents; + + const paymentDenom = input.paymentDenom || "USDC"; + const budgetMicro = toBudgetMicro(paymentDenom, totalBudgetUsdCents); + + const selection = await this.deps.selectOrdersForBudget( + input.creditType, + budgetMicro, + paymentDenom + ); + + if (selection.orders.length === 0) { + return { + status: "no_orders", + month: input.month, + creditType: input.creditType, + budgetUsdCents: totalBudgetUsdCents, + plannedQuantity: selection.totalQuantity, + plannedCostMicro: selection.totalCostMicro, + plannedCostDenom: selection.paymentDenom, + message: + "No eligible sell orders were found for the configured budget and filters.", + }; + } + + const attributions = buildContributorAttributions({ + contributors: monthlySummary.contributors, + totalContributionUsdCents: monthlySummary.totalUsdCents, + appliedBudgetUsdCents: totalBudgetUsdCents, + totalCostMicro: selection.totalCostMicro, + retiredQuantity: selection.totalQuantity, + paymentDenom: selection.paymentDenom, + }); + + const config = this.deps.loadConfig(); + const retireJurisdiction = input.jurisdiction || config.defaultJurisdiction; + const retireReason = + input.reason || `Monthly subscription pool retirement (${input.month})`; + + if (input.dryRun !== false) { + const record = buildExecutionRecord("dry_run", { + month: input.month, + creditType: input.creditType, + reason: retireReason, + budgetUsdCents: totalBudgetUsdCents, + selection, + attributions, + dryRun: true, + }); + await this.appendExecution(record); + return { + status: "dry_run", + month: input.month, + creditType: input.creditType, + budgetUsdCents: totalBudgetUsdCents, + plannedQuantity: selection.totalQuantity, + plannedCostMicro: selection.totalCostMicro, + plannedCostDenom: selection.paymentDenom, + attributions, + message: "Dry run complete. No on-chain transaction was broadcast.", + executionRecord: record, + }; + } + + if (!this.deps.isWalletConfigured()) { + return { + status: "wallet_not_configured", + month: input.month, + creditType: input.creditType, + budgetUsdCents: totalBudgetUsdCents, + plannedQuantity: selection.totalQuantity, + plannedCostMicro: selection.totalCostMicro, + plannedCostDenom: selection.paymentDenom, + attributions, + message: + "Wallet is not configured. Set REGEN_WALLET_MNEMONIC before executing monthly batch retirements.", + }; + } + + try { + const { address } = await this.deps.initWallet(); + const orders = selection.orders.map((order) => ({ + sellOrderId: BigInt(order.sellOrderId), + quantity: order.quantity, + bidPrice: { + denom: order.askDenom, + amount: order.askAmount, + }, + disableAutoRetire: false, + retirementJurisdiction: retireJurisdiction, + retirementReason: retireReason, + })); + + const txResult = await this.deps.signAndBroadcast([ + { + typeUrl: "/regen.ecocredit.marketplace.v1.MsgBuyDirect", + value: { + buyer: address, + orders, + }, + }, + ]); + + if (txResult.code !== 0) { + const record = buildExecutionRecord("failed", { + month: input.month, + creditType: input.creditType, + reason: retireReason, + budgetUsdCents: totalBudgetUsdCents, + selection, + attributions, + error: `Transaction rejected (code ${txResult.code}): ${ + txResult.rawLog || "unknown error" + }`, + dryRun: false, + }); + await this.appendExecution(record); + + return { + status: "failed", + month: input.month, + creditType: input.creditType, + budgetUsdCents: totalBudgetUsdCents, + plannedQuantity: selection.totalQuantity, + plannedCostMicro: selection.totalCostMicro, + plannedCostDenom: selection.paymentDenom, + attributions, + message: record.error || "Monthly batch transaction failed.", + executionRecord: record, + }; + } + + const retirement = await this.deps.waitForRetirement(txResult.transactionHash); + const record = buildExecutionRecord("success", { + month: input.month, + creditType: input.creditType, + reason: retireReason, + budgetUsdCents: totalBudgetUsdCents, + selection, + attributions, + txHash: txResult.transactionHash, + blockHeight: txResult.height, + retirementId: retirement?.nodeId, + dryRun: false, + }); + await this.appendExecution(record); + + return { + status: "success", + month: input.month, + creditType: input.creditType, + budgetUsdCents: totalBudgetUsdCents, + plannedQuantity: selection.totalQuantity, + plannedCostMicro: selection.totalCostMicro, + plannedCostDenom: selection.paymentDenom, + attributions, + txHash: txResult.transactionHash, + blockHeight: txResult.height, + retirementId: retirement?.nodeId, + message: "Monthly batch retirement completed successfully.", + executionRecord: record, + }; + } catch (error) { + const errMsg = + error instanceof Error ? error.message : "Unknown batch execution error"; + const record = buildExecutionRecord("failed", { + month: input.month, + creditType: input.creditType, + reason: retireReason, + budgetUsdCents: totalBudgetUsdCents, + selection, + attributions, + error: errMsg, + dryRun: false, + }); + await this.appendExecution(record); + + return { + status: "failed", + month: input.month, + creditType: input.creditType, + budgetUsdCents: totalBudgetUsdCents, + plannedQuantity: selection.totalQuantity, + plannedCostMicro: selection.totalCostMicro, + plannedCostDenom: selection.paymentDenom, + attributions, + message: errMsg, + executionRecord: record, + }; + } + } +} diff --git a/src/services/batch-retirement/planner.ts b/src/services/batch-retirement/planner.ts new file mode 100644 index 0000000..fa18605 --- /dev/null +++ b/src/services/batch-retirement/planner.ts @@ -0,0 +1,162 @@ +import { + getAllowedDenoms, + listCreditClasses, + listSellOrders, + type AllowedDenom, +} from "../ledger.js"; +import type { BudgetOrderSelection, BudgetSelectedOrder } from "./types.js"; + +function pickDenom( + allowedDenoms: AllowedDenom[], + preferred?: string +): { bankDenom: string; displayDenom: string; exponent: number } { + if (preferred) { + const preferredLower = preferred.toLowerCase(); + const match = allowedDenoms.find( + (d) => + d.bank_denom.toLowerCase() === preferredLower || + d.display_denom.toLowerCase() === preferredLower + ); + if (match) { + return { + bankDenom: match.bank_denom, + displayDenom: match.display_denom, + exponent: match.exponent, + }; + } + } + + const regen = allowedDenoms.find( + (d) => d.display_denom === "REGEN" || d.bank_denom === "uregen" + ); + if (regen) { + return { + bankDenom: regen.bank_denom, + displayDenom: regen.display_denom, + exponent: regen.exponent, + }; + } + + if (allowedDenoms.length > 0) { + const first = allowedDenoms[0]; + return { + bankDenom: first.bank_denom, + displayDenom: first.display_denom, + exponent: first.exponent, + }; + } + + return { bankDenom: "uregen", displayDenom: "REGEN", exponent: 6 }; +} + +function toMicroQuantityString(microQuantity: bigint): string { + const whole = microQuantity / 1_000_000n; + const frac = (microQuantity % 1_000_000n).toString().padStart(6, "0"); + return `${whole.toString()}.${frac}`; +} + +export async function selectOrdersForBudget( + creditType: "carbon" | "biodiversity" | undefined, + budgetMicro: bigint, + preferredDenom?: string +): Promise { + if (budgetMicro <= 0n) { + throw new Error("budgetMicro must be greater than zero"); + } + + const [sellOrders, classes, allowedDenoms] = await Promise.all([ + listSellOrders(), + listCreditClasses(), + getAllowedDenoms(), + ]); + + const classTypeMap = new Map(); + for (const cls of classes) { + classTypeMap.set(cls.id, cls.credit_type_abbrev); + } + + const denomInfo = pickDenom(allowedDenoms, preferredDenom); + + const eligible = sellOrders.filter((order) => { + if (order.disable_auto_retire) return false; + if (order.ask_denom !== denomInfo.bankDenom) return false; + + if (creditType) { + const classId = order.batch_denom.split("-").slice(0, 1).join(""); + const abbrev = classTypeMap.get(classId); + if (!abbrev) return false; + if (creditType === "carbon" && abbrev !== "C") return false; + if (creditType === "biodiversity" && abbrev === "C") return false; + } + + if (order.expiration) { + const expDate = new Date(order.expiration); + if (expDate <= new Date()) return false; + } + + return true; + }); + + eligible.sort((a, b) => { + const aPrice = BigInt(a.ask_amount); + const bPrice = BigInt(b.ask_amount); + if (aPrice < bPrice) return -1; + if (aPrice > bPrice) return 1; + return 0; + }); + + const MICRO_CREDITS = 1_000_000n; + let remainingBudget = budgetMicro; + let totalQuantityMicro = 0n; + let totalCostMicro = 0n; + const selected: BudgetSelectedOrder[] = []; + + for (const order of eligible) { + if (remainingBudget <= 0n) break; + + const available = parseFloat(order.quantity); + if (!Number.isFinite(available) || available <= 0) continue; + + const availableMicro = BigInt(Math.floor(available * 1_000_000)); + if (availableMicro <= 0n) continue; + + const pricePerCreditMicro = BigInt(order.ask_amount); + if (pricePerCreditMicro <= 0n) continue; + + const affordableMicro = + (remainingBudget * MICRO_CREDITS) / pricePerCreditMicro; + if (affordableMicro <= 0n) continue; + + const takeMicro = + availableMicro < affordableMicro ? availableMicro : affordableMicro; + if (takeMicro <= 0n) continue; + + const costMicro = + (pricePerCreditMicro * takeMicro + MICRO_CREDITS - 1n) / MICRO_CREDITS; + if (costMicro <= 0n || costMicro > remainingBudget) continue; + + selected.push({ + sellOrderId: order.id, + batchDenom: order.batch_denom, + quantity: toMicroQuantityString(takeMicro), + askAmount: order.ask_amount, + askDenom: order.ask_denom, + costMicro, + }); + + totalQuantityMicro += takeMicro; + totalCostMicro += costMicro; + remainingBudget -= costMicro; + } + + return { + orders: selected, + totalQuantity: toMicroQuantityString(totalQuantityMicro), + totalCostMicro, + remainingBudgetMicro: remainingBudget, + paymentDenom: denomInfo.bankDenom, + displayDenom: denomInfo.displayDenom, + exponent: denomInfo.exponent, + exhaustedBudget: remainingBudget === 0n, + }; +} diff --git a/src/services/batch-retirement/store.ts b/src/services/batch-retirement/store.ts new file mode 100644 index 0000000..a08759f --- /dev/null +++ b/src/services/batch-retirement/store.ts @@ -0,0 +1,57 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { + BatchExecutionState, + BatchExecutionStore, +} from "./types.js"; + +const DEFAULT_RELATIVE_EXECUTIONS_PATH = "data/monthly-batch-executions.json"; + +function getDefaultState(): BatchExecutionState { + return { version: 1, executions: [] }; +} + +function isValidState(value: unknown): value is BatchExecutionState { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return candidate.version === 1 && Array.isArray(candidate.executions); +} + +export function getDefaultBatchExecutionsPath(): string { + const configured = process.env.REGEN_BATCH_EXECUTIONS_PATH?.trim(); + if (configured) { + return path.resolve(configured); + } + return path.resolve(process.cwd(), DEFAULT_RELATIVE_EXECUTIONS_PATH); +} + +export class JsonFileBatchExecutionStore implements BatchExecutionStore { + constructor( + private readonly filePath: string = getDefaultBatchExecutionsPath() + ) {} + + async readState(): Promise { + try { + const raw = await readFile(this.filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isValidState(parsed)) { + throw new Error("Invalid monthly batch executions file format"); + } + return parsed; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return getDefaultState(); + } + throw err; + } + } + + async writeState(state: BatchExecutionState): Promise { + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true }); + + const tempPath = `${this.filePath}.tmp`; + await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8"); + await rename(tempPath, this.filePath); + } +} diff --git a/src/services/batch-retirement/types.ts b/src/services/batch-retirement/types.ts new file mode 100644 index 0000000..0a2803a --- /dev/null +++ b/src/services/batch-retirement/types.ts @@ -0,0 +1,99 @@ +export interface BudgetSelectedOrder { + sellOrderId: string; + batchDenom: string; + quantity: string; + askAmount: string; + askDenom: string; + costMicro: bigint; +} + +export interface BudgetOrderSelection { + orders: BudgetSelectedOrder[]; + totalQuantity: string; + totalCostMicro: bigint; + remainingBudgetMicro: bigint; + paymentDenom: string; + displayDenom: string; + exponent: number; + exhaustedBudget: boolean; +} + +export interface ContributorAttribution { + userId: string; + email?: string; + customerId?: string; + sharePpm: number; + contributionUsdCents: number; + attributedBudgetUsdCents: number; + attributedCostMicro: string; + attributedQuantity: string; + paymentDenom: string; +} + +export type BatchExecutionStatus = + | "success" + | "failed" + | "dry_run"; + +export interface BatchExecutionRecord { + id: string; + month: string; + creditType?: "carbon" | "biodiversity"; + dryRun: boolean; + status: BatchExecutionStatus; + reason: string; + budgetUsdCents: number; + spentMicro: string; + spentDenom: string; + retiredQuantity: string; + attributions?: ContributorAttribution[]; + txHash?: string; + blockHeight?: number; + retirementId?: string; + error?: string; + executedAt: string; +} + +export interface BatchExecutionState { + version: 1; + executions: BatchExecutionRecord[]; +} + +export interface BatchExecutionStore { + readState(): Promise; + writeState(state: BatchExecutionState): Promise; +} + +export interface RunMonthlyBatchInput { + month: string; + creditType?: "carbon" | "biodiversity"; + paymentDenom?: "USDC" | "uusdc"; + maxBudgetUsd?: number; + jurisdiction?: string; + reason?: string; + dryRun?: boolean; + force?: boolean; +} + +export interface RunMonthlyBatchResult { + status: + | "success" + | "dry_run" + | "no_contributions" + | "no_orders" + | "wallet_not_configured" + | "already_executed" + | "failed"; + month: string; + creditType?: "carbon" | "biodiversity"; + budgetUsdCents: number; + plannedQuantity: string; + plannedCostMicro: bigint; + plannedCostDenom: string; + attributions?: ContributorAttribution[]; + txHash?: string; + blockHeight?: number; + retirementId?: string; + message: string; + executionRecord?: BatchExecutionRecord; +} diff --git a/src/services/identity.ts b/src/services/identity.ts new file mode 100644 index 0000000..9c1ac08 --- /dev/null +++ b/src/services/identity.ts @@ -0,0 +1,162 @@ +/** + * Identity attribution helpers for retirement certificates. + * + * We append a compact metadata tag to the on-chain retirement reason so + * identity details can be recovered later from certificate queries. + */ + +const IDENTITY_TAG_PATTERN = /\s*\[identity:([A-Za-z0-9\-_]+)\]\s*$/; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export type AuthMethod = "none" | "manual" | "email" | "oauth"; + +export interface IdentityInput { + beneficiaryName?: string; + beneficiaryEmail?: string; + authProvider?: string; + authSubject?: string; +} + +export interface IdentityAttribution { + authMethod: AuthMethod; + beneficiaryName?: string; + beneficiaryEmail?: string; + authProvider?: string; + authSubject?: string; +} + +interface EncodedIdentityV1 { + v: 1; + method: Exclude; + name?: string; + email?: string; + provider?: string; + subject?: string; +} + +export interface ParsedAttributedReason { + reasonText: string; + identity?: EncodedIdentityV1; +} + +function normalize(value?: string | null): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeEmail(value?: string | null): string | undefined { + const email = normalize(value); + if (!email) return undefined; + return email.toLowerCase(); +} + +export function captureIdentity(input: IdentityInput): IdentityAttribution { + const beneficiaryName = normalize(input.beneficiaryName); + const beneficiaryEmail = normalizeEmail(input.beneficiaryEmail); + const authProvider = normalize(input.authProvider); + const authSubject = normalize(input.authSubject); + + if (beneficiaryEmail && !EMAIL_PATTERN.test(beneficiaryEmail)) { + throw new Error("Invalid beneficiary_email format"); + } + + if ((authProvider && !authSubject) || (!authProvider && authSubject)) { + throw new Error("auth_provider and auth_subject must be provided together"); + } + + if (authProvider && authSubject) { + return { + authMethod: "oauth", + beneficiaryName, + beneficiaryEmail, + authProvider, + authSubject, + }; + } + + if (beneficiaryEmail) { + return { + authMethod: "email", + beneficiaryName, + beneficiaryEmail, + }; + } + + if (beneficiaryName) { + return { + authMethod: "manual", + beneficiaryName, + }; + } + + return { authMethod: "none" }; +} + +export function appendIdentityToReason( + baseReason: string, + identity: IdentityAttribution +): string { + if (identity.authMethod === "none") { + return baseReason; + } + + const payload: EncodedIdentityV1 = { + v: 1, + method: identity.authMethod, + }; + + if (identity.beneficiaryName) payload.name = identity.beneficiaryName; + if (identity.beneficiaryEmail) payload.email = identity.beneficiaryEmail; + if (identity.authProvider) payload.provider = identity.authProvider; + if (identity.authSubject) payload.subject = identity.authSubject; + + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString( + "base64url" + ); + + return `${baseReason} [identity:${encoded}]`; +} + +export function parseAttributedReason( + reason?: string | null +): ParsedAttributedReason { + const rawReason = normalize(reason) || "Ecological regeneration"; + const match = rawReason.match(IDENTITY_TAG_PATTERN); + if (!match) { + return { reasonText: rawReason }; + } + + const encoded = match[1]; + if (!encoded) { + return { reasonText: rawReason }; + } + + try { + const decoded = Buffer.from(encoded, "base64url").toString("utf8"); + const parsed = JSON.parse(decoded) as Partial; + + if ( + parsed.v !== 1 || + !parsed.method || + !["manual", "email", "oauth"].includes(parsed.method) + ) { + return { reasonText: rawReason }; + } + + const reasonText = rawReason.replace(IDENTITY_TAG_PATTERN, ""); + return { + reasonText: reasonText || "Ecological regeneration", + identity: { + v: 1, + method: parsed.method, + name: normalize(parsed.name), + email: normalizeEmail(parsed.email), + provider: normalize(parsed.provider), + subject: normalize(parsed.subject), + }, + }; + } catch { + return { reasonText: rawReason }; + } +} diff --git a/src/services/payment/stripe-stub.ts b/src/services/payment/stripe-stub.ts index 0fc1968..d90b976 100644 --- a/src/services/payment/stripe-stub.ts +++ b/src/services/payment/stripe-stub.ts @@ -1,9 +1,10 @@ /** - * Stripe payment provider stub. + * Stripe payment provider implementation. * - * Placeholder for the Regen team to implement. Returns a clear - * "not implemented" error so users know the capability exists - * but isn't wired up yet. + * Uses PaymentIntents with manual capture: + * - authorizePayment: create + confirm an intent with capture_method=manual + * - capturePayment: capture the previously authorized intent + * - refundPayment: cancel the intent to release the authorization hold */ import type { @@ -12,33 +13,274 @@ import type { PaymentReceipt, } from "./types.js"; +const STRIPE_API_BASE = "https://api.stripe.com/v1"; + +interface StripeIntent { + id: string; + status: string; + amount?: number; + currency?: string; + metadata?: Record; + last_payment_error?: { message?: string }; +} + +interface StripeErrorResponse { + error?: { + message?: string; + type?: string; + code?: string; + }; +} + +interface StripeChargeSpec { + amountMinor: number; + currency: string; +} + +function ceilDiv(dividend: bigint, divisor: bigint): bigint { + return (dividend + divisor - 1n) / divisor; +} + +function assertSafeNumber(value: bigint, label: string): number { + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${label} is too large for Stripe API numeric limits`); + } + return Number(value); +} + +function toStripeCharge(amountMicro: bigint, denom: string): StripeChargeSpec { + if (amountMicro <= 0n) { + throw new Error("Payment amount must be greater than zero"); + } + + const normalized = denom.toLowerCase(); + + // 1 USDC = 1 USD; on-chain uses micro-units (1e-6). + // Stripe USD amounts are in cents (1e-2). + if (normalized === "uusdc") { + const amountCents = ceilDiv(amountMicro, 10_000n); + return { + amountMinor: assertSafeNumber(amountCents, "Stripe amount"), + currency: "usd", + }; + } + + throw new Error( + `Stripe supports only uusdc-denominated retirements; received ${denom}` + ); +} + +function fromStripeCharge(amountMinor: number, currency: string): { + amountMicro: bigint; + denom: string; +} { + if (currency.toLowerCase() === "usd") { + // cents to micro-USDC + return { + amountMicro: BigInt(amountMinor) * 10_000n, + denom: "uusdc", + }; + } + + return { amountMicro: 0n, denom: "" }; +} + +function encodeForm( + data: Record +): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(data)) { + if (value === undefined) continue; + params.append(key, String(value)); + } + return params.toString(); +} + export class StripePaymentProvider implements PaymentProvider { name = "stripe"; + private getSecretKey(): string | undefined { + const key = process.env.STRIPE_SECRET_KEY?.trim(); + return key && key.length > 0 ? key : undefined; + } + + private getPaymentMethodId(metadata?: Record): string | undefined { + const fromMetadata = metadata?.stripe_payment_method_id?.trim(); + if (fromMetadata) return fromMetadata; + const fromEnv = process.env.STRIPE_PAYMENT_METHOD_ID?.trim(); + return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; + } + + private getCustomerId(metadata?: Record): string | undefined { + const fromMetadata = metadata?.stripe_customer_id?.trim(); + if (fromMetadata) return fromMetadata; + const fromEnv = process.env.STRIPE_CUSTOMER_ID?.trim(); + return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; + } + + private async stripePost( + path: string, + fields: Record + ): Promise { + const secretKey = this.getSecretKey(); + if (!secretKey) { + throw new Error("Stripe is not configured: missing STRIPE_SECRET_KEY"); + } + + const response = await fetch(`${STRIPE_API_BASE}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${secretKey}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: encodeForm(fields), + }); + + const payload = (await response.json()) as StripeIntent | StripeErrorResponse; + + if (!response.ok) { + const err = (payload as StripeErrorResponse).error; + const code = err?.code ? ` (${err.code})` : ""; + throw new Error(err?.message ? `${err.message}${code}` : "Stripe API error"); + } + + return payload as StripeIntent; + } + async authorizePayment( - _amountMicro: bigint, - _denom: string, - _metadata?: Record + amountMicro: bigint, + denom: string, + metadata?: Record ): Promise { + const secretKey = this.getSecretKey(); + if (!secretKey) { + return { + id: `stripe-misconfigured-${Date.now()}`, + provider: this.name, + amountMicro, + denom, + status: "failed", + message: "Stripe is not configured: missing STRIPE_SECRET_KEY", + }; + } + + let charge: StripeChargeSpec; + try { + charge = toStripeCharge(amountMicro, denom); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return { + id: `stripe-unsupported-${Date.now()}`, + provider: this.name, + amountMicro, + denom, + status: "failed", + message: errMsg, + }; + } + + const paymentMethodId = this.getPaymentMethodId(metadata); + if (!paymentMethodId) { + return { + id: `stripe-missing-payment-method-${Date.now()}`, + provider: this.name, + amountMicro, + denom, + status: "failed", + message: + "Stripe payment method is not configured. Set STRIPE_PAYMENT_METHOD_ID " + + "or provide metadata.stripe_payment_method_id.", + }; + } + + try { + const intent = await this.stripePost("/payment_intents", { + amount: charge.amountMinor, + currency: charge.currency, + capture_method: "manual", + confirm: true, + payment_method: paymentMethodId, + customer: this.getCustomerId(metadata), + off_session: true, + description: "Regen Compute Credits retirement authorization", + "metadata[integration]": "regen-compute-credits", + "metadata[regen_amount_micro]": amountMicro.toString(), + "metadata[regen_denom]": denom, + "metadata[buyer]": metadata?.buyer, + "metadata[credit_class]": metadata?.creditClass, + }); + + if (intent.status !== "requires_capture" && intent.status !== "succeeded") { + return { + id: intent.id, + provider: this.name, + amountMicro, + denom, + status: "failed", + message: + intent.last_payment_error?.message || + `Stripe authorization failed with status ${intent.status}`, + }; + } + + return { + id: intent.id, + provider: this.name, + amountMicro, + denom, + status: "authorized", + }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return { + id: `stripe-error-${Date.now()}`, + provider: this.name, + amountMicro, + denom, + status: "failed", + message: `Stripe authorization failed: ${errMsg}`, + }; + } + } + + async capturePayment(authorizationId: string): Promise { + const intent = await this.stripePost( + `/payment_intents/${authorizationId}/capture`, + {} + ); + + const amountFromMetadata = intent.metadata?.regen_amount_micro; + const denomFromMetadata = intent.metadata?.regen_denom; + + if (amountFromMetadata && denomFromMetadata) { + return { + id: intent.id, + provider: this.name, + amountMicro: BigInt(amountFromMetadata), + denom: denomFromMetadata, + status: "captured", + }; + } + + const fallback = fromStripeCharge(intent.amount || 0, intent.currency || ""); return { - id: "stripe-not-implemented", + id: intent.id, provider: this.name, - amountMicro: _amountMicro, - denom: _denom, - status: "failed", - message: - "Stripe payment is not yet implemented. " + - "The Regen team will wire up Stripe PaymentIntents here. " + - "For now, use a funded REGEN/USDC wallet (crypto provider) " + - "or purchase via the marketplace link.", + amountMicro: fallback.amountMicro, + denom: fallback.denom, + status: "captured", }; } - async capturePayment(_authorizationId: string): Promise { - throw new Error("Stripe capturePayment not implemented"); - } - - async refundPayment(_authorizationId: string): Promise { - throw new Error("Stripe refundPayment not implemented"); + async refundPayment(authorizationId: string): Promise { + try { + await this.stripePost(`/payment_intents/${authorizationId}/cancel`, {}); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.toLowerCase().includes("already canceled")) { + return; + } + throw err; + } } } diff --git a/src/services/pool-accounting/service.ts b/src/services/pool-accounting/service.ts new file mode 100644 index 0000000..d498524 --- /dev/null +++ b/src/services/pool-accounting/service.ts @@ -0,0 +1,275 @@ +import { randomUUID } from "node:crypto"; +import { getSubscriptionTier } from "../subscription/tiers.js"; +import { + JsonFilePoolAccountingStore, +} from "./store.js"; +import type { + ContributionInput, + ContributionReceipt, + ContributionRecord, + MonthlyPoolSummary, + PoolAccountingStore, + UserContributionSummary, +} from "./types.js"; + +const MONTH_REGEX = /^\d{4}-\d{2}$/; + +function normalize(value?: string): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeEmail(email?: string): string | undefined { + const value = normalize(email); + return value ? value.toLowerCase() : undefined; +} + +function toIsoTimestamp(value?: string): string { + const input = normalize(value); + if (!input) return new Date().toISOString(); + + const parsed = new Date(input); + if (Number.isNaN(parsed.getTime())) { + throw new Error("Invalid contributedAt timestamp"); + } + return parsed.toISOString(); +} + +function toMonth(isoTimestamp: string): string { + return isoTimestamp.slice(0, 7); +} + +function resolveAmountUsdCents(input: ContributionInput): number { + if (typeof input.amountUsdCents === "number") { + if (!Number.isInteger(input.amountUsdCents) || input.amountUsdCents <= 0) { + throw new Error("amountUsdCents must be a positive integer"); + } + return input.amountUsdCents; + } + + if (typeof input.amountUsd === "number") { + if (!Number.isFinite(input.amountUsd) || input.amountUsd <= 0) { + throw new Error("amountUsd must be a positive number"); + } + return Math.round(input.amountUsd * 100); + } + + if (input.tierId) { + const tier = getSubscriptionTier(input.tierId); + if (!tier) { + throw new Error(`Unknown tier '${input.tierId}'`); + } + return tier.monthlyUsd * 100; + } + + throw new Error( + "Provide one of amountUsdCents, amountUsd, or tierId to determine contribution amount" + ); +} + +function resolveUserId(input: ContributionInput): string { + const userId = normalize(input.userId); + if (userId) return userId; + + const customerId = normalize(input.customerId); + if (customerId) return `customer:${customerId}`; + + const email = normalizeEmail(input.email); + if (email) return `email:${email}`; + + throw new Error("Contribution requires at least one identifier: userId, customerId, or email"); +} + +function toUsd(cents: number): number { + return cents / 100; +} + +function summarizeUserRecords( + userId: string, + records: ContributionRecord[] +): UserContributionSummary { + const sorted = [...records].sort((a, b) => + a.contributedAt.localeCompare(b.contributedAt) + ); + const totalUsdCents = sorted.reduce((sum, record) => sum + record.amountUsdCents, 0); + + const byMonthMap = new Map< + string, + { month: string; contributionCount: number; totalUsdCents: number } + >(); + for (const record of sorted) { + const existing = byMonthMap.get(record.month) || { + month: record.month, + contributionCount: 0, + totalUsdCents: 0, + }; + existing.contributionCount += 1; + existing.totalUsdCents += record.amountUsdCents; + byMonthMap.set(record.month, existing); + } + + const byMonth = [...byMonthMap.values()] + .sort((a, b) => a.month.localeCompare(b.month)) + .map((entry) => ({ + month: entry.month, + contributionCount: entry.contributionCount, + totalUsdCents: entry.totalUsdCents, + totalUsd: toUsd(entry.totalUsdCents), + })); + + const mostRecent = sorted[sorted.length - 1]; + + return { + userId, + email: mostRecent?.email, + customerId: mostRecent?.customerId, + contributionCount: sorted.length, + totalUsdCents, + totalUsd: toUsd(totalUsdCents), + lastContributionAt: mostRecent?.contributedAt, + byMonth, + }; +} + +function summarizeMonth( + month: string, + records: ContributionRecord[] +): MonthlyPoolSummary { + const filtered = records.filter((record) => record.month === month); + const contributorMap = new Map< + string, + { + userId: string; + email?: string; + customerId?: string; + contributionCount: number; + totalUsdCents: number; + } + >(); + + let totalUsdCents = 0; + for (const record of filtered) { + totalUsdCents += record.amountUsdCents; + const existing = contributorMap.get(record.userId) || { + userId: record.userId, + email: record.email, + customerId: record.customerId, + contributionCount: 0, + totalUsdCents: 0, + }; + existing.contributionCount += 1; + existing.totalUsdCents += record.amountUsdCents; + contributorMap.set(record.userId, existing); + } + + const contributors = [...contributorMap.values()] + .sort((a, b) => b.totalUsdCents - a.totalUsdCents) + .map((entry) => ({ + userId: entry.userId, + email: entry.email, + customerId: entry.customerId, + contributionCount: entry.contributionCount, + totalUsdCents: entry.totalUsdCents, + totalUsd: toUsd(entry.totalUsdCents), + })); + + return { + month, + contributionCount: filtered.length, + uniqueContributors: contributors.length, + totalUsdCents, + totalUsd: toUsd(totalUsdCents), + contributors, + }; +} + +export class PoolAccountingService { + constructor( + private readonly store: PoolAccountingStore = new JsonFilePoolAccountingStore() + ) {} + + async recordContribution(input: ContributionInput): Promise { + const state = await this.store.readState(); + const userId = resolveUserId(input); + const contributedAt = toIsoTimestamp(input.contributedAt); + const month = toMonth(contributedAt); + const amountUsdCents = resolveAmountUsdCents(input); + + const record: ContributionRecord = { + id: `contrib_${randomUUID()}`, + userId, + email: normalizeEmail(input.email), + customerId: normalize(input.customerId), + subscriptionId: normalize(input.subscriptionId), + tierId: input.tierId, + amountUsdCents, + contributedAt, + month, + source: input.source || "subscription", + metadata: input.metadata && Object.keys(input.metadata).length > 0 + ? input.metadata + : undefined, + }; + + state.contributions.push(record); + state.contributions.sort((a, b) => + a.contributedAt.localeCompare(b.contributedAt) + ); + await this.store.writeState(state); + + const userRecords = state.contributions.filter((item) => item.userId === userId); + return { + record, + userSummary: summarizeUserRecords(userId, userRecords), + monthSummary: summarizeMonth(month, state.contributions), + }; + } + + async getUserSummary( + identifier: { userId?: string; email?: string; customerId?: string } + ): Promise { + const state = await this.store.readState(); + + let records: ContributionRecord[] = []; + let resolvedUserId: string | undefined; + + const explicitUserId = normalize(identifier.userId); + const customerId = normalize(identifier.customerId); + const email = normalizeEmail(identifier.email); + + if (explicitUserId) { + resolvedUserId = explicitUserId; + records = state.contributions.filter((item) => item.userId === explicitUserId); + } else if (customerId) { + records = state.contributions.filter((item) => item.customerId === customerId); + resolvedUserId = records[0]?.userId; + } else if (email) { + records = state.contributions.filter((item) => item.email === email); + resolvedUserId = records[0]?.userId; + } else { + throw new Error("Provide one identifier: userId, customerId, or email"); + } + + if (!records.length || !resolvedUserId) { + return null; + } + + return summarizeUserRecords(resolvedUserId, records); + } + + async getMonthlySummary(month: string): Promise { + if (!MONTH_REGEX.test(month)) { + throw new Error("month must be in YYYY-MM format"); + } + + const state = await this.store.readState(); + return summarizeMonth(month, state.contributions); + } + + async listAvailableMonths(): Promise { + const state = await this.store.readState(); + return [...new Set(state.contributions.map((item) => item.month))] + .sort((a, b) => b.localeCompare(a)); + } +} diff --git a/src/services/pool-accounting/store.ts b/src/services/pool-accounting/store.ts new file mode 100644 index 0000000..79a0553 --- /dev/null +++ b/src/services/pool-accounting/store.ts @@ -0,0 +1,52 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { PoolAccountingState, PoolAccountingStore } from "./types.js"; + +const DEFAULT_RELATIVE_LEDGER_PATH = "data/pool-accounting-ledger.json"; + +function getDefaultState(): PoolAccountingState { + return { version: 1, contributions: [] }; +} + +function isValidState(value: unknown): value is PoolAccountingState { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return candidate.version === 1 && Array.isArray(candidate.contributions); +} + +export function getDefaultPoolAccountingPath(): string { + const configured = process.env.REGEN_POOL_ACCOUNTING_PATH?.trim(); + if (configured) { + return path.resolve(configured); + } + return path.resolve(process.cwd(), DEFAULT_RELATIVE_LEDGER_PATH); +} + +export class JsonFilePoolAccountingStore implements PoolAccountingStore { + constructor(private readonly filePath: string = getDefaultPoolAccountingPath()) {} + + async readState(): Promise { + try { + const raw = await readFile(this.filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isValidState(parsed)) { + throw new Error("Invalid pool accounting ledger format"); + } + return parsed; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return getDefaultState(); + } + throw err; + } + } + + async writeState(state: PoolAccountingState): Promise { + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true }); + + const tempPath = `${this.filePath}.tmp`; + await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8"); + await rename(tempPath, this.filePath); + } +} diff --git a/src/services/pool-accounting/types.ts b/src/services/pool-accounting/types.ts new file mode 100644 index 0000000..06b19aa --- /dev/null +++ b/src/services/pool-accounting/types.ts @@ -0,0 +1,85 @@ +import type { SubscriptionTierId } from "../subscription/types.js"; + +export type ContributionSource = + | "subscription" + | "manual" + | "adjustment"; + +export interface ContributionInput { + userId?: string; + email?: string; + customerId?: string; + subscriptionId?: string; + tierId?: SubscriptionTierId; + amountUsd?: number; + amountUsdCents?: number; + contributedAt?: string; + source?: ContributionSource; + metadata?: Record; +} + +export interface ContributionRecord { + id: string; + userId: string; + email?: string; + customerId?: string; + subscriptionId?: string; + tierId?: SubscriptionTierId; + amountUsdCents: number; + contributedAt: string; + month: string; + source: ContributionSource; + metadata?: Record; +} + +export interface UserMonthlyContribution { + month: string; + contributionCount: number; + totalUsdCents: number; + totalUsd: number; +} + +export interface UserContributionSummary { + userId: string; + email?: string; + customerId?: string; + contributionCount: number; + totalUsdCents: number; + totalUsd: number; + lastContributionAt?: string; + byMonth: UserMonthlyContribution[]; +} + +export interface MonthlyContributorAggregate { + userId: string; + email?: string; + customerId?: string; + contributionCount: number; + totalUsdCents: number; + totalUsd: number; +} + +export interface MonthlyPoolSummary { + month: string; + contributionCount: number; + uniqueContributors: number; + totalUsdCents: number; + totalUsd: number; + contributors: MonthlyContributorAggregate[]; +} + +export interface PoolAccountingState { + version: 1; + contributions: ContributionRecord[]; +} + +export interface PoolAccountingStore { + readState(): Promise; + writeState(state: PoolAccountingState): Promise; +} + +export interface ContributionReceipt { + record: ContributionRecord; + userSummary: UserContributionSummary; + monthSummary: MonthlyPoolSummary; +} diff --git a/src/services/subscription/stripe.ts b/src/services/subscription/stripe.ts new file mode 100644 index 0000000..fc36247 --- /dev/null +++ b/src/services/subscription/stripe.ts @@ -0,0 +1,411 @@ +import { + getStripePriceIdForTier, + getTierIdForStripePrice, + listSubscriptionTiers, +} from "./tiers.js"; +import type { + SubscriptionIdentityInput, + SubscriptionState, + SubscriptionStatus, + SubscriptionTier, + SubscriptionTierId, +} from "./types.js"; + +const STRIPE_API_BASE = "https://api.stripe.com/v1"; + +interface StripeErrorPayload { + error?: { + message?: string; + code?: string; + type?: string; + }; +} + +interface StripeCustomer { + id: string; + email?: string; + name?: string; + deleted?: boolean; +} + +interface StripePrice { + id: string; +} + +interface StripeSubscriptionItem { + id: string; + price?: StripePrice; +} + +interface StripeSubscription { + id: string; + status: string; + customer: string; + cancel_at_period_end: boolean; + current_period_end?: number; + items?: { + data: StripeSubscriptionItem[]; + }; +} + +interface StripeListResponse { + data: T[]; +} + +function trimOrUndefined(value?: string): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function encodeForm( + data: Record +): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(data)) { + if (value === undefined) continue; + params.append(key, String(value)); + } + return params.toString(); +} + +function mapStripeStatus(status: string | undefined): SubscriptionStatus { + if (!status) return "none"; + switch (status) { + case "trialing": + case "active": + case "past_due": + case "canceled": + case "incomplete": + case "incomplete_expired": + case "unpaid": + case "paused": + return status; + default: + return "none"; + } +} + +function pickSubscription( + subscriptions: StripeSubscription[] +): StripeSubscription | undefined { + const priority: SubscriptionStatus[] = [ + "active", + "trialing", + "past_due", + "incomplete", + "unpaid", + "paused", + "canceled", + "incomplete_expired", + ]; + + const normalized = subscriptions + .map((sub) => ({ + sub, + status: mapStripeStatus(sub.status), + idx: priority.indexOf(mapStripeStatus(sub.status)), + })) + .sort((a, b) => { + const aIdx = a.idx === -1 ? Number.MAX_SAFE_INTEGER : a.idx; + const bIdx = b.idx === -1 ? Number.MAX_SAFE_INTEGER : b.idx; + return aIdx - bIdx; + }); + + return normalized[0]?.sub; +} + +function toState( + customer: StripeCustomer | undefined, + subscription: StripeSubscription | undefined +): SubscriptionState { + if (!subscription) { + return { + customerId: customer?.id, + email: customer?.email, + status: "none", + cancelAtPeriodEnd: false, + }; + } + + const item = subscription.items?.data[0]; + const priceId = item?.price?.id; + const tierId = getTierIdForStripePrice(priceId); + const currentPeriodEnd = + subscription.current_period_end && subscription.current_period_end > 0 + ? new Date(subscription.current_period_end * 1000).toISOString() + : undefined; + + return { + customerId: customer?.id || subscription.customer, + email: customer?.email, + subscriptionId: subscription.id, + status: mapStripeStatus(subscription.status), + tierId, + priceId, + cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end), + currentPeriodEnd, + }; +} + +export class StripeSubscriptionService { + listTiers(): SubscriptionTier[] { + return listSubscriptionTiers(); + } + + private getSecretKey(): string { + const key = trimOrUndefined(process.env.STRIPE_SECRET_KEY); + if (!key) { + throw new Error("Missing STRIPE_SECRET_KEY for Stripe subscriptions"); + } + return key; + } + + private getDefaultPaymentMethodId( + input?: SubscriptionIdentityInput + ): string | undefined { + return ( + trimOrUndefined(input?.paymentMethodId) || + trimOrUndefined(process.env.STRIPE_PAYMENT_METHOD_ID) + ); + } + + private async stripeRequest( + method: "GET" | "POST", + path: string, + fields?: Record + ): Promise { + const secretKey = this.getSecretKey(); + const url = + method === "GET" && fields + ? `${STRIPE_API_BASE}${path}?${encodeForm(fields)}` + : `${STRIPE_API_BASE}${path}`; + + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${secretKey}`, + ...(method === "POST" + ? { "Content-Type": "application/x-www-form-urlencoded" } + : {}), + }, + body: method === "POST" ? encodeForm(fields || {}) : undefined, + }); + + const payload = (await response.json()) as T | StripeErrorPayload; + if (!response.ok) { + const err = (payload as StripeErrorPayload).error; + const code = err?.code ? ` (${err.code})` : ""; + throw new Error(err?.message ? `${err.message}${code}` : "Stripe API error"); + } + + return payload as T; + } + + private async findCustomer( + input: SubscriptionIdentityInput + ): Promise { + const customerId = trimOrUndefined(input.customerId); + if (customerId) { + const customer = await this.stripeRequest( + "GET", + `/customers/${customerId}` + ); + return customer.deleted ? undefined : customer; + } + + const email = trimOrUndefined(input.email); + if (!email) return undefined; + + const list = await this.stripeRequest>( + "GET", + "/customers", + { + email, + limit: 1, + } + ); + const customer = list.data[0]; + if (!customer || customer.deleted) return undefined; + return customer; + } + + private async createCustomer( + input: SubscriptionIdentityInput + ): Promise { + const email = trimOrUndefined(input.email); + if (!email) { + throw new Error("email is required to create a Stripe customer"); + } + + return this.stripeRequest("POST", "/customers", { + email, + name: trimOrUndefined(input.fullName), + "metadata[integration]": "regen-compute-credits", + }); + } + + private async resolveCustomer( + input: SubscriptionIdentityInput, + createIfMissing: boolean + ): Promise { + const existing = await this.findCustomer(input); + if (existing) return existing; + if (!createIfMissing) return undefined; + return this.createCustomer(input); + } + + private async setDefaultPaymentMethod( + customerId: string, + paymentMethodId: string + ): Promise { + try { + await this.stripeRequest( + "POST", + `/payment_methods/${paymentMethodId}/attach`, + { customer: customerId } + ); + } catch (err) { + const message = err instanceof Error ? err.message.toLowerCase() : ""; + if (!message.includes("already been attached")) { + throw err; + } + } + + await this.stripeRequest("POST", `/customers/${customerId}`, { + "invoice_settings[default_payment_method]": paymentMethodId, + }); + } + + private async listCustomerSubscriptions( + customerId: string + ): Promise { + const list = await this.stripeRequest>( + "GET", + "/subscriptions", + { + customer: customerId, + status: "all", + limit: 100, + } + ); + return list.data; + } + + async ensureSubscription( + tierId: SubscriptionTierId, + input: SubscriptionIdentityInput + ): Promise { + const priceId = getStripePriceIdForTier(tierId); + if (!priceId) { + throw new Error(`Missing Stripe price config for tier '${tierId}'`); + } + + const customer = await this.resolveCustomer(input, true); + if (!customer) { + throw new Error("Unable to resolve Stripe customer"); + } + + const paymentMethodId = this.getDefaultPaymentMethodId(input); + if (!paymentMethodId) { + throw new Error( + "Missing Stripe payment method. Set STRIPE_PAYMENT_METHOD_ID or provide payment_method_id." + ); + } + await this.setDefaultPaymentMethod(customer.id, paymentMethodId); + + const subscriptions = await this.listCustomerSubscriptions(customer.id); + const current = pickSubscription(subscriptions); + const updatable = + current && + current.status !== "canceled" && + current.status !== "incomplete_expired"; + + let finalSubscription: StripeSubscription; + + if (updatable) { + const itemId = current.items?.data[0]?.id; + if (itemId) { + finalSubscription = await this.stripeRequest( + "POST", + `/subscriptions/${current.id}`, + { + "items[0][id]": itemId, + "items[0][price]": priceId, + cancel_at_period_end: false, + proration_behavior: "create_prorations", + } + ); + } else { + finalSubscription = await this.stripeRequest( + "POST", + "/subscriptions", + { + customer: customer.id, + "items[0][price]": priceId, + collection_method: "charge_automatically", + } + ); + } + } else { + finalSubscription = await this.stripeRequest( + "POST", + "/subscriptions", + { + customer: customer.id, + "items[0][price]": priceId, + collection_method: "charge_automatically", + } + ); + } + + return toState(customer, finalSubscription); + } + + async getSubscriptionState( + input: SubscriptionIdentityInput + ): Promise { + const customer = await this.resolveCustomer(input, false); + if (!customer) { + return { + email: trimOrUndefined(input.email), + status: "none", + cancelAtPeriodEnd: false, + }; + } + + const subscriptions = await this.listCustomerSubscriptions(customer.id); + const current = pickSubscription(subscriptions); + return toState(customer, current); + } + + async cancelSubscription( + input: SubscriptionIdentityInput + ): Promise { + const customer = await this.resolveCustomer(input, false); + if (!customer) { + return { + email: trimOrUndefined(input.email), + status: "none", + cancelAtPeriodEnd: false, + }; + } + + const subscriptions = await this.listCustomerSubscriptions(customer.id); + const current = pickSubscription(subscriptions); + if (!current) { + return toState(customer, undefined); + } + + if (current.status === "canceled") { + return toState(customer, current); + } + + const canceled = await this.stripeRequest( + "POST", + `/subscriptions/${current.id}`, + { cancel_at_period_end: true } + ); + return toState(customer, canceled); + } +} diff --git a/src/services/subscription/tiers.ts b/src/services/subscription/tiers.ts new file mode 100644 index 0000000..09659ca --- /dev/null +++ b/src/services/subscription/tiers.ts @@ -0,0 +1,57 @@ +import type { SubscriptionTier, SubscriptionTierId } from "./types.js"; + +const SUBSCRIPTION_TIERS: SubscriptionTier[] = [ + { + id: "starter", + name: "Starter", + monthlyUsd: 1, + description: "Entry tier for monthly ecological contributions.", + stripePriceIdEnv: "STRIPE_PRICE_ID_STARTER", + }, + { + id: "growth", + name: "Growth", + monthlyUsd: 3, + description: "Balanced recurring contribution tier.", + stripePriceIdEnv: "STRIPE_PRICE_ID_GROWTH", + }, + { + id: "impact", + name: "Impact", + monthlyUsd: 5, + description: "Highest monthly contribution tier.", + stripePriceIdEnv: "STRIPE_PRICE_ID_IMPACT", + }, +]; + +export function listSubscriptionTiers(): SubscriptionTier[] { + return SUBSCRIPTION_TIERS; +} + +export function getSubscriptionTier( + tierId: SubscriptionTierId +): SubscriptionTier | undefined { + return SUBSCRIPTION_TIERS.find((tier) => tier.id === tierId); +} + +export function getStripePriceIdForTier( + tierId: SubscriptionTierId +): string | undefined { + const tier = getSubscriptionTier(tierId); + if (!tier) return undefined; + const value = process.env[tier.stripePriceIdEnv]?.trim(); + return value && value.length > 0 ? value : undefined; +} + +export function getTierIdForStripePrice( + priceId?: string +): SubscriptionTierId | undefined { + if (!priceId) return undefined; + for (const tier of SUBSCRIPTION_TIERS) { + const configured = process.env[tier.stripePriceIdEnv]?.trim(); + if (configured && configured === priceId) { + return tier.id; + } + } + return undefined; +} diff --git a/src/services/subscription/types.ts b/src/services/subscription/types.ts new file mode 100644 index 0000000..97a7f72 --- /dev/null +++ b/src/services/subscription/types.ts @@ -0,0 +1,38 @@ +export type SubscriptionTierId = "starter" | "growth" | "impact"; + +export type SubscriptionStatus = + | "none" + | "trialing" + | "active" + | "past_due" + | "canceled" + | "incomplete" + | "incomplete_expired" + | "unpaid" + | "paused"; + +export interface SubscriptionTier { + id: SubscriptionTierId; + name: string; + monthlyUsd: number; + description: string; + stripePriceIdEnv: string; +} + +export interface SubscriptionIdentityInput { + email?: string; + customerId?: string; + fullName?: string; + paymentMethodId?: string; +} + +export interface SubscriptionState { + customerId?: string; + email?: string; + subscriptionId?: string; + status: SubscriptionStatus; + tierId?: SubscriptionTierId; + priceId?: string; + cancelAtPeriodEnd: boolean; + currentPeriodEnd?: string; +} diff --git a/src/tools/attribution-dashboard.ts b/src/tools/attribution-dashboard.ts new file mode 100644 index 0000000..18625b1 --- /dev/null +++ b/src/tools/attribution-dashboard.ts @@ -0,0 +1,154 @@ +import { AttributionDashboardService } from "../services/attribution/dashboard.js"; + +const attributionDashboard = new AttributionDashboardService(); + +function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +export async function getSubscriberImpactDashboardTool( + userId?: string, + email?: string, + customerId?: string +) { + try { + const dashboard = await attributionDashboard.getSubscriberDashboard({ + userId, + email, + customerId, + }); + + if (!dashboard) { + return { + content: [ + { + type: "text" as const, + text: "No subscriber contribution history found for the provided identifier.", + }, + ], + }; + } + + const lines = [ + "## Subscriber Impact Dashboard", + "", + "| Field | Value |", + "|-------|-------|", + `| User ID | ${dashboard.userId} |`, + `| Email | ${dashboard.email || "N/A"} |`, + `| Customer ID | ${dashboard.customerId || "N/A"} |`, + `| Contribution Count | ${dashboard.contributionCount} |`, + `| Total Contributed | ${formatUsd(dashboard.totalContributedUsdCents)} |`, + `| Attributed Retirements | ${dashboard.attributionCount} |`, + `| Total Attributed Budget | ${formatUsd(dashboard.totalAttributedBudgetUsdCents)} |`, + `| Total Attributed Quantity | ${dashboard.totalAttributedQuantity} credits |`, + "", + "### Monthly History", + "", + "| Month | Contributed | Attributed Budget | Attributed Quantity |", + "|-------|-------------|-------------------|---------------------|", + ...dashboard.byMonth.map( + (month) => + `| ${month.month} | ${formatUsd(month.contributionUsdCents)} | ${formatUsd(month.attributedBudgetUsdCents)} | ${month.attributedQuantity} |` + ), + ]; + + if (dashboard.attributions.length > 0) { + lines.push( + "", + "### Attribution Executions", + "", + "| Month | Share | Attributed Budget | Retirement ID | Tx Hash |", + "|-------|-------|-------------------|---------------|---------|", + ...dashboard.attributions.slice(0, 25).map((entry) => { + const sharePercent = (entry.sharePpm / 10_000).toFixed(2); + return `| ${entry.month} | ${sharePercent}% | ${formatUsd(entry.attributedBudgetUsdCents)} | ${entry.retirementId || "N/A"} | ${entry.txHash || "N/A"} |`; + }) + ); + if (dashboard.attributions.length > 25) { + lines.push( + "", + `Showing 25 of ${dashboard.attributions.length} attribution execution rows.` + ); + } + } + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown dashboard error"; + return { + content: [ + { + type: "text" as const, + text: `Failed to load subscriber dashboard: ${message}`, + }, + ], + isError: true, + }; + } +} + +export async function getSubscriberAttributionCertificateTool( + month: string, + userId?: string, + email?: string, + customerId?: string +) { + try { + const certificate = + await attributionDashboard.getSubscriberCertificateForMonth({ + month, + userId, + email, + customerId, + }); + + if (!certificate) { + return { + content: [ + { + type: "text" as const, + text: + "No attribution certificate found for the provided user/month combination.", + }, + ], + }; + } + + const sharePercent = (certificate.execution.sharePpm / 10_000).toFixed(2); + const lines = [ + "## Subscriber Attribution Certificate", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${certificate.month} |`, + `| User ID | ${certificate.userId} |`, + `| Email | ${certificate.email || "N/A"} |`, + `| Customer ID | ${certificate.customerId || "N/A"} |`, + `| Contribution | ${formatUsd(certificate.contributionUsdCents)} |`, + `| Attribution Share | ${sharePercent}% |`, + `| Attributed Budget | ${formatUsd(certificate.execution.attributedBudgetUsdCents)} |`, + `| Attributed Quantity | ${certificate.execution.attributedQuantity} credits |`, + `| Retirement ID | ${certificate.execution.retirementId || "N/A"} |`, + `| Transaction Hash | ${certificate.execution.txHash || "N/A"} |`, + `| Retirement Reason | ${certificate.execution.reason} |`, + "", + "This certificate reflects your fractional attribution in the monthly pooled retirement batch.", + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown certificate error"; + return { + content: [ + { + type: "text" as const, + text: `Failed to load attribution certificate: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/certificates.ts b/src/tools/certificates.ts index 4e2c451..54b3a8a 100644 --- a/src/tools/certificates.ts +++ b/src/tools/certificates.ts @@ -1,4 +1,5 @@ import { getRetirementById } from "../services/indexer.js"; +import { parseAttributedReason } from "../services/identity.js"; export async function getRetirementCertificate(retirementId: string) { try { @@ -16,6 +17,8 @@ export async function getRetirementCertificate(retirementId: string) { }; } + const parsedReason = parseAttributedReason(retirement.reason); + const text = [ `## Retirement Certificate`, ``, @@ -26,15 +29,31 @@ export async function getRetirementCertificate(retirementId: string) { `| Credit Batch | ${retirement.batchDenom} |`, `| Beneficiary | ${retirement.owner} |`, `| Jurisdiction | ${retirement.jurisdiction} |`, - `| Reason | ${retirement.reason || "Ecological regeneration"} |`, + `| Reason | ${parsedReason.reasonText} |`, `| Timestamp | ${retirement.timestamp} |`, `| Block Height | ${retirement.blockHeight} |`, `| Transaction Hash | ${retirement.txHash} |`, + ]; + + if (parsedReason.identity?.name) { + text.push(`| Beneficiary Name | ${parsedReason.identity.name} |`); + } + if (parsedReason.identity?.email) { + text.push(`| Beneficiary Email | ${parsedReason.identity.email} |`); + } + if (parsedReason.identity?.provider) { + text.push(`| Auth Provider | ${parsedReason.identity.provider} |`); + } + if (parsedReason.identity?.subject) { + text.push(`| Auth Subject | ${parsedReason.identity.subject} |`); + } + + text.push( ``, - `**On-chain verification**: This retirement is permanently recorded on Regen Ledger and cannot be altered or reversed.`, - ].join("\n"); + `**On-chain verification**: This retirement is permanently recorded on Regen Ledger and cannot be altered or reversed.` + ); - return { content: [{ type: "text" as const, text }] }; + return { content: [{ type: "text" as const, text: text.join("\n") }] }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error occurred"; diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts new file mode 100644 index 0000000..7ecbee0 --- /dev/null +++ b/src/tools/monthly-batch-retirement.ts @@ -0,0 +1,102 @@ +import { MonthlyBatchRetirementExecutor } from "../services/batch-retirement/executor.js"; + +const executor = new MonthlyBatchRetirementExecutor(); + +function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +function formatMicroAmount(amount: bigint, denom: string, exponent: number): string { + const divisor = 10n ** BigInt(exponent); + const whole = amount / divisor; + const frac = amount % divisor; + const fracStr = frac.toString().padStart(exponent, "0").replace(/0+$/, ""); + return fracStr ? `${whole.toString()}.${fracStr} ${denom}` : `${whole.toString()} ${denom}`; +} + +function denomExponent(denom: string): number { + return denom.toLowerCase() === "uusdc" ? 6 : 6; +} + +export async function runMonthlyBatchRetirementTool( + month: string, + creditType?: "carbon" | "biodiversity", + maxBudgetUsd?: number, + dryRun?: boolean, + force?: boolean, + reason?: string, + jurisdiction?: string +) { + try { + const result = await executor.runMonthlyBatch({ + month, + creditType, + maxBudgetUsd, + dryRun, + force, + reason, + jurisdiction, + paymentDenom: "USDC", + }); + + const lines: string[] = [ + `## Monthly Batch Retirement`, + ``, + `| Field | Value |`, + `|-------|-------|`, + `| Status | ${result.status} |`, + `| Month | ${result.month} |`, + `| Credit Type | ${result.creditType || "all"} |`, + `| Budget | ${formatUsd(result.budgetUsdCents)} |`, + `| Planned Quantity | ${result.plannedQuantity} |`, + `| Planned Cost | ${formatMicroAmount(result.plannedCostMicro, result.plannedCostDenom, denomExponent(result.plannedCostDenom))} |`, + ]; + + if (result.txHash) { + lines.push(`| Transaction Hash | \`${result.txHash}\` |`); + } + if (typeof result.blockHeight === "number") { + lines.push(`| Block Height | ${result.blockHeight} |`); + } + if (result.retirementId) { + lines.push(`| Retirement ID | ${result.retirementId} |`); + } + + if (result.attributions && result.attributions.length > 0) { + lines.push( + "", + "### Fractional Attribution", + "", + "| User ID | Share | Attributed Budget | Attributed Quantity |", + "|---------|-------|-------------------|---------------------|", + ...result.attributions.slice(0, 25).map((item) => { + const share = `${(item.sharePpm / 10_000).toFixed(2)}%`; + return `| ${item.userId} | ${share} | ${formatUsd(item.attributedBudgetUsdCents)} | ${item.attributedQuantity} |`; + }) + ); + + if (result.attributions.length > 25) { + lines.push( + "", + `Showing 25 of ${result.attributions.length} attribution rows.` + ); + } + } + + lines.push("", result.message); + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown monthly batch error"; + return { + content: [ + { + type: "text" as const, + text: `Monthly batch retirement failed: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/pool-accounting.ts b/src/tools/pool-accounting.ts new file mode 100644 index 0000000..471db26 --- /dev/null +++ b/src/tools/pool-accounting.ts @@ -0,0 +1,143 @@ +import { PoolAccountingService } from "../services/pool-accounting/service.js"; +import type { ContributionInput } from "../services/pool-accounting/types.js"; + +const poolAccounting = new PoolAccountingService(); + +function renderMoney(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +export async function recordPoolContributionTool(input: ContributionInput) { + try { + const receipt = await poolAccounting.recordContribution(input); + + const lines = [ + "## Pool Contribution Recorded", + "", + `| Field | Value |`, + `|-------|-------|`, + `| Contribution ID | ${receipt.record.id} |`, + `| User ID | ${receipt.record.userId} |`, + `| Amount | ${renderMoney(receipt.record.amountUsdCents)} |`, + `| Month | ${receipt.record.month} |`, + `| Source | ${receipt.record.source} |`, + `| User Lifetime Total | ${renderMoney(receipt.userSummary.totalUsdCents)} |`, + `| Month Pool Total | ${renderMoney(receipt.monthSummary.totalUsdCents)} |`, + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Failed to record contribution: ${message}`, + }, + ], + isError: true, + }; + } +} + +export async function getPoolAccountingSummaryTool( + month?: string, + userId?: string, + email?: string, + customerId?: string +) { + try { + if (userId || email || customerId) { + const summary = await poolAccounting.getUserSummary({ + userId, + email, + customerId, + }); + + if (!summary) { + return { + content: [ + { + type: "text" as const, + text: "No contribution history found for that user identifier.", + }, + ], + }; + } + + const lines = [ + "## User Contribution Summary", + "", + `| Field | Value |`, + `|-------|-------|`, + `| User ID | ${summary.userId} |`, + `| Email | ${summary.email || "N/A"} |`, + `| Customer ID | ${summary.customerId || "N/A"} |`, + `| Contribution Count | ${summary.contributionCount} |`, + `| Lifetime Total | ${renderMoney(summary.totalUsdCents)} |`, + `| Last Contribution | ${summary.lastContributionAt || "N/A"} |`, + "", + "### By Month", + "", + "| Month | Count | Total |", + "|-------|-------|-------|", + ...summary.byMonth.map( + (item) => + `| ${item.month} | ${item.contributionCount} | ${renderMoney(item.totalUsdCents)} |` + ), + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } + + const months = await poolAccounting.listAvailableMonths(); + const targetMonth = month || months[0]; + + if (!targetMonth) { + return { + content: [ + { + type: "text" as const, + text: "No pool accounting records exist yet.", + }, + ], + }; + } + + const summary = await poolAccounting.getMonthlySummary(targetMonth); + const lines = [ + "## Monthly Pool Summary", + "", + `| Field | Value |`, + `|-------|-------|`, + `| Month | ${summary.month} |`, + `| Contribution Count | ${summary.contributionCount} |`, + `| Unique Contributors | ${summary.uniqueContributors} |`, + `| Total Pool Amount | ${renderMoney(summary.totalUsdCents)} |`, + "", + "### Contributors", + "", + "| User ID | Contributions | Total |", + "|---------|---------------|-------|", + ...summary.contributors.map( + (item) => + `| ${item.userId} | ${item.contributionCount} | ${renderMoney(item.totalUsdCents)} |` + ), + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Failed to query pool accounting: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/retire.ts b/src/tools/retire.ts index 8f778a9..e795e29 100644 --- a/src/tools/retire.ts +++ b/src/tools/retire.ts @@ -15,6 +15,7 @@ import { waitForRetirement } from "../services/indexer.js"; import { CryptoPaymentProvider } from "../services/payment/crypto.js"; import { StripePaymentProvider } from "../services/payment/stripe-stub.js"; import type { PaymentProvider } from "../services/payment/types.js"; +import { appendIdentityToReason, captureIdentity } from "../services/identity.js"; function getMarketplaceLink(): string { const config = loadConfig(); @@ -25,7 +26,9 @@ function marketplaceFallback( message: string, creditClass?: string, quantity?: number, - beneficiaryName?: string + beneficiaryName?: string, + beneficiaryEmail?: string, + authProvider?: string ): { content: Array<{ type: "text"; text: string }> } { const url = getMarketplaceLink(); const lines: string[] = [ @@ -39,7 +42,9 @@ function marketplaceFallback( if (creditClass) lines.push(`**Credit class**: ${creditClass}`); if (quantity) lines.push(`**Quantity**: ${quantity} credits`); - if (beneficiaryName) lines.push(`**Beneficiary**: ${beneficiaryName}`); + if (beneficiaryName) lines.push(`**Beneficiary name**: ${beneficiaryName}`); + if (beneficiaryEmail) lines.push(`**Beneficiary email**: ${beneficiaryEmail}`); + if (authProvider) lines.push(`**Auth provider**: ${authProvider}`); lines.push( ``, @@ -85,28 +90,65 @@ export async function retireCredits( quantity?: number, beneficiaryName?: string, jurisdiction?: string, - reason?: string + reason?: string, + beneficiaryEmail?: string, + authProvider?: string, + authSubject?: string ): Promise<{ content: Array<{ type: "text"; text: string }> }> { + let identity: ReturnType = { authMethod: "none" }; + try { + identity = captureIdentity({ + beneficiaryName, + beneficiaryEmail, + authProvider, + authSubject, + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return marketplaceFallback( + `Identity capture failed: ${errMsg}`, + creditClass, + quantity, + beneficiaryName, + beneficiaryEmail, + authProvider + ); + } + // Path A: No wallet → marketplace link (fully backward compatible) if (!isWalletConfigured()) { - return marketplaceFallback("", creditClass, quantity, beneficiaryName); + return marketplaceFallback( + "", + creditClass, + quantity, + identity.beneficiaryName, + identity.beneficiaryEmail, + identity.authProvider + ); } // Path B: Direct on-chain retirement const config = loadConfig(); const retireJurisdiction = jurisdiction || config.defaultJurisdiction; - const retireReason = - reason || "Regenerative contribution via Regen for AI"; + const retireReasonBase = + reason || "Regenerative contribution via Regen Compute Credits MCP server"; + const retireReason = appendIdentityToReason(retireReasonBase, identity); const retireQuantity = quantity || 1; try { // 1. Initialize wallet const { address } = await initWallet(); - // 2. Find best-priced sell orders + // 2. Select payment provider and find best-priced sell orders. + // Stripe flow expects USDC-denominated orders so we can authorize in fiat. + const provider = getPaymentProvider(); + const preferredDenom = provider.name === "stripe" ? "USDC" : undefined; + + // 3. Find best-priced sell orders const selection = await selectBestOrders( creditClass ? (creditClass.startsWith("C") ? "carbon" : "biodiversity") : undefined, - retireQuantity + retireQuantity, + preferredDenom ); if (selection.orders.length === 0) { @@ -114,7 +156,9 @@ export async function retireCredits( "No matching sell orders found on-chain. Try the marketplace instead.", creditClass, quantity, - beneficiaryName + identity.beneficiaryName, + identity.beneficiaryEmail, + identity.authProvider ); } @@ -125,12 +169,13 @@ export async function retireCredits( `You can try a smaller quantity or use the marketplace.`, creditClass, quantity, - beneficiaryName + identity.beneficiaryName, + identity.beneficiaryEmail, + identity.authProvider ); } - // 3. Authorize payment (balance check for crypto, hold for Stripe) - const provider = getPaymentProvider(); + // 4. Authorize payment (balance check for crypto, hold for Stripe) const auth = await provider.authorizePayment( selection.totalCostMicro, selection.paymentDenom, @@ -148,11 +193,13 @@ export async function retireCredits( `Insufficient wallet balance. Need ${displayCost} to purchase ${retireQuantity} credits.`, creditClass, quantity, - beneficiaryName + identity.beneficiaryName, + identity.beneficiaryEmail, + identity.authProvider ); } - // 4. Build and broadcast MsgBuyDirect + // 5. Build and broadcast MsgBuyDirect const buyOrders = selection.orders.map((order) => ({ sellOrderId: BigInt(order.sellOrderId), quantity: order.quantity, @@ -188,7 +235,9 @@ export async function retireCredits( `Transaction broadcast failed: ${errMsg}`, creditClass, quantity, - beneficiaryName + identity.beneficiaryName, + identity.beneficiaryEmail, + identity.authProvider ); } @@ -203,17 +252,19 @@ export async function retireCredits( `Transaction rejected (code ${txResult.code}): ${txResult.rawLog || "unknown error"}`, creditClass, quantity, - beneficiaryName + identity.beneficiaryName, + identity.beneficiaryEmail, + identity.authProvider ); } - // 5. Capture payment (no-op for crypto) + // 6. Capture payment (no-op for crypto) await provider.capturePayment(auth.id); - // 6. Poll indexer for retirement certificate + // 7. Poll indexer for retirement certificate const retirement = await waitForRetirement(txResult.transactionHash); - // 7. Build success response + // 8. Build success response const displayCost = formatAmount( selection.totalCostMicro, selection.exponent, @@ -230,13 +281,22 @@ export async function retireCredits( `| Credits Retired | ${selection.totalQuantity} |`, `| Cost | ${displayCost} |`, `| Jurisdiction | ${retireJurisdiction} |`, - `| Reason | ${retireReason} |`, + `| Reason | ${retireReasonBase} |`, `| Transaction Hash | \`${txResult.transactionHash}\` |`, `| Block Height | ${txResult.height} |`, ]; - if (beneficiaryName) { - lines.push(`| Beneficiary | ${beneficiaryName} |`); + if (identity.beneficiaryName) { + lines.push(`| Beneficiary Name | ${identity.beneficiaryName} |`); + } + if (identity.beneficiaryEmail) { + lines.push(`| Beneficiary Email | ${identity.beneficiaryEmail} |`); + } + if (identity.authProvider) { + lines.push(`| Auth Provider | ${identity.authProvider} |`); + } + if (identity.authSubject) { + lines.push(`| Auth Subject | ${identity.authSubject} |`); } if (retirement) { @@ -267,7 +327,9 @@ export async function retireCredits( `Direct retirement failed: ${errMsg}`, creditClass, quantity, - beneficiaryName + identity.beneficiaryName || beneficiaryName, + identity.beneficiaryEmail || beneficiaryEmail, + identity.authProvider || authProvider ); } } diff --git a/src/tools/subscriptions.ts b/src/tools/subscriptions.ts new file mode 100644 index 0000000..f004558 --- /dev/null +++ b/src/tools/subscriptions.ts @@ -0,0 +1,147 @@ +import { StripeSubscriptionService } from "../services/subscription/stripe.js"; +import type { + SubscriptionIdentityInput, + SubscriptionState, + SubscriptionTierId, +} from "../services/subscription/types.js"; + +const subscriptions = new StripeSubscriptionService(); + +function renderState(title: string, state: SubscriptionState): string { + const lines: string[] = [ + `## ${title}`, + ``, + `| Field | Value |`, + `|-------|-------|`, + `| Customer ID | ${state.customerId || "N/A"} |`, + `| Status | ${state.status} |`, + `| Tier | ${state.tierId || "N/A"} |`, + `| Stripe Price | ${state.priceId || "N/A"} |`, + `| Cancel At Period End | ${state.cancelAtPeriodEnd ? "Yes" : "No"} |`, + `| Current Period End | ${state.currentPeriodEnd || "N/A"} |`, + ]; + + if (state.email) { + lines.splice(4, 0, `| Email | ${state.email} |`); + } + + if (state.status === "none") { + lines.push( + ``, + `No active subscription was found for the provided customer/email.` + ); + } + + return lines.join("\n"); +} + +function normalizeIdentity( + email?: string, + customerId?: string, + fullName?: string, + paymentMethodId?: string +): SubscriptionIdentityInput { + return { + email: email?.trim() || undefined, + customerId: customerId?.trim() || undefined, + fullName: fullName?.trim() || undefined, + paymentMethodId: paymentMethodId?.trim() || undefined, + }; +} + +export async function listSubscriptionTiersTool() { + try { + const tiers = subscriptions.listTiers(); + const lines = [ + "## Subscription Tiers", + "", + "| Tier | Name | Monthly Price | Description | Stripe Price Config |", + "|------|------|---------------|-------------|---------------------|", + ...tiers.map( + (tier) => + `| ${tier.id} | ${tier.name} | $${tier.monthlyUsd}/month | ${tier.description} | ${process.env[tier.stripePriceIdEnv] ? "Configured" : `Missing (${tier.stripePriceIdEnv})`} |` + ), + "", + "Supported plan IDs: `starter`, `growth`, `impact`.", + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [{ type: "text" as const, text: `Error listing tiers: ${message}` }], + isError: true, + }; + } +} + +export async function manageSubscriptionTool( + action: "subscribe" | "status" | "cancel", + tier?: SubscriptionTierId, + email?: string, + customerId?: string, + fullName?: string, + paymentMethodId?: string +) { + try { + const identity = normalizeIdentity(email, customerId, fullName, paymentMethodId); + + if (!identity.email && !identity.customerId) { + return { + content: [ + { + type: "text" as const, + text: + "Provide at least one of `email` or `customer_id` to manage subscriptions.", + }, + ], + isError: true, + }; + } + + if (action === "status") { + const state = await subscriptions.getSubscriptionState(identity); + return { + content: [{ type: "text" as const, text: renderState("Subscription Status", state) }], + }; + } + + if (action === "cancel") { + const state = await subscriptions.cancelSubscription(identity); + return { + content: [{ type: "text" as const, text: renderState("Subscription Canceled", state) }], + }; + } + + if (!tier) { + return { + content: [ + { + type: "text" as const, + text: + "For `subscribe`, you must provide a `tier` (`starter`, `growth`, or `impact`).", + }, + ], + isError: true, + }; + } + + const state = await subscriptions.ensureSubscription(tier, identity); + return { + content: [{ type: "text" as const, text: renderState("Subscription Updated", state) }], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Subscription operation failed: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/tests/attribution-dashboard.test.ts b/tests/attribution-dashboard.test.ts new file mode 100644 index 0000000..8e3fc86 --- /dev/null +++ b/tests/attribution-dashboard.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { AttributionDashboardService } from "../src/services/attribution/dashboard.js"; +import type { BatchExecutionState, BatchExecutionStore } from "../src/services/batch-retirement/types.js"; +import type { UserContributionSummary } from "../src/services/pool-accounting/types.js"; + +class InMemoryBatchExecutionStore implements BatchExecutionStore { + constructor(private readonly state: BatchExecutionState) {} + + async readState(): Promise { + return JSON.parse(JSON.stringify(this.state)) as BatchExecutionState; + } + + async writeState(state: BatchExecutionState): Promise { + this.state = JSON.parse(JSON.stringify(state)) as BatchExecutionState; + } +} + +describe("AttributionDashboardService", () => { + it("builds subscriber dashboard totals from contribution + attribution history", async () => { + const userSummary: UserContributionSummary = { + userId: "email:alice@example.com", + email: "alice@example.com", + customerId: "cus_123", + contributionCount: 2, + totalUsdCents: 400, + totalUsd: 4, + lastContributionAt: "2026-03-15T00:00:00.000Z", + byMonth: [ + { + month: "2026-02", + contributionCount: 1, + totalUsdCents: 100, + totalUsd: 1, + }, + { + month: "2026-03", + contributionCount: 1, + totalUsdCents: 300, + totalUsd: 3, + }, + ], + }; + + const service = new AttributionDashboardService({ + poolAccounting: { + getUserSummary: async () => userSummary, + }, + executionStore: new InMemoryBatchExecutionStore({ + version: 1, + executions: [ + { + id: "batch_1", + month: "2026-03", + creditType: "carbon", + dryRun: false, + status: "success", + reason: "Monthly pool retirement", + budgetUsdCents: 300, + spentMicro: "3000000", + spentDenom: "uusdc", + retiredQuantity: "1.500000", + attributions: [ + { + userId: "email:alice@example.com", + email: "alice@example.com", + customerId: "cus_123", + sharePpm: 500000, + contributionUsdCents: 300, + attributedBudgetUsdCents: 150, + attributedCostMicro: "1500000", + attributedQuantity: "0.750000", + paymentDenom: "uusdc", + }, + ], + txHash: "TX123", + blockHeight: 123, + retirementId: "WyRet1", + executedAt: "2026-03-31T00:00:00.000Z", + }, + { + id: "batch_2", + month: "2026-02", + creditType: "biodiversity", + dryRun: false, + status: "success", + reason: "Monthly pool retirement", + budgetUsdCents: 100, + spentMicro: "1000000", + spentDenom: "uusdc", + retiredQuantity: "0.500000", + attributions: [ + { + userId: "email:alice@example.com", + email: "alice@example.com", + customerId: "cus_123", + sharePpm: 1000000, + contributionUsdCents: 100, + attributedBudgetUsdCents: 100, + attributedCostMicro: "1000000", + attributedQuantity: "0.500000", + paymentDenom: "uusdc", + }, + ], + txHash: "TX122", + blockHeight: 122, + retirementId: "WyRet2", + executedAt: "2026-02-28T00:00:00.000Z", + }, + ], + }), + }); + + const dashboard = await service.getSubscriberDashboard({ + email: "alice@example.com", + }); + + expect(dashboard).toMatchObject({ + userId: "email:alice@example.com", + contributionCount: 2, + totalContributedUsdCents: 400, + totalAttributedBudgetUsdCents: 250, + totalAttributedQuantity: "1.250000", + attributionCount: 2, + }); + expect(dashboard?.byMonth[0]).toMatchObject({ + month: "2026-03", + contributionUsdCents: 300, + attributedBudgetUsdCents: 150, + attributedQuantity: "0.750000", + }); + }); + + it("returns a month-specific subscriber attribution certificate", async () => { + const service = new AttributionDashboardService({ + poolAccounting: { + getUserSummary: async () => + ({ + userId: "user-1", + email: "user@example.com", + contributionCount: 1, + totalUsdCents: 300, + totalUsd: 3, + byMonth: [ + { + month: "2026-03", + contributionCount: 1, + totalUsdCents: 300, + totalUsd: 3, + }, + ], + } as UserContributionSummary), + }, + executionStore: new InMemoryBatchExecutionStore({ + version: 1, + executions: [ + { + id: "batch_1", + month: "2026-03", + dryRun: false, + status: "success", + reason: "Monthly pool retirement", + budgetUsdCents: 300, + spentMicro: "3000000", + spentDenom: "uusdc", + retiredQuantity: "1.500000", + attributions: [ + { + userId: "user-1", + sharePpm: 400000, + contributionUsdCents: 300, + attributedBudgetUsdCents: 120, + attributedCostMicro: "1200000", + attributedQuantity: "0.600000", + paymentDenom: "uusdc", + }, + ], + txHash: "TX123", + blockHeight: 123, + retirementId: "WyRet1", + executedAt: "2026-03-31T00:00:00.000Z", + }, + ], + }), + }); + + const certificate = await service.getSubscriberCertificateForMonth({ + month: "2026-03", + userId: "user-1", + }); + + expect(certificate).toMatchObject({ + userId: "user-1", + month: "2026-03", + contributionUsdCents: 300, + }); + expect(certificate?.execution).toMatchObject({ + executionId: "batch_1", + attributedBudgetUsdCents: 120, + attributedQuantity: "0.600000", + txHash: "TX123", + }); + }); +}); diff --git a/tests/certificates.test.ts b/tests/certificates.test.ts new file mode 100644 index 0000000..e624bbf --- /dev/null +++ b/tests/certificates.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { appendIdentityToReason } from "../src/services/identity.js"; + +const mocks = vi.hoisted(() => ({ + getRetirementById: vi.fn(), +})); + +vi.mock("../src/services/indexer.js", () => ({ + getRetirementById: mocks.getRetirementById, +})); + +import { getRetirementCertificate } from "../src/tools/certificates.js"; + +describe("getRetirementCertificate", () => { + beforeEach(() => { + mocks.getRetirementById.mockReset(); + }); + + it("renders parsed attribution fields from encoded retirement reason metadata", async () => { + const reason = appendIdentityToReason("Regenerative contribution", { + authMethod: "oauth", + beneficiaryName: "Alice", + beneficiaryEmail: "alice@example.com", + authProvider: "google", + authSubject: "oauth-sub-123", + }); + + mocks.getRetirementById.mockResolvedValue({ + nodeId: "WyCert123", + type: "RETIREMENT", + amount: "1.000000", + batchDenom: "C01-001-2026", + jurisdiction: "US-CA", + owner: "regen1owner", + reason, + timestamp: "2026-02-23T00:00:00.000Z", + txHash: "ABC123", + blockHeight: "12345", + chainNum: 1, + }); + + const result = await getRetirementCertificate("WyCert123"); + const text = result.content[0]?.text ?? ""; + + expect(text).toContain("| Reason | Regenerative contribution |"); + expect(text).toContain("| Beneficiary Name | Alice |"); + expect(text).toContain("| Beneficiary Email | alice@example.com |"); + expect(text).toContain("| Auth Provider | google |"); + expect(text).toContain("| Auth Subject | oauth-sub-123 |"); + }); +}); diff --git a/tests/estimator.foundation.test.ts b/tests/estimator.foundation.test.ts new file mode 100644 index 0000000..f25f2b2 --- /dev/null +++ b/tests/estimator.foundation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { estimateFootprint } from "../src/services/estimator.js"; + +describe("estimateFootprint", () => { + it("returns deterministic estimates for a session without tool calls", () => { + const result = estimateFootprint(10); + + expect(result.session_minutes).toBe(10); + expect(result.estimated_queries).toBe(15); + expect(result.energy_kwh).toBe(0.15); + expect(result.co2_kg).toBe(0.06); + expect(result.co2_tonnes).toBe(0.00006); + expect(result.equivalent_carbon_credits).toBe(0.00006); + expect(result.equivalent_cost_usd).toBe(0); + expect(result.methodology_note.length).toBeGreaterThan(0); + }); + + it("applies output rounding precision across all derived fields", () => { + const result = estimateFootprint(123.456); + + expect(result.estimated_queries).toBe(185); + expect(result.energy_kwh).toBe(1.8518); + expect(result.co2_kg).toBe(0.741); + expect(result.co2_tonnes).toBe(0.00074); + expect(result.equivalent_carbon_credits).toBe(0.00074); + expect(result.equivalent_cost_usd).toBe(0.03); + }); + + it("uses tool calls as a minimum query floor when larger than duration estimate", () => { + const result = estimateFootprint(2, 4); + + expect(result.estimated_queries).toBe(8); + expect(result.energy_kwh).toBe(0.08); + expect(result.co2_kg).toBe(0.032); + expect(result.co2_tonnes).toBe(0.00003); + }); + + it("uses duration estimate when it exceeds tool-call floor", () => { + const result = estimateFootprint(50, 10); + + expect(result.estimated_queries).toBe(75); + expect(result.energy_kwh).toBe(0.75); + expect(result.co2_kg).toBe(0.3); + expect(result.co2_tonnes).toBe(0.0003); + expect(result.equivalent_cost_usd).toBe(0.01); + }); + + it("treats zero tool calls the same as omitting tool calls", () => { + const withoutToolCalls = estimateFootprint(1); + const zeroToolCalls = estimateFootprint(1, 0); + + expect(zeroToolCalls).toEqual(withoutToolCalls); + }); +}); diff --git a/tests/fractional-attribution.test.ts b/tests/fractional-attribution.test.ts new file mode 100644 index 0000000..75bf4e6 --- /dev/null +++ b/tests/fractional-attribution.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { buildContributorAttributions } from "../src/services/batch-retirement/attribution.js"; + +describe("buildContributorAttributions", () => { + it("allocates budget, spend, and quantity proportionally while preserving totals", () => { + const attributions = buildContributorAttributions({ + contributors: [ + { + userId: "user-a", + contributionCount: 1, + totalUsdCents: 100, + totalUsd: 1, + }, + { + userId: "user-b", + contributionCount: 1, + totalUsdCents: 300, + totalUsd: 3, + }, + ], + totalContributionUsdCents: 400, + appliedBudgetUsdCents: 400, + totalCostMicro: 4_000_000n, + retiredQuantity: "2.000000", + paymentDenom: "uusdc", + }); + + expect(attributions).toHaveLength(2); + expect(attributions[0]).toMatchObject({ + userId: "user-a", + sharePpm: 250000, + attributedBudgetUsdCents: 100, + attributedCostMicro: "1000000", + attributedQuantity: "0.500000", + }); + expect(attributions[1]).toMatchObject({ + userId: "user-b", + sharePpm: 750000, + attributedBudgetUsdCents: 300, + attributedCostMicro: "3000000", + attributedQuantity: "1.500000", + }); + + const totalBudget = attributions.reduce( + (sum, item) => sum + item.attributedBudgetUsdCents, + 0 + ); + const totalCostMicro = attributions.reduce( + (sum, item) => sum + BigInt(item.attributedCostMicro), + 0n + ); + expect(totalBudget).toBe(400); + expect(totalCostMicro).toBe(4_000_000n); + }); + + it("handles rounding by distributing remainder deterministically", () => { + const attributions = buildContributorAttributions({ + contributors: [ + { + userId: "u1", + contributionCount: 1, + totalUsdCents: 1, + totalUsd: 0.01, + }, + { + userId: "u2", + contributionCount: 1, + totalUsdCents: 1, + totalUsd: 0.01, + }, + { + userId: "u3", + contributionCount: 1, + totalUsdCents: 1, + totalUsd: 0.01, + }, + ], + totalContributionUsdCents: 3, + appliedBudgetUsdCents: 2, + totalCostMicro: 2n, + retiredQuantity: "0.000002", + paymentDenom: "uusdc", + }); + + const totalBudget = attributions.reduce( + (sum, item) => sum + item.attributedBudgetUsdCents, + 0 + ); + const totalCostMicro = attributions.reduce( + (sum, item) => sum + BigInt(item.attributedCostMicro), + 0n + ); + const totalQuantityMicro = attributions.reduce((sum, item) => { + const [whole, frac = ""] = item.attributedQuantity.split("."); + return sum + BigInt(whole) * 1_000_000n + BigInt(frac.padEnd(6, "0")); + }, 0n); + + expect(totalBudget).toBe(2); + expect(totalCostMicro).toBe(2n); + expect(totalQuantityMicro).toBe(2n); + }); +}); diff --git a/tests/identity.test.ts b/tests/identity.test.ts new file mode 100644 index 0000000..8878f03 --- /dev/null +++ b/tests/identity.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + appendIdentityToReason, + captureIdentity, + parseAttributedReason, +} from "../src/services/identity.js"; + +describe("identity attribution helpers", () => { + it("captures normalized email attribution", () => { + const identity = captureIdentity({ + beneficiaryName: " Alice ", + beneficiaryEmail: "ALICE@Example.com ", + }); + + expect(identity).toEqual({ + authMethod: "email", + beneficiaryName: "Alice", + beneficiaryEmail: "alice@example.com", + }); + }); + + it("requires auth_provider and auth_subject together", () => { + expect(() => captureIdentity({ authProvider: "google" })).toThrow( + "auth_provider and auth_subject must be provided together" + ); + + expect(() => captureIdentity({ authSubject: "sub-123" })).toThrow( + "auth_provider and auth_subject must be provided together" + ); + }); + + it("round-trips reason metadata with parseAttributedReason", () => { + const withIdentity = appendIdentityToReason("Funding regenerative impact", { + authMethod: "oauth", + beneficiaryName: "Alice", + beneficiaryEmail: "alice@example.com", + authProvider: "google", + authSubject: "sub-123", + }); + + const parsed = parseAttributedReason(withIdentity); + expect(parsed.reasonText).toBe("Funding regenerative impact"); + expect(parsed.identity).toMatchObject({ + method: "oauth", + name: "Alice", + email: "alice@example.com", + provider: "google", + subject: "sub-123", + }); + }); + + it("returns unmodified reason text when no identity tag exists", () => { + const parsed = parseAttributedReason("Plain reason text"); + expect(parsed.reasonText).toBe("Plain reason text"); + expect(parsed.identity).toBeUndefined(); + }); +}); diff --git a/tests/monthly-batch-executor.test.ts b/tests/monthly-batch-executor.test.ts new file mode 100644 index 0000000..288cc22 --- /dev/null +++ b/tests/monthly-batch-executor.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MonthlyBatchRetirementExecutor } from "../src/services/batch-retirement/executor.js"; +import type { + BatchExecutionState, + BatchExecutionStore, + BudgetOrderSelection, +} from "../src/services/batch-retirement/types.js"; + +class InMemoryBatchExecutionStore implements BatchExecutionStore { + private state: BatchExecutionState = { version: 1, executions: [] }; + + async readState(): Promise { + return JSON.parse(JSON.stringify(this.state)) as BatchExecutionState; + } + + async writeState(state: BatchExecutionState): Promise { + this.state = JSON.parse(JSON.stringify(state)) as BatchExecutionState; + } +} + +describe("MonthlyBatchRetirementExecutor", () => { + let store: InMemoryBatchExecutionStore; + let selectOrdersForBudget: ReturnType; + let signAndBroadcast: ReturnType; + let waitForRetirement: ReturnType; + let initWallet: ReturnType; + let getMonthlySummary: ReturnType; + + const selection: BudgetOrderSelection = { + orders: [ + { + sellOrderId: "101", + batchDenom: "C01-001-2026", + quantity: "1.250000", + askAmount: "2000000", + askDenom: "uusdc", + costMicro: 2_500_000n, + }, + ], + totalQuantity: "1.250000", + totalCostMicro: 2_500_000n, + remainingBudgetMicro: 500_000n, + paymentDenom: "uusdc", + displayDenom: "USDC", + exponent: 6, + exhaustedBudget: false, + }; + + beforeEach(() => { + store = new InMemoryBatchExecutionStore(); + selectOrdersForBudget = vi.fn().mockResolvedValue(selection); + signAndBroadcast = vi.fn().mockResolvedValue({ + code: 0, + transactionHash: "TX123", + height: 123456, + rawLog: "", + }); + waitForRetirement = vi.fn().mockResolvedValue({ nodeId: "WyRet123" }); + initWallet = vi.fn().mockResolvedValue({ address: "regen1batchbuyer" }); + getMonthlySummary = vi.fn().mockResolvedValue({ + month: "2026-03", + contributionCount: 3, + uniqueContributors: 1, + totalUsdCents: 300, + totalUsd: 3, + contributors: [ + { + userId: "user-a", + contributionCount: 3, + totalUsdCents: 300, + totalUsd: 3, + }, + ], + }); + }); + + function createExecutor(walletConfigured = true): MonthlyBatchRetirementExecutor { + return new MonthlyBatchRetirementExecutor({ + poolAccounting: { getMonthlySummary }, + executionStore: store, + selectOrdersForBudget, + isWalletConfigured: () => walletConfigured, + initWallet, + signAndBroadcast, + waitForRetirement, + loadConfig: () => + ({ + defaultJurisdiction: "US", + }) as any, + }); + } + + it("returns no_contributions when month has no pool funds", async () => { + getMonthlySummary.mockResolvedValueOnce({ + month: "2026-03", + contributionCount: 0, + uniqueContributors: 0, + totalUsdCents: 0, + totalUsd: 0, + contributors: [], + }); + const executor = createExecutor(); + + const result = await executor.runMonthlyBatch({ month: "2026-03" }); + + expect(result.status).toBe("no_contributions"); + expect(selectOrdersForBudget).not.toHaveBeenCalled(); + }); + + it("executes dry-run by default and stores a dry-run execution record", async () => { + const executor = createExecutor(); + + const result = await executor.runMonthlyBatch({ month: "2026-03" }); + + expect(result.status).toBe("dry_run"); + expect(selectOrdersForBudget).toHaveBeenCalledWith( + undefined, + 3_000_000n, + "USDC" + ); + expect(signAndBroadcast).not.toHaveBeenCalled(); + expect(result.attributions).toHaveLength(1); + expect(result.attributions?.[0]).toMatchObject({ + userId: "user-a", + attributedBudgetUsdCents: 300, + attributedQuantity: "1.250000", + }); + + const state = await store.readState(); + expect(state.executions).toHaveLength(1); + expect(state.executions[0]?.status).toBe("dry_run"); + expect(state.executions[0]?.attributions?.[0]?.userId).toBe("user-a"); + }); + + it("executes on-chain batch and writes success record when dryRun=false", async () => { + const executor = createExecutor(true); + + const result = await executor.runMonthlyBatch({ + month: "2026-03", + dryRun: false, + creditType: "carbon", + }); + + expect(result.status).toBe("success"); + expect(initWallet).toHaveBeenCalledTimes(1); + expect(signAndBroadcast).toHaveBeenCalledTimes(1); + expect(waitForRetirement).toHaveBeenCalledWith("TX123"); + expect(result.txHash).toBe("TX123"); + expect(result.retirementId).toBe("WyRet123"); + expect(result.attributions).toHaveLength(1); + + const state = await store.readState(); + expect(state.executions).toHaveLength(1); + expect(state.executions[0]).toMatchObject({ + month: "2026-03", + status: "success", + txHash: "TX123", + creditType: "carbon", + }); + }); + + it("blocks duplicate successful monthly execution unless force=true", async () => { + const executor = createExecutor(true); + await executor.runMonthlyBatch({ + month: "2026-03", + dryRun: false, + creditType: "carbon", + }); + + const second = await executor.runMonthlyBatch({ + month: "2026-03", + dryRun: false, + creditType: "carbon", + }); + expect(second.status).toBe("already_executed"); + expect(signAndBroadcast).toHaveBeenCalledTimes(1); + + const forced = await executor.runMonthlyBatch({ + month: "2026-03", + dryRun: false, + creditType: "carbon", + force: true, + }); + expect(forced.status).toBe("success"); + expect(signAndBroadcast).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/order-selector.test.ts b/tests/order-selector.test.ts new file mode 100644 index 0000000..57cef6e --- /dev/null +++ b/tests/order-selector.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AllowedDenom, CreditClass, SellOrder } from "../src/services/ledger.js"; + +const ledgerMocks = vi.hoisted(() => ({ + listSellOrders: vi.fn<() => Promise>(), + listCreditClasses: vi.fn<() => Promise>(), + listBatches: vi.fn(), + getAllowedDenoms: vi.fn<() => Promise>(), +})); + +vi.mock("../src/services/ledger.js", () => ({ + listSellOrders: ledgerMocks.listSellOrders, + listCreditClasses: ledgerMocks.listCreditClasses, + listBatches: ledgerMocks.listBatches, + getAllowedDenoms: ledgerMocks.getAllowedDenoms, +})); + +import { selectBestOrders } from "../src/services/order-selector.js"; + +const CARBON_CLASSES: CreditClass[] = [ + { + id: "C01", + admin: "regen1admin", + metadata: "", + credit_type_abbrev: "C", + }, +]; + +function order(overrides: Partial): SellOrder { + return { + id: "order-1", + seller: "regen1seller", + batch_denom: "C01-001-2026", + quantity: "1", + ask_denom: "uregen", + ask_amount: "1000", + disable_auto_retire: false, + expiration: null, + ...overrides, + }; +} + +describe("selectBestOrders", () => { + beforeEach(() => { + ledgerMocks.listSellOrders.mockReset(); + ledgerMocks.listCreditClasses.mockReset(); + ledgerMocks.getAllowedDenoms.mockReset(); + ledgerMocks.listBatches.mockReset(); + vi.useRealTimers(); + + ledgerMocks.listCreditClasses.mockResolvedValue(CARBON_CLASSES); + ledgerMocks.getAllowedDenoms.mockResolvedValue([ + { bank_denom: "uregen", display_denom: "REGEN", exponent: 6 }, + ]); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("routes fills cheapest-first across multiple eligible orders", async () => { + ledgerMocks.listSellOrders.mockResolvedValue([ + order({ id: "expensive", ask_amount: "2200", quantity: "2" }), + order({ id: "cheapest", ask_amount: "1000", quantity: "1" }), + order({ id: "mid", ask_amount: "1500", quantity: "3" }), + ]); + + const selection = await selectBestOrders("carbon", 3.5); + + expect(selection.orders.map((o) => o.sellOrderId)).toEqual(["cheapest", "mid"]); + expect(selection.orders.map((o) => o.quantity)).toEqual(["1.000000", "2.500000"]); + expect(selection.totalCostMicro).toBe(4750n); + expect(selection.totalQuantity).toBe("3.500000"); + expect(selection.insufficientSupply).toBe(false); + }); + + it("filters out expired orders", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z")); + + ledgerMocks.listSellOrders.mockResolvedValue([ + order({ + id: "expired-cheap", + ask_amount: "500", + expiration: "2026-05-31T23:59:59.000Z", + }), + order({ + id: "open-order", + ask_amount: "900", + expiration: null, + }), + order({ + id: "future-order", + ask_amount: "1200", + expiration: "2026-12-31T00:00:00.000Z", + }), + ]); + + const selection = await selectBestOrders("carbon", 1); + + expect(selection.orders).toHaveLength(1); + expect(selection.orders[0]?.sellOrderId).toBe("open-order"); + expect(selection.totalCostMicro).toBe(900n); + }); + + it("honors preferred payment denom when provided", async () => { + ledgerMocks.getAllowedDenoms.mockResolvedValue([ + { bank_denom: "uregen", display_denom: "REGEN", exponent: 6 }, + { bank_denom: "uusdc", display_denom: "USDC", exponent: 6 }, + ]); + ledgerMocks.listSellOrders.mockResolvedValue([ + order({ id: "regen-cheap", ask_denom: "uregen", ask_amount: "1000" }), + order({ id: "usdc-order", ask_denom: "uusdc", ask_amount: "1300" }), + ]); + + const selection = await selectBestOrders("carbon", 1, "USDC"); + + expect(selection.paymentDenom).toBe("uusdc"); + expect(selection.displayDenom).toBe("USDC"); + expect(selection.orders).toHaveLength(1); + expect(selection.orders[0]?.sellOrderId).toBe("usdc-order"); + expect(selection.orders[0]?.askDenom).toBe("uusdc"); + }); + + it("flags insufficient supply when eligible order quantity is too low", async () => { + ledgerMocks.listSellOrders.mockResolvedValue([ + order({ id: "small-1", quantity: "1", ask_amount: "1000" }), + order({ id: "small-2", quantity: "2", ask_amount: "1100" }), + ]); + + const selection = await selectBestOrders("carbon", 5); + + expect(selection.insufficientSupply).toBe(true); + expect(selection.totalQuantity).toBe("3.000000"); + expect(selection.orders.map((o) => o.sellOrderId)).toEqual(["small-1", "small-2"]); + expect(selection.totalCostMicro).toBe(3200n); + }); +}); diff --git a/tests/pool-accounting.test.ts b/tests/pool-accounting.test.ts new file mode 100644 index 0000000..5c69b89 --- /dev/null +++ b/tests/pool-accounting.test.ts @@ -0,0 +1,169 @@ +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { JsonFilePoolAccountingStore } from "../src/services/pool-accounting/store.js"; +import { PoolAccountingService } from "../src/services/pool-accounting/service.js"; +import type { PoolAccountingState, PoolAccountingStore } from "../src/services/pool-accounting/types.js"; + +class InMemoryPoolAccountingStore implements PoolAccountingStore { + private state: PoolAccountingState = { version: 1, contributions: [] }; + + async readState(): Promise { + return JSON.parse(JSON.stringify(this.state)) as PoolAccountingState; + } + + async writeState(state: PoolAccountingState): Promise { + this.state = JSON.parse(JSON.stringify(state)) as PoolAccountingState; + } +} + +describe("PoolAccountingService", () => { + let service: PoolAccountingService; + + beforeEach(() => { + service = new PoolAccountingService(new InMemoryPoolAccountingStore()); + }); + + it("records per-user contributions and maps tier amount defaults", async () => { + const receipt = await service.recordContribution({ + email: "ALICE@example.com", + tierId: "growth", + contributedAt: "2026-02-10T12:00:00.000Z", + }); + + expect(receipt.record.userId).toBe("email:alice@example.com"); + expect(receipt.record.amountUsdCents).toBe(300); + expect(receipt.record.month).toBe("2026-02"); + expect(receipt.userSummary).toMatchObject({ + userId: "email:alice@example.com", + email: "alice@example.com", + contributionCount: 1, + totalUsdCents: 300, + totalUsd: 3, + }); + expect(receipt.monthSummary).toMatchObject({ + month: "2026-02", + contributionCount: 1, + uniqueContributors: 1, + totalUsdCents: 300, + totalUsd: 3, + }); + }); + + it("aggregates contributions per month across multiple users", async () => { + await service.recordContribution({ + userId: "user-a", + amountUsd: 1, + contributedAt: "2026-03-01T00:00:00.000Z", + }); + await service.recordContribution({ + userId: "user-a", + amountUsd: 0.5, + contributedAt: "2026-03-05T00:00:00.000Z", + }); + await service.recordContribution({ + userId: "user-b", + amountUsdCents: 300, + contributedAt: "2026-03-06T00:00:00.000Z", + }); + await service.recordContribution({ + userId: "user-b", + amountUsdCents: 100, + contributedAt: "2026-04-01T00:00:00.000Z", + }); + + const march = await service.getMonthlySummary("2026-03"); + expect(march).toMatchObject({ + month: "2026-03", + contributionCount: 3, + uniqueContributors: 2, + totalUsdCents: 450, + totalUsd: 4.5, + }); + expect(march.contributors[0]).toMatchObject({ + userId: "user-b", + contributionCount: 1, + totalUsdCents: 300, + totalUsd: 3, + }); + expect(march.contributors[1]).toMatchObject({ + userId: "user-a", + contributionCount: 2, + totalUsdCents: 150, + totalUsd: 1.5, + }); + }); + + it("returns user summary by customer lookup", async () => { + await service.recordContribution({ + customerId: "cus_123", + email: "alice@example.com", + amountUsdCents: 100, + contributedAt: "2026-03-01T00:00:00.000Z", + }); + await service.recordContribution({ + customerId: "cus_123", + email: "alice@example.com", + amountUsdCents: 500, + contributedAt: "2026-04-01T00:00:00.000Z", + }); + + const summary = await service.getUserSummary({ customerId: "cus_123" }); + expect(summary).toMatchObject({ + userId: "customer:cus_123", + customerId: "cus_123", + contributionCount: 2, + totalUsdCents: 600, + totalUsd: 6, + }); + expect(summary?.byMonth).toHaveLength(2); + }); + + it("persists records via the JSON file store", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pool-accounting-")); + const ledgerPath = path.join(tempDir, "ledger.json"); + + const first = new PoolAccountingService( + new JsonFilePoolAccountingStore(ledgerPath) + ); + await first.recordContribution({ + userId: "user-persisted", + amountUsdCents: 200, + contributedAt: "2026-02-01T00:00:00.000Z", + }); + + const second = new PoolAccountingService( + new JsonFilePoolAccountingStore(ledgerPath) + ); + const summary = await second.getUserSummary({ userId: "user-persisted" }); + + expect(summary).toMatchObject({ + userId: "user-persisted", + contributionCount: 1, + totalUsdCents: 200, + totalUsd: 2, + }); + }); + + it("lists available months in descending order", async () => { + await service.recordContribution({ + userId: "user-a", + amountUsdCents: 100, + contributedAt: "2026-01-15T00:00:00.000Z", + }); + await service.recordContribution({ + userId: "user-a", + amountUsdCents: 100, + contributedAt: "2026-03-15T00:00:00.000Z", + }); + await service.recordContribution({ + userId: "user-a", + amountUsdCents: 100, + contributedAt: "2026-02-15T00:00:00.000Z", + }); + + const months = await service.listAvailableMonths(); + expect(months).toEqual(["2026-03", "2026-02", "2026-01"]); + }); +}); diff --git a/tests/retire-credits.test.ts b/tests/retire-credits.test.ts new file mode 100644 index 0000000..d2d9fea --- /dev/null +++ b/tests/retire-credits.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parseAttributedReason } from "../src/services/identity.js"; + +const mocks = vi.hoisted(() => ({ + isWalletConfigured: vi.fn(), + loadConfig: vi.fn(), + initWallet: vi.fn(), + signAndBroadcast: vi.fn(), + selectBestOrders: vi.fn(), + waitForRetirement: vi.fn(), + cryptoAuthorizePayment: vi.fn(), + cryptoCapturePayment: vi.fn(), + cryptoRefundPayment: vi.fn(), + stripeAuthorizePayment: vi.fn(), + stripeCapturePayment: vi.fn(), + stripeRefundPayment: vi.fn(), +})); + +vi.mock("../src/config.js", () => ({ + isWalletConfigured: mocks.isWalletConfigured, + loadConfig: mocks.loadConfig, +})); + +vi.mock("../src/services/wallet.js", () => ({ + initWallet: mocks.initWallet, + signAndBroadcast: mocks.signAndBroadcast, +})); + +vi.mock("../src/services/order-selector.js", () => ({ + selectBestOrders: mocks.selectBestOrders, +})); + +vi.mock("../src/services/indexer.js", () => ({ + waitForRetirement: mocks.waitForRetirement, +})); + +vi.mock("../src/services/payment/crypto.js", () => ({ + CryptoPaymentProvider: class { + name = "crypto"; + + authorizePayment( + amountMicro: bigint, + denom: string, + metadata?: Record + ) { + return mocks.cryptoAuthorizePayment(amountMicro, denom, metadata); + } + + capturePayment(authorizationId: string) { + return mocks.cryptoCapturePayment(authorizationId); + } + + refundPayment(authorizationId: string) { + return mocks.cryptoRefundPayment(authorizationId); + } + }, +})); + +vi.mock("../src/services/payment/stripe-stub.js", () => ({ + StripePaymentProvider: class { + name = "stripe"; + + authorizePayment( + amountMicro: bigint, + denom: string, + metadata?: Record + ) { + return mocks.stripeAuthorizePayment(amountMicro, denom, metadata); + } + + capturePayment(authorizationId: string) { + return mocks.stripeCapturePayment(authorizationId); + } + + refundPayment(authorizationId: string) { + return mocks.stripeRefundPayment(authorizationId); + } + }, +})); + +import { retireCredits } from "../src/tools/retire.js"; + +function responseText(result: { content: Array<{ type: "text"; text: string }> }): string { + return result.content[0]?.text ?? ""; +} + +describe("retireCredits", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.loadConfig.mockReturnValue({ + indexerUrl: "https://api.regen.network/indexer/v1/graphql", + lcdUrl: "https://lcd-regen.keplr.app", + marketplaceUrl: "https://app.regen.network", + rpcUrl: "http://mainnet.regen.network:26657", + chainId: "regen-1", + walletMnemonic: "test mnemonic", + paymentProvider: "crypto", + defaultJurisdiction: "US", + }); + + mocks.cryptoAuthorizePayment.mockResolvedValue({ + id: "auth-1", + provider: "crypto", + amountMicro: 2_000_000n, + denom: "uregen", + status: "authorized", + }); + mocks.cryptoCapturePayment.mockResolvedValue({ + id: "auth-1", + provider: "crypto", + amountMicro: 2_000_000n, + denom: "uregen", + status: "captured", + }); + mocks.cryptoRefundPayment.mockResolvedValue(undefined); + mocks.stripeAuthorizePayment.mockResolvedValue({ + id: "stripe-auth-1", + provider: "stripe", + amountMicro: 2_000_000n, + denom: "uusdc", + status: "authorized", + }); + mocks.stripeCapturePayment.mockResolvedValue({ + id: "stripe-auth-1", + provider: "stripe", + amountMicro: 2_000_000n, + denom: "uusdc", + status: "captured", + }); + mocks.stripeRefundPayment.mockResolvedValue(undefined); + + mocks.selectBestOrders.mockResolvedValue({ + orders: [ + { + sellOrderId: "123", + batchDenom: "C01-001-2026", + quantity: "1.000000", + askAmount: "2000000", + askDenom: "uregen", + costMicro: 2_000_000n, + }, + ], + totalQuantity: "1.000000", + totalCostMicro: 2_000_000n, + paymentDenom: "uregen", + displayDenom: "REGEN", + exponent: 6, + insufficientSupply: false, + }); + }); + + it("returns marketplace fallback when no wallet is configured", async () => { + mocks.isWalletConfigured.mockReturnValue(false); + + const result = await retireCredits("C01", 2, "Alice"); + const text = responseText(result); + + expect(text).toContain("## Retire Ecocredits on Regen Network"); + expect(text).toContain("**Credit class**: C01"); + expect(text).toContain("**Quantity**: 2 credits"); + expect(text).toContain("**Beneficiary name**: Alice"); + expect(text).toContain("**[app.regen.network](https://app.regen.network/projects/1?buying_options_filters=credit_card)**"); + expect(mocks.initWallet).not.toHaveBeenCalled(); + }); + + it("returns success details after an on-chain retirement", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + mocks.initWallet.mockResolvedValue({ address: "regen1buyer" }); + mocks.signAndBroadcast.mockResolvedValue({ + code: 0, + transactionHash: "ABC123", + height: 12345, + rawLog: "", + }); + mocks.waitForRetirement.mockResolvedValue({ nodeId: "WyCert123" }); + + const result = await retireCredits( + "C01", + 1, + "Alice", + "US-CA", + "Unit test retirement" + ); + const text = responseText(result); + + expect(mocks.selectBestOrders).toHaveBeenCalledWith("carbon", 1, undefined); + expect(mocks.cryptoAuthorizePayment).toHaveBeenCalledWith( + 2_000_000n, + "uregen", + { buyer: "regen1buyer", creditClass: "C01" } + ); + expect(mocks.signAndBroadcast).toHaveBeenCalledTimes(1); + expect(mocks.cryptoCapturePayment).toHaveBeenCalledWith("auth-1"); + expect(mocks.waitForRetirement).toHaveBeenCalledWith("ABC123"); + expect(mocks.cryptoRefundPayment).not.toHaveBeenCalled(); + + expect(text).toContain("## Ecocredit Retirement Successful"); + expect(text).toContain("| Cost | 2 REGEN |"); + expect(text).toContain("| Jurisdiction | US-CA |"); + expect(text).toContain("| Reason | Unit test retirement |"); + expect(text).toContain("| Transaction Hash | `ABC123` |"); + expect(text).toContain("| Certificate ID | WyCert123 |"); + expect(text).toContain("| Beneficiary Name | Alice |"); + + const messages = mocks.signAndBroadcast.mock.calls[0]?.[0]; + const retirementReason = + messages?.[0]?.value?.orders?.[0]?.retirementReason; + const parsedReason = parseAttributedReason(retirementReason); + expect(parsedReason.reasonText).toBe("Unit test retirement"); + expect(parsedReason.identity).toMatchObject({ + method: "manual", + name: "Alice", + }); + }); + + it("falls back to marketplace and refunds when tx broadcast throws", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + mocks.initWallet.mockResolvedValue({ address: "regen1buyer" }); + mocks.signAndBroadcast.mockRejectedValue(new Error("rpc unavailable")); + + const result = await retireCredits("C01", 1, "Alice"); + const text = responseText(result); + + expect(mocks.cryptoRefundPayment).toHaveBeenCalledWith("auth-1"); + expect(mocks.cryptoCapturePayment).not.toHaveBeenCalled(); + expect(mocks.waitForRetirement).not.toHaveBeenCalled(); + expect(text).toContain("Transaction broadcast failed: rpc unavailable"); + expect(text).toContain("**[app.regen.network](https://app.regen.network/projects/1?buying_options_filters=credit_card)**"); + }); + + it("falls back to marketplace and refunds when tx is rejected", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + mocks.initWallet.mockResolvedValue({ address: "regen1buyer" }); + mocks.signAndBroadcast.mockResolvedValue({ + code: 13, + transactionHash: "BADTX", + height: 9999, + rawLog: "insufficient fees", + }); + + const result = await retireCredits("C01", 1, "Alice"); + const text = responseText(result); + + expect(mocks.cryptoRefundPayment).toHaveBeenCalledWith("auth-1"); + expect(mocks.cryptoCapturePayment).not.toHaveBeenCalled(); + expect(mocks.waitForRetirement).not.toHaveBeenCalled(); + expect(text).toContain("Transaction rejected (code 13): insufficient fees"); + expect(text).toContain("## Retire Ecocredits on Regen Network"); + }); + + it("embeds OAuth/email identity attribution into the on-chain reason", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + mocks.initWallet.mockResolvedValue({ address: "regen1buyer" }); + mocks.signAndBroadcast.mockResolvedValue({ + code: 0, + transactionHash: "ABC123", + height: 12345, + rawLog: "", + }); + mocks.waitForRetirement.mockResolvedValue({ nodeId: "WyCert123" }); + + const result = await retireCredits( + "C01", + 1, + "Alice", + "US-CA", + "Unit test retirement", + "alice@example.com", + "google", + "google-oauth-sub-123" + ); + const text = responseText(result); + + const messages = mocks.signAndBroadcast.mock.calls[0]?.[0]; + const retirementReason = + messages?.[0]?.value?.orders?.[0]?.retirementReason; + const parsedReason = parseAttributedReason(retirementReason); + expect(parsedReason.reasonText).toBe("Unit test retirement"); + expect(parsedReason.identity).toMatchObject({ + method: "oauth", + name: "Alice", + email: "alice@example.com", + provider: "google", + subject: "google-oauth-sub-123", + }); + + expect(text).toContain("| Beneficiary Email | alice@example.com |"); + expect(text).toContain("| Auth Provider | google |"); + expect(text).toContain("| Auth Subject | google-oauth-sub-123 |"); + }); + + it("returns fallback instructions when identity input is invalid", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + + const result = await retireCredits( + "C01", + 1, + "Alice", + "US-CA", + "Unit test retirement", + "not-an-email" + ); + const text = responseText(result); + + expect(text).toContain("Identity capture failed: Invalid beneficiary_email format"); + expect(mocks.initWallet).not.toHaveBeenCalled(); + }); + + it("prefers USDC order routing when stripe provider is selected", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + mocks.loadConfig.mockReturnValue({ + indexerUrl: "https://api.regen.network/indexer/v1/graphql", + lcdUrl: "https://lcd-regen.keplr.app", + marketplaceUrl: "https://app.regen.network", + rpcUrl: "http://mainnet.regen.network:26657", + chainId: "regen-1", + walletMnemonic: "test mnemonic", + paymentProvider: "stripe", + defaultJurisdiction: "US", + }); + mocks.selectBestOrders.mockResolvedValue({ + orders: [ + { + sellOrderId: "123", + batchDenom: "C01-001-2026", + quantity: "1.000000", + askAmount: "2000000", + askDenom: "uusdc", + costMicro: 2_000_000n, + }, + ], + totalQuantity: "1.000000", + totalCostMicro: 2_000_000n, + paymentDenom: "uusdc", + displayDenom: "USDC", + exponent: 6, + insufficientSupply: false, + }); + mocks.initWallet.mockResolvedValue({ address: "regen1buyer" }); + mocks.signAndBroadcast.mockResolvedValue({ + code: 0, + transactionHash: "ABC123", + height: 12345, + rawLog: "", + }); + mocks.waitForRetirement.mockResolvedValue({ nodeId: "WyCert123" }); + + await retireCredits("C01", 1, "Alice"); + + expect(mocks.selectBestOrders).toHaveBeenCalledWith("carbon", 1, "USDC"); + expect(mocks.stripeAuthorizePayment).toHaveBeenCalledWith( + 2_000_000n, + "uusdc", + { buyer: "regen1buyer", creditClass: "C01" } + ); + expect(mocks.stripeCapturePayment).toHaveBeenCalledWith("stripe-auth-1"); + }); +}); diff --git a/tests/stripe-provider.test.ts b/tests/stripe-provider.test.ts new file mode 100644 index 0000000..b2b863d --- /dev/null +++ b/tests/stripe-provider.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StripePaymentProvider } from "../src/services/payment/stripe-stub.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("StripePaymentProvider", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + process.env.STRIPE_SECRET_KEY = "sk_test_123"; + process.env.STRIPE_PAYMENT_METHOD_ID = "pm_test_123"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("authorizes payment intents with manual capture", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { + id: "pi_123", + status: "requires_capture", + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = new StripePaymentProvider(); + const auth = await provider.authorizePayment(2_000_000n, "uusdc", { + buyer: "regen1buyer", + creditClass: "C01", + }); + + expect(auth).toEqual({ + id: "pi_123", + provider: "stripe", + amountMicro: 2_000_000n, + denom: "uusdc", + status: "authorized", + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.stripe.com/v1/payment_intents"); + expect(init.method).toBe("POST"); + expect(String(init.body)).toContain("amount=200"); + expect(String(init.body)).toContain("capture_method=manual"); + expect(String(init.body)).toContain("payment_method=pm_test_123"); + }); + + it("fails authorization when denom is unsupported", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const provider = new StripePaymentProvider(); + const auth = await provider.authorizePayment(2_000_000n, "uregen"); + + expect(auth.status).toBe("failed"); + expect(auth.message).toContain("supports only uusdc"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("captures payment intents and returns receipt values from metadata", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(200, { + id: "pi_123", + status: "succeeded", + metadata: { + regen_amount_micro: "2500000", + regen_denom: "uusdc", + }, + }) + ) + ); + + const provider = new StripePaymentProvider(); + const receipt = await provider.capturePayment("pi_123"); + + expect(receipt).toEqual({ + id: "pi_123", + provider: "stripe", + amountMicro: 2_500_000n, + denom: "uusdc", + status: "captured", + }); + }); + + it("cancels authorization holds on refundPayment", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { + id: "pi_123", + status: "canceled", + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = new StripePaymentProvider(); + await provider.refundPayment("pi_123"); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.stripe.com/v1/payment_intents/pi_123/cancel"); + }); +}); diff --git a/tests/subscription-service.test.ts b/tests/subscription-service.test.ts new file mode 100644 index 0000000..456834c --- /dev/null +++ b/tests/subscription-service.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StripeSubscriptionService } from "../src/services/subscription/stripe.js"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("StripeSubscriptionService", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + process.env.STRIPE_SECRET_KEY = "sk_test_123"; + process.env.STRIPE_PAYMENT_METHOD_ID = "pm_env_default"; + process.env.STRIPE_PRICE_ID_STARTER = "price_starter"; + process.env.STRIPE_PRICE_ID_GROWTH = "price_growth"; + process.env.STRIPE_PRICE_ID_IMPACT = "price_impact"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("creates a customer subscription for the selected tier", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(200, { data: [] })) + .mockResolvedValueOnce( + jsonResponse(200, { + id: "cus_123", + email: "alice@example.com", + name: "Alice", + }) + ) + .mockResolvedValueOnce(jsonResponse(200, { id: "pm_123" })) + .mockResolvedValueOnce( + jsonResponse(200, { id: "cus_123", email: "alice@example.com" }) + ) + .mockResolvedValueOnce(jsonResponse(200, { data: [] })) + .mockResolvedValueOnce( + jsonResponse(200, { + id: "sub_123", + status: "active", + customer: "cus_123", + cancel_at_period_end: false, + current_period_end: 1767225600, + items: { + data: [ + { + id: "si_123", + price: { id: "price_growth" }, + }, + ], + }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new StripeSubscriptionService(); + const state = await service.ensureSubscription("growth", { + email: "alice@example.com", + fullName: "Alice", + paymentMethodId: "pm_123", + }); + + expect(state).toMatchObject({ + customerId: "cus_123", + email: "alice@example.com", + subscriptionId: "sub_123", + status: "active", + tierId: "growth", + priceId: "price_growth", + cancelAtPeriodEnd: false, + }); + expect(fetchMock).toHaveBeenCalledTimes(6); + + const [searchUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(searchUrl).toContain("/customers?email=alice%40example.com&limit=1"); + + const [, createSubscriptionInit] = fetchMock.mock.calls[5] as [ + string, + RequestInit, + ]; + expect(String(createSubscriptionInit.body)).toContain("price_growth"); + }); + + it("returns status=none when no customer exists for the lookup email", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(200, { data: [] }))); + + const service = new StripeSubscriptionService(); + const state = await service.getSubscriptionState({ + email: "missing@example.com", + }); + + expect(state).toEqual({ + email: "missing@example.com", + status: "none", + cancelAtPeriodEnd: false, + }); + }); + + it("cancels active subscriptions at period end", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse(200, { + data: [{ id: "cus_123", email: "alice@example.com" }], + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + data: [ + { + id: "sub_123", + status: "active", + customer: "cus_123", + cancel_at_period_end: false, + current_period_end: 1767225600, + items: { data: [{ id: "si_123", price: { id: "price_starter" } }] }, + }, + ], + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + id: "sub_123", + status: "active", + customer: "cus_123", + cancel_at_period_end: true, + current_period_end: 1767225600, + items: { data: [{ id: "si_123", price: { id: "price_starter" } }] }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new StripeSubscriptionService(); + const state = await service.cancelSubscription({ email: "alice@example.com" }); + + expect(state).toMatchObject({ + customerId: "cus_123", + status: "active", + tierId: "starter", + cancelAtPeriodEnd: true, + }); + + const [cancelUrl, cancelInit] = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(cancelUrl).toContain("/subscriptions/sub_123"); + expect(String(cancelInit.body)).toContain("cancel_at_period_end=true"); + }); +}); From 4c01fcc4a173141b0f8d5ae0e4dc6cc685ab5466 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:23:30 -0800 Subject: [PATCH 07/31] feat: add protocol fee calculation to monthly batch retirements --- .env.example | 4 +++ README.md | 5 ++- src/config.ts | 23 ++++++++++++ src/services/batch-retirement/executor.ts | 39 ++++++++++++++++++-- src/services/batch-retirement/fee.ts | 37 +++++++++++++++++++ src/services/batch-retirement/types.ts | 11 ++++++ src/tools/monthly-batch-retirement.ts | 12 ++++++- tests/monthly-batch-executor.test.ts | 14 ++++++-- tests/protocol-fee.test.ts | 44 +++++++++++++++++++++++ 9 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 src/services/batch-retirement/fee.ts create mode 100644 tests/protocol-fee.test.ts diff --git a/.env.example b/.env.example index b79664e..eb2e6c6 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ REGEN_PAYMENT_PROVIDER=crypto # Default retirement jurisdiction (ISO 3166-1 alpha-2, e.g., US, DE, BR) REGEN_DEFAULT_JURISDICTION=US +# Optional: protocol fee basis points for pooled monthly retirement budgets. +# Range: 800-1200 (8%-12%), default: 1000 (10%). +# REGEN_PROTOCOL_FEE_BPS=1000 + # Authentication (OAuth - for user identity on retirement certificates) # OAUTH_CLIENT_ID= # OAUTH_CLIENT_SECRET= diff --git a/README.md b/README.md index fa3f676..b346cd5 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,8 @@ Executes the monthly pooled retirement batch from the contribution ledger. Supports: - `dry_run=true` planning (default, no on-chain transaction), - real execution with `dry_run=false`, -- duplicate-run protection per month (override with `force=true`). +- duplicate-run protection per month (override with `force=true`), +- protocol fee allocation (configurable `8%-12%`, default `10%`) with gross/fee/net budget reporting. **When it's used:** Running the monthly pooled buy-and-retire process from aggregated subscription funds. @@ -344,6 +345,8 @@ export STRIPE_PRICE_ID_IMPACT=price_... export REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json # optional custom history path for monthly batch executions export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json +# optional protocol fee basis points for monthly pool budgets (800-1200, default 1000) +export REGEN_PROTOCOL_FEE_BPS=1000 ``` Note: Stripe mode currently requires USDC-denominated sell orders (`uusdc`) so fiat charges map cleanly to on-chain pricing. diff --git a/src/config.ts b/src/config.ts index 460186f..7c275f6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ export interface Config { walletMnemonic: string | undefined; paymentProvider: "crypto" | "stripe"; defaultJurisdiction: string; + protocolFeeBps: number; // ecoBridge integration (Phase 1.5) ecoBridgeApiUrl: string; @@ -31,6 +32,27 @@ export interface Config { let _config: Config | undefined; +const DEFAULT_PROTOCOL_FEE_BPS = 1000; +const MIN_PROTOCOL_FEE_BPS = 800; +const MAX_PROTOCOL_FEE_BPS = 1200; + +function parseProtocolFeeBps(rawValue: string | undefined): number { + if (!rawValue) return DEFAULT_PROTOCOL_FEE_BPS; + + const parsed = Number(rawValue); + if ( + !Number.isInteger(parsed) || + parsed < MIN_PROTOCOL_FEE_BPS || + parsed > MAX_PROTOCOL_FEE_BPS + ) { + throw new Error( + `REGEN_PROTOCOL_FEE_BPS must be an integer between ${MIN_PROTOCOL_FEE_BPS} and ${MAX_PROTOCOL_FEE_BPS}` + ); + } + + return parsed; +} + export function loadConfig(): Config { if (_config) return _config; @@ -49,6 +71,7 @@ export function loadConfig(): Config { paymentProvider: (process.env.REGEN_PAYMENT_PROVIDER as "crypto" | "stripe") || "crypto", defaultJurisdiction: process.env.REGEN_DEFAULT_JURISDICTION || "US", + protocolFeeBps: parseProtocolFeeBps(process.env.REGEN_PROTOCOL_FEE_BPS), ecoBridgeApiUrl: process.env.ECOBRIDGE_API_URL || "https://api.bridge.eco", diff --git a/src/services/batch-retirement/executor.ts b/src/services/batch-retirement/executor.ts index 8387a11..2f4323a 100644 --- a/src/services/batch-retirement/executor.ts +++ b/src/services/batch-retirement/executor.ts @@ -4,6 +4,7 @@ import { waitForRetirement } from "../indexer.js"; import { signAndBroadcast, initWallet } from "../wallet.js"; import { PoolAccountingService } from "../pool-accounting/service.js"; import { buildContributorAttributions } from "./attribution.js"; +import { calculateProtocolFee } from "./fee.js"; import { selectOrdersForBudget } from "./planner.js"; import { JsonFileBatchExecutionStore } from "./store.js"; import type { @@ -50,6 +51,7 @@ function buildExecutionRecord( reason: string; budgetUsdCents: number; selection: BudgetOrderSelection; + protocolFee?: BatchExecutionRecord["protocolFee"]; attributions?: BatchExecutionRecord["attributions"]; txHash?: string; blockHeight?: number; @@ -69,6 +71,7 @@ function buildExecutionRecord( spentMicro: input.selection.totalCostMicro.toString(), spentDenom: input.selection.paymentDenom, retiredQuantity: input.selection.totalQuantity, + protocolFee: input.protocolFee, attributions: input.attributions, txHash: input.txHash, blockHeight: input.blockHeight, @@ -159,8 +162,29 @@ export class MonthlyBatchRetirementExecutor { ? Math.min(monthlySummary.totalUsdCents, usdToCents(input.maxBudgetUsd)) : monthlySummary.totalUsdCents; + const config = this.deps.loadConfig(); const paymentDenom = input.paymentDenom || "USDC"; - const budgetMicro = toBudgetMicro(paymentDenom, totalBudgetUsdCents); + const protocolFee = calculateProtocolFee({ + grossBudgetUsdCents: totalBudgetUsdCents, + protocolFeeBps: config.protocolFeeBps, + paymentDenom, + }); + const budgetMicro = toBudgetMicro(paymentDenom, protocolFee.creditBudgetUsdCents); + + if (protocolFee.creditBudgetUsdCents <= 0) { + return { + status: "no_orders", + month: input.month, + creditType: input.creditType, + budgetUsdCents: totalBudgetUsdCents, + plannedQuantity: "0.000000", + plannedCostMicro: 0n, + plannedCostDenom: paymentDenom, + protocolFee, + message: + "No credit purchase budget remains after applying protocol fee to this monthly pool.", + }; + } const selection = await this.deps.selectOrdersForBudget( input.creditType, @@ -177,6 +201,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + protocolFee, message: "No eligible sell orders were found for the configured budget and filters.", }; @@ -185,13 +210,12 @@ export class MonthlyBatchRetirementExecutor { const attributions = buildContributorAttributions({ contributors: monthlySummary.contributors, totalContributionUsdCents: monthlySummary.totalUsdCents, - appliedBudgetUsdCents: totalBudgetUsdCents, + appliedBudgetUsdCents: protocolFee.creditBudgetUsdCents, totalCostMicro: selection.totalCostMicro, retiredQuantity: selection.totalQuantity, paymentDenom: selection.paymentDenom, }); - const config = this.deps.loadConfig(); const retireJurisdiction = input.jurisdiction || config.defaultJurisdiction; const retireReason = input.reason || `Monthly subscription pool retirement (${input.month})`; @@ -203,6 +227,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + protocolFee, attributions, dryRun: true, }); @@ -215,6 +240,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + protocolFee, attributions, message: "Dry run complete. No on-chain transaction was broadcast.", executionRecord: record, @@ -230,6 +256,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + protocolFee, attributions, message: "Wallet is not configured. Set REGEN_WALLET_MNEMONIC before executing monthly batch retirements.", @@ -267,6 +294,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + protocolFee, attributions, error: `Transaction rejected (code ${txResult.code}): ${ txResult.rawLog || "unknown error" @@ -283,6 +311,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + protocolFee, attributions, message: record.error || "Monthly batch transaction failed.", executionRecord: record, @@ -296,6 +325,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + protocolFee, attributions, txHash: txResult.transactionHash, blockHeight: txResult.height, @@ -312,6 +342,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + protocolFee, attributions, txHash: txResult.transactionHash, blockHeight: txResult.height, @@ -328,6 +359,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + protocolFee, attributions, error: errMsg, dryRun: false, @@ -342,6 +374,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + protocolFee, attributions, message: errMsg, executionRecord: record, diff --git a/src/services/batch-retirement/fee.ts b/src/services/batch-retirement/fee.ts new file mode 100644 index 0000000..0eb7b6f --- /dev/null +++ b/src/services/batch-retirement/fee.ts @@ -0,0 +1,37 @@ +import type { ProtocolFeeBreakdown } from "./types.js"; + +const BPS_DENOMINATOR = 10_000; +const USD_CENT_TO_MICRO = 10_000n; + +export function calculateProtocolFee(input: { + grossBudgetUsdCents: number; + protocolFeeBps: number; + paymentDenom: "USDC" | "uusdc"; +}): ProtocolFeeBreakdown { + const { grossBudgetUsdCents, protocolFeeBps, paymentDenom } = input; + + if (!Number.isInteger(grossBudgetUsdCents) || grossBudgetUsdCents < 0) { + throw new Error("grossBudgetUsdCents must be a non-negative integer"); + } + if ( + !Number.isInteger(protocolFeeBps) || + protocolFeeBps < 0 || + protocolFeeBps > BPS_DENOMINATOR + ) { + throw new Error("protocolFeeBps must be an integer between 0 and 10000"); + } + + const protocolFeeUsdCents = Math.floor( + (grossBudgetUsdCents * protocolFeeBps) / BPS_DENOMINATOR + ); + const creditBudgetUsdCents = Math.max(grossBudgetUsdCents - protocolFeeUsdCents, 0); + + return { + protocolFeeBps, + grossBudgetUsdCents, + protocolFeeUsdCents, + protocolFeeMicro: (BigInt(protocolFeeUsdCents) * USD_CENT_TO_MICRO).toString(), + protocolFeeDenom: paymentDenom, + creditBudgetUsdCents, + }; +} diff --git a/src/services/batch-retirement/types.ts b/src/services/batch-retirement/types.ts index 0a2803a..93f0413 100644 --- a/src/services/batch-retirement/types.ts +++ b/src/services/batch-retirement/types.ts @@ -30,6 +30,15 @@ export interface ContributorAttribution { paymentDenom: string; } +export interface ProtocolFeeBreakdown { + protocolFeeBps: number; + grossBudgetUsdCents: number; + protocolFeeUsdCents: number; + protocolFeeMicro: string; + protocolFeeDenom: "USDC" | "uusdc"; + creditBudgetUsdCents: number; +} + export type BatchExecutionStatus = | "success" | "failed" @@ -46,6 +55,7 @@ export interface BatchExecutionRecord { spentMicro: string; spentDenom: string; retiredQuantity: string; + protocolFee?: ProtocolFeeBreakdown; attributions?: ContributorAttribution[]; txHash?: string; blockHeight?: number; @@ -90,6 +100,7 @@ export interface RunMonthlyBatchResult { plannedQuantity: string; plannedCostMicro: bigint; plannedCostDenom: string; + protocolFee?: ProtocolFeeBreakdown; attributions?: ContributorAttribution[]; txHash?: string; blockHeight?: number; diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 7ecbee0..2d15097 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -47,11 +47,21 @@ export async function runMonthlyBatchRetirementTool( `| Status | ${result.status} |`, `| Month | ${result.month} |`, `| Credit Type | ${result.creditType || "all"} |`, - `| Budget | ${formatUsd(result.budgetUsdCents)} |`, + `| Gross Budget | ${formatUsd(result.budgetUsdCents)} |`, `| Planned Quantity | ${result.plannedQuantity} |`, `| Planned Cost | ${formatMicroAmount(result.plannedCostMicro, result.plannedCostDenom, denomExponent(result.plannedCostDenom))} |`, ]; + if (result.protocolFee) { + const pct = (result.protocolFee.protocolFeeBps / 100).toFixed(2); + lines.splice( + 7, + 0, + `| Protocol Fee | ${formatUsd(result.protocolFee.protocolFeeUsdCents)} (${pct}%) |`, + `| Credit Purchase Budget | ${formatUsd(result.protocolFee.creditBudgetUsdCents)} |` + ); + } + if (result.txHash) { lines.push(`| Transaction Hash | \`${result.txHash}\` |`); } diff --git a/tests/monthly-batch-executor.test.ts b/tests/monthly-batch-executor.test.ts index 288cc22..b558552 100644 --- a/tests/monthly-batch-executor.test.ts +++ b/tests/monthly-batch-executor.test.ts @@ -86,6 +86,7 @@ describe("MonthlyBatchRetirementExecutor", () => { loadConfig: () => ({ defaultJurisdiction: "US", + protocolFeeBps: 1000, }) as any, }); } @@ -115,20 +116,28 @@ describe("MonthlyBatchRetirementExecutor", () => { expect(result.status).toBe("dry_run"); expect(selectOrdersForBudget).toHaveBeenCalledWith( undefined, - 3_000_000n, + 2_700_000n, "USDC" ); expect(signAndBroadcast).not.toHaveBeenCalled(); + expect(result.protocolFee).toMatchObject({ + protocolFeeBps: 1000, + grossBudgetUsdCents: 300, + protocolFeeUsdCents: 30, + creditBudgetUsdCents: 270, + protocolFeeDenom: "USDC", + }); expect(result.attributions).toHaveLength(1); expect(result.attributions?.[0]).toMatchObject({ userId: "user-a", - attributedBudgetUsdCents: 300, + attributedBudgetUsdCents: 270, attributedQuantity: "1.250000", }); const state = await store.readState(); expect(state.executions).toHaveLength(1); expect(state.executions[0]?.status).toBe("dry_run"); + expect(state.executions[0]?.protocolFee?.protocolFeeUsdCents).toBe(30); expect(state.executions[0]?.attributions?.[0]?.userId).toBe("user-a"); }); @@ -147,6 +156,7 @@ describe("MonthlyBatchRetirementExecutor", () => { expect(waitForRetirement).toHaveBeenCalledWith("TX123"); expect(result.txHash).toBe("TX123"); expect(result.retirementId).toBe("WyRet123"); + expect(result.protocolFee?.protocolFeeUsdCents).toBe(30); expect(result.attributions).toHaveLength(1); const state = await store.readState(); diff --git a/tests/protocol-fee.test.ts b/tests/protocol-fee.test.ts new file mode 100644 index 0000000..7c7fcd9 --- /dev/null +++ b/tests/protocol-fee.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { calculateProtocolFee } from "../src/services/batch-retirement/fee.js"; + +describe("calculateProtocolFee", () => { + it("computes fee and net credit budget in cents and micro units", () => { + const breakdown = calculateProtocolFee({ + grossBudgetUsdCents: 300, + protocolFeeBps: 1000, + paymentDenom: "USDC", + }); + + expect(breakdown).toEqual({ + protocolFeeBps: 1000, + grossBudgetUsdCents: 300, + protocolFeeUsdCents: 30, + protocolFeeMicro: "300000", + protocolFeeDenom: "USDC", + creditBudgetUsdCents: 270, + }); + }); + + it("uses floor rounding for fractional-cent fee results", () => { + const breakdown = calculateProtocolFee({ + grossBudgetUsdCents: 101, + protocolFeeBps: 1200, + paymentDenom: "uusdc", + }); + + expect(breakdown.protocolFeeUsdCents).toBe(12); + expect(breakdown.creditBudgetUsdCents).toBe(89); + expect(breakdown.protocolFeeMicro).toBe("120000"); + expect(breakdown.protocolFeeDenom).toBe("uusdc"); + }); + + it("rejects invalid protocol fee bps values", () => { + expect(() => + calculateProtocolFee({ + grossBudgetUsdCents: 100, + protocolFeeBps: 10001, + paymentDenom: "USDC", + }) + ).toThrow("protocolFeeBps must be an integer between 0 and 10000"); + }); +}); From 48feaf2b373b995ce1745ed7660fab581547295a Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:29:11 -0800 Subject: [PATCH 08/31] feat: add REGEN acquisition adapter for protocol fee flow --- .env.example | 9 ++ README.md | 7 +- src/config.ts | 41 ++++++++ src/services/batch-retirement/executor.ts | 87 +++++++++++++++- src/services/batch-retirement/types.ts | 19 ++++ src/services/regen-acquisition/provider.ts | 110 +++++++++++++++++++++ src/tools/monthly-batch-retirement.ts | 27 +++++ tests/monthly-batch-executor.test.ts | 40 ++++++++ tests/regen-acquisition-provider.test.ts | 46 +++++++++ 9 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 src/services/regen-acquisition/provider.ts create mode 100644 tests/regen-acquisition-provider.test.ts diff --git a/.env.example b/.env.example index eb2e6c6..56211a8 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,15 @@ REGEN_DEFAULT_JURISDICTION=US # Range: 800-1200 (8%-12%), default: 1000 (10%). # REGEN_PROTOCOL_FEE_BPS=1000 +# Optional: protocol fee REGEN acquisition provider. +# - disabled (default): no REGEN acquisition step is executed +# - simulated: uses fixed rate conversion for planning/testing flows +# REGEN_ACQUISITION_PROVIDER=disabled + +# Optional: simulated provider conversion rate in micro-REGEN per 1 USDC. +# default: 2000000 (2.0 REGEN per 1 USDC) +# REGEN_ACQUISITION_RATE_UREGEN_PER_USDC=2000000 + # Authentication (OAuth - for user identity on retirement certificates) # OAUTH_CLIENT_ID= # OAUTH_CLIENT_SECRET= diff --git a/README.md b/README.md index b346cd5..8408051 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,8 @@ Supports: - `dry_run=true` planning (default, no on-chain transaction), - real execution with `dry_run=false`, - duplicate-run protection per month (override with `force=true`), -- protocol fee allocation (configurable `8%-12%`, default `10%`) with gross/fee/net budget reporting. +- protocol fee allocation (configurable `8%-12%`, default `10%`) with gross/fee/net budget reporting, +- protocol fee REGEN acquisition adapter output (planned/executed/skipped). **When it's used:** Running the monthly pooled buy-and-retire process from aggregated subscription funds. @@ -347,6 +348,10 @@ export REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json # optional protocol fee basis points for monthly pool budgets (800-1200, default 1000) export REGEN_PROTOCOL_FEE_BPS=1000 +# optional protocol fee REGEN acquisition provider: disabled | simulated +export REGEN_ACQUISITION_PROVIDER=disabled +# optional simulated acquisition rate (micro-REGEN per 1 USDC, default 2000000) +export REGEN_ACQUISITION_RATE_UREGEN_PER_USDC=2000000 ``` Note: Stripe mode currently requires USDC-denominated sell orders (`uusdc`) so fiat charges map cleanly to on-chain pricing. diff --git a/src/config.ts b/src/config.ts index 7c275f6..8bcc306 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,8 @@ export interface Config { // ecoBridge EVM wallet (for sending tokens on Base/Ethereum/etc.) ecoBridgeEvmMnemonic: string | undefined; ecoBridgeEvmDerivationPath: string; + regenAcquisitionProvider: "disabled" | "simulated"; + regenAcquisitionRateUregenPerUsdc: number; } let _config: Config | undefined; @@ -35,6 +37,8 @@ let _config: Config | undefined; const DEFAULT_PROTOCOL_FEE_BPS = 1000; const MIN_PROTOCOL_FEE_BPS = 800; const MAX_PROTOCOL_FEE_BPS = 1200; +const DEFAULT_REGEN_ACQUISITION_PROVIDER = "disabled" as const; +const DEFAULT_REGEN_ACQUISITION_RATE_UREGEN_PER_USDC = 2_000_000; function parseProtocolFeeBps(rawValue: string | undefined): number { if (!rawValue) return DEFAULT_PROTOCOL_FEE_BPS; @@ -53,6 +57,35 @@ function parseProtocolFeeBps(rawValue: string | undefined): number { return parsed; } +function parseRegenAcquisitionProvider( + rawValue: string | undefined +): "disabled" | "simulated" { + if (!rawValue) return DEFAULT_REGEN_ACQUISITION_PROVIDER; + + const normalized = rawValue.trim().toLowerCase(); + if (normalized === "disabled") return "disabled"; + if (normalized === "simulated") return "simulated"; + + throw new Error( + "REGEN_ACQUISITION_PROVIDER must be one of: disabled, simulated" + ); +} + +function parsePositiveInteger( + rawValue: string | undefined, + envName: string, + defaultValue: number +): number { + if (!rawValue) return defaultValue; + + const parsed = Number(rawValue); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${envName} must be a positive integer`); + } + + return parsed; +} + export function loadConfig(): Config { if (_config) return _config; @@ -84,6 +117,14 @@ export function loadConfig(): Config { ecoBridgeEvmMnemonic: process.env.ECOBRIDGE_EVM_MNEMONIC || undefined, ecoBridgeEvmDerivationPath: process.env.ECOBRIDGE_EVM_DERIVATION_PATH || "m/44'/60'/0'/0/0", + regenAcquisitionProvider: parseRegenAcquisitionProvider( + process.env.REGEN_ACQUISITION_PROVIDER + ), + regenAcquisitionRateUregenPerUsdc: parsePositiveInteger( + process.env.REGEN_ACQUISITION_RATE_UREGEN_PER_USDC, + "REGEN_ACQUISITION_RATE_UREGEN_PER_USDC", + DEFAULT_REGEN_ACQUISITION_RATE_UREGEN_PER_USDC + ), }; return _config; diff --git a/src/services/batch-retirement/executor.ts b/src/services/batch-retirement/executor.ts index 2f4323a..bd77a39 100644 --- a/src/services/batch-retirement/executor.ts +++ b/src/services/batch-retirement/executor.ts @@ -6,6 +6,10 @@ import { PoolAccountingService } from "../pool-accounting/service.js"; import { buildContributorAttributions } from "./attribution.js"; import { calculateProtocolFee } from "./fee.js"; import { selectOrdersForBudget } from "./planner.js"; +import { + createRegenAcquisitionProvider, + type RegenAcquisitionProvider, +} from "../regen-acquisition/provider.js"; import { JsonFileBatchExecutionStore } from "./store.js"; import type { BatchExecutionRecord, @@ -26,6 +30,7 @@ export interface MonthlyBatchExecutorDeps { signAndBroadcast: typeof signAndBroadcast; waitForRetirement: typeof waitForRetirement; loadConfig: typeof loadConfig; + regenAcquisitionProvider: RegenAcquisitionProvider; } function usdToCents(value: number): number { @@ -52,6 +57,7 @@ function buildExecutionRecord( budgetUsdCents: number; selection: BudgetOrderSelection; protocolFee?: BatchExecutionRecord["protocolFee"]; + regenAcquisition?: BatchExecutionRecord["regenAcquisition"]; attributions?: BatchExecutionRecord["attributions"]; txHash?: string; blockHeight?: number; @@ -72,6 +78,7 @@ function buildExecutionRecord( spentDenom: input.selection.paymentDenom, retiredQuantity: input.selection.totalQuantity, protocolFee: input.protocolFee, + regenAcquisition: input.regenAcquisition, attributions: input.attributions, txHash: input.txHash, blockHeight: input.blockHeight, @@ -85,6 +92,9 @@ export class MonthlyBatchRetirementExecutor { private readonly deps: MonthlyBatchExecutorDeps; constructor(deps?: Partial) { + const loadConfigDep = deps?.loadConfig || loadConfig; + const configForDeps = loadConfigDep(); + this.deps = { poolAccounting: deps?.poolAccounting || new PoolAccountingService(), executionStore: deps?.executionStore || new JsonFileBatchExecutionStore(), @@ -93,7 +103,14 @@ export class MonthlyBatchRetirementExecutor { initWallet: deps?.initWallet || initWallet, signAndBroadcast: deps?.signAndBroadcast || signAndBroadcast, waitForRetirement: deps?.waitForRetirement || waitForRetirement, - loadConfig: deps?.loadConfig || loadConfig, + loadConfig: loadConfigDep, + regenAcquisitionProvider: + deps?.regenAcquisitionProvider || + createRegenAcquisitionProvider({ + provider: configForDeps.regenAcquisitionProvider, + simulatedRateUregenPerUsdc: + configForDeps.regenAcquisitionRateUregenPerUsdc, + }), }; } @@ -163,12 +180,24 @@ export class MonthlyBatchRetirementExecutor { : monthlySummary.totalUsdCents; const config = this.deps.loadConfig(); + const retireReason = + input.reason || `Monthly subscription pool retirement (${input.month})`; const paymentDenom = input.paymentDenom || "USDC"; const protocolFee = calculateProtocolFee({ grossBudgetUsdCents: totalBudgetUsdCents, protocolFeeBps: config.protocolFeeBps, paymentDenom, }); + + const plannedRegenAcquisition = + protocolFee.protocolFeeUsdCents > 0 + ? await this.deps.regenAcquisitionProvider.planAcquisition({ + month: input.month, + spendMicro: BigInt(protocolFee.protocolFeeMicro), + spendDenom: protocolFee.protocolFeeDenom, + }) + : undefined; + const budgetMicro = toBudgetMicro(paymentDenom, protocolFee.creditBudgetUsdCents); if (protocolFee.creditBudgetUsdCents <= 0) { @@ -181,6 +210,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostMicro: 0n, plannedCostDenom: paymentDenom, protocolFee, + regenAcquisition: plannedRegenAcquisition, message: "No credit purchase budget remains after applying protocol fee to this monthly pool.", }; @@ -202,6 +232,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, protocolFee, + regenAcquisition: plannedRegenAcquisition, message: "No eligible sell orders were found for the configured budget and filters.", }; @@ -217,8 +248,6 @@ export class MonthlyBatchRetirementExecutor { }); const retireJurisdiction = input.jurisdiction || config.defaultJurisdiction; - const retireReason = - input.reason || `Monthly subscription pool retirement (${input.month})`; if (input.dryRun !== false) { const record = buildExecutionRecord("dry_run", { @@ -228,6 +257,7 @@ export class MonthlyBatchRetirementExecutor { budgetUsdCents: totalBudgetUsdCents, selection, protocolFee, + regenAcquisition: plannedRegenAcquisition, attributions, dryRun: true, }); @@ -241,6 +271,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, protocolFee, + regenAcquisition: plannedRegenAcquisition, attributions, message: "Dry run complete. No on-chain transaction was broadcast.", executionRecord: record, @@ -257,6 +288,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, protocolFee, + regenAcquisition: plannedRegenAcquisition, attributions, message: "Wallet is not configured. Set REGEN_WALLET_MNEMONIC before executing monthly batch retirements.", @@ -295,6 +327,14 @@ export class MonthlyBatchRetirementExecutor { budgetUsdCents: totalBudgetUsdCents, selection, protocolFee, + regenAcquisition: plannedRegenAcquisition + ? { + ...plannedRegenAcquisition, + status: "skipped", + message: + "Skipped REGEN acquisition because the retirement transaction was rejected.", + } + : undefined, attributions, error: `Transaction rejected (code ${txResult.code}): ${ txResult.rawLog || "unknown error" @@ -312,6 +352,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, protocolFee, + regenAcquisition: record.regenAcquisition, attributions, message: record.error || "Monthly batch transaction failed.", executionRecord: record, @@ -319,6 +360,30 @@ export class MonthlyBatchRetirementExecutor { } const retirement = await this.deps.waitForRetirement(txResult.transactionHash); + + let regenAcquisition = plannedRegenAcquisition; + if (plannedRegenAcquisition && plannedRegenAcquisition.status !== "skipped") { + try { + regenAcquisition = await this.deps.regenAcquisitionProvider.executeAcquisition( + { + month: input.month, + spendMicro: BigInt(protocolFee.protocolFeeMicro), + spendDenom: protocolFee.protocolFeeDenom, + } + ); + } catch (error) { + const errMsg = + error instanceof Error + ? error.message + : "Unknown REGEN acquisition error"; + regenAcquisition = { + ...plannedRegenAcquisition, + status: "failed", + message: `REGEN acquisition failed: ${errMsg}`, + }; + } + } + const record = buildExecutionRecord("success", { month: input.month, creditType: input.creditType, @@ -326,6 +391,7 @@ export class MonthlyBatchRetirementExecutor { budgetUsdCents: totalBudgetUsdCents, selection, protocolFee, + regenAcquisition, attributions, txHash: txResult.transactionHash, blockHeight: txResult.height, @@ -343,11 +409,15 @@ export class MonthlyBatchRetirementExecutor { plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, protocolFee, + regenAcquisition, attributions, txHash: txResult.transactionHash, blockHeight: txResult.height, retirementId: retirement?.nodeId, - message: "Monthly batch retirement completed successfully.", + message: + regenAcquisition?.status === "failed" + ? "Monthly batch retirement completed, but REGEN acquisition failed." + : "Monthly batch retirement completed successfully.", executionRecord: record, }; } catch (error) { @@ -360,6 +430,14 @@ export class MonthlyBatchRetirementExecutor { budgetUsdCents: totalBudgetUsdCents, selection, protocolFee, + regenAcquisition: plannedRegenAcquisition + ? { + ...plannedRegenAcquisition, + status: "skipped", + message: + "Skipped REGEN acquisition because monthly retirement execution failed.", + } + : undefined, attributions, error: errMsg, dryRun: false, @@ -375,6 +453,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, protocolFee, + regenAcquisition: record.regenAcquisition, attributions, message: errMsg, executionRecord: record, diff --git a/src/services/batch-retirement/types.ts b/src/services/batch-retirement/types.ts index 93f0413..4331540 100644 --- a/src/services/batch-retirement/types.ts +++ b/src/services/batch-retirement/types.ts @@ -39,6 +39,23 @@ export interface ProtocolFeeBreakdown { creditBudgetUsdCents: number; } +export type RegenAcquisitionStatus = + | "planned" + | "executed" + | "skipped" + | "failed"; + +export interface RegenAcquisitionRecord { + provider: string; + status: RegenAcquisitionStatus; + spendMicro: string; + spendDenom: "USDC" | "uusdc"; + estimatedRegenMicro: string; + acquiredRegenMicro?: string; + txHash?: string; + message: string; +} + export type BatchExecutionStatus = | "success" | "failed" @@ -56,6 +73,7 @@ export interface BatchExecutionRecord { spentDenom: string; retiredQuantity: string; protocolFee?: ProtocolFeeBreakdown; + regenAcquisition?: RegenAcquisitionRecord; attributions?: ContributorAttribution[]; txHash?: string; blockHeight?: number; @@ -101,6 +119,7 @@ export interface RunMonthlyBatchResult { plannedCostMicro: bigint; plannedCostDenom: string; protocolFee?: ProtocolFeeBreakdown; + regenAcquisition?: RegenAcquisitionRecord; attributions?: ContributorAttribution[]; txHash?: string; blockHeight?: number; diff --git a/src/services/regen-acquisition/provider.ts b/src/services/regen-acquisition/provider.ts new file mode 100644 index 0000000..652f8a4 --- /dev/null +++ b/src/services/regen-acquisition/provider.ts @@ -0,0 +1,110 @@ +import { randomUUID } from "node:crypto"; +import type { RegenAcquisitionRecord } from "../batch-retirement/types.js"; + +const MICRO_FACTOR = 1_000_000n; + +export interface RegenAcquisitionInput { + month: string; + spendMicro: bigint; + spendDenom: "USDC" | "uusdc"; +} + +export interface RegenAcquisitionProvider { + name: string; + planAcquisition(input: RegenAcquisitionInput): Promise; + executeAcquisition(input: RegenAcquisitionInput): Promise; +} + +export interface RegenAcquisitionProviderConfig { + provider: "disabled" | "simulated"; + simulatedRateUregenPerUsdc: number; +} + +function normalizeSpendDenom(denom: "USDC" | "uusdc"): "USDC" | "uusdc" { + return denom.toLowerCase() === "uusdc" ? "uusdc" : "USDC"; +} + +function toRegenMicro(spendMicro: bigint, rateUregenPerUsdc: bigint): bigint { + return (spendMicro * rateUregenPerUsdc) / MICRO_FACTOR; +} + +class DisabledRegenAcquisitionProvider implements RegenAcquisitionProvider { + name = "disabled"; + + async planAcquisition(input: RegenAcquisitionInput): Promise { + return { + provider: this.name, + status: "skipped", + spendMicro: input.spendMicro.toString(), + spendDenom: normalizeSpendDenom(input.spendDenom), + estimatedRegenMicro: "0", + message: + "REGEN acquisition provider is disabled. Set REGEN_ACQUISITION_PROVIDER=simulated (or a future live provider) to enable protocol fee swaps.", + }; + } + + async executeAcquisition(input: RegenAcquisitionInput): Promise { + return this.planAcquisition(input); + } +} + +class SimulatedRegenAcquisitionProvider implements RegenAcquisitionProvider { + name = "simulated"; + private readonly rateUregenPerUsdc: bigint; + + constructor(rateUregenPerUsdc: number) { + if (!Number.isInteger(rateUregenPerUsdc) || rateUregenPerUsdc <= 0) { + throw new Error("simulatedRateUregenPerUsdc must be a positive integer"); + } + this.rateUregenPerUsdc = BigInt(rateUregenPerUsdc); + } + + async planAcquisition(input: RegenAcquisitionInput): Promise { + const spendDenom = normalizeSpendDenom(input.spendDenom); + if (input.spendMicro <= 0n) { + return { + provider: this.name, + status: "skipped", + spendMicro: "0", + spendDenom, + estimatedRegenMicro: "0", + message: "Protocol fee spend is zero; no REGEN acquisition planned.", + }; + } + + const estimatedRegenMicro = toRegenMicro(input.spendMicro, this.rateUregenPerUsdc); + return { + provider: this.name, + status: "planned", + spendMicro: input.spendMicro.toString(), + spendDenom, + estimatedRegenMicro: estimatedRegenMicro.toString(), + message: `Planned simulated DEX acquisition for ${input.month}.`, + }; + } + + async executeAcquisition(input: RegenAcquisitionInput): Promise { + const planned = await this.planAcquisition(input); + if (planned.status === "skipped") return planned; + + return { + provider: this.name, + status: "executed", + spendMicro: planned.spendMicro, + spendDenom: planned.spendDenom, + estimatedRegenMicro: planned.estimatedRegenMicro, + acquiredRegenMicro: planned.estimatedRegenMicro, + txHash: `sim_dex_${randomUUID()}`, + message: `Executed simulated DEX acquisition for ${input.month}.`, + }; + } +} + +export function createRegenAcquisitionProvider( + config: RegenAcquisitionProviderConfig +): RegenAcquisitionProvider { + if (config.provider === "simulated") { + return new SimulatedRegenAcquisitionProvider(config.simulatedRateUregenPerUsdc); + } + return new DisabledRegenAcquisitionProvider(); +} diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 2d15097..581fe79 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -18,6 +18,11 @@ function denomExponent(denom: string): number { return denom.toLowerCase() === "uusdc" ? 6 : 6; } +function formatRegenMicro(value: string): string { + const amount = BigInt(value); + return formatMicroAmount(amount, "REGEN", 6); +} + export async function runMonthlyBatchRetirementTool( month: string, creditType?: "carbon" | "biodiversity", @@ -62,6 +67,25 @@ export async function runMonthlyBatchRetirementTool( ); } + if (result.regenAcquisition) { + lines.push( + `| REGEN Acquisition Status | ${result.regenAcquisition.status} (${result.regenAcquisition.provider}) |`, + `| REGEN Acquisition Spend | ${formatMicroAmount(BigInt(result.regenAcquisition.spendMicro), result.regenAcquisition.spendDenom, denomExponent(result.regenAcquisition.spendDenom))} |`, + `| Estimated REGEN | ${formatRegenMicro(result.regenAcquisition.estimatedRegenMicro)} |` + ); + + if (result.regenAcquisition.acquiredRegenMicro) { + lines.push( + `| Acquired REGEN | ${formatRegenMicro(result.regenAcquisition.acquiredRegenMicro)} |` + ); + } + if (result.regenAcquisition.txHash) { + lines.push( + `| REGEN Acquisition Tx | \`${result.regenAcquisition.txHash}\` |` + ); + } + } + if (result.txHash) { lines.push(`| Transaction Hash | \`${result.txHash}\` |`); } @@ -94,6 +118,9 @@ export async function runMonthlyBatchRetirementTool( } lines.push("", result.message); + if (result.regenAcquisition) { + lines.push(`REGEN acquisition: ${result.regenAcquisition.message}`); + } return { content: [{ type: "text" as const, text: lines.join("\n") }] }; } catch (error) { diff --git a/tests/monthly-batch-executor.test.ts b/tests/monthly-batch-executor.test.ts index b558552..e3aed51 100644 --- a/tests/monthly-batch-executor.test.ts +++ b/tests/monthly-batch-executor.test.ts @@ -4,6 +4,7 @@ import type { BatchExecutionState, BatchExecutionStore, BudgetOrderSelection, + RegenAcquisitionRecord, } from "../src/services/batch-retirement/types.js"; class InMemoryBatchExecutionStore implements BatchExecutionStore { @@ -25,6 +26,8 @@ describe("MonthlyBatchRetirementExecutor", () => { let waitForRetirement: ReturnType; let initWallet: ReturnType; let getMonthlySummary: ReturnType; + let planAcquisition: ReturnType; + let executeAcquisition: ReturnType; const selection: BudgetOrderSelection = { orders: [ @@ -57,6 +60,24 @@ describe("MonthlyBatchRetirementExecutor", () => { }); waitForRetirement = vi.fn().mockResolvedValue({ nodeId: "WyRet123" }); initWallet = vi.fn().mockResolvedValue({ address: "regen1batchbuyer" }); + planAcquisition = vi.fn().mockResolvedValue({ + provider: "simulated", + status: "planned", + spendMicro: "300000", + spendDenom: "USDC", + estimatedRegenMicro: "600000", + message: "Planned simulated DEX acquisition for 2026-03.", + } satisfies RegenAcquisitionRecord); + executeAcquisition = vi.fn().mockResolvedValue({ + provider: "simulated", + status: "executed", + spendMicro: "300000", + spendDenom: "USDC", + estimatedRegenMicro: "600000", + acquiredRegenMicro: "600000", + txHash: "sim_dex_abc", + message: "Executed simulated DEX acquisition for 2026-03.", + } satisfies RegenAcquisitionRecord); getMonthlySummary = vi.fn().mockResolvedValue({ month: "2026-03", contributionCount: 3, @@ -83,6 +104,11 @@ describe("MonthlyBatchRetirementExecutor", () => { initWallet, signAndBroadcast, waitForRetirement, + regenAcquisitionProvider: { + name: "simulated", + planAcquisition, + executeAcquisition, + }, loadConfig: () => ({ defaultJurisdiction: "US", @@ -127,6 +153,13 @@ describe("MonthlyBatchRetirementExecutor", () => { creditBudgetUsdCents: 270, protocolFeeDenom: "USDC", }); + expect(planAcquisition).toHaveBeenCalledWith({ + month: "2026-03", + spendMicro: 300_000n, + spendDenom: "USDC", + }); + expect(executeAcquisition).not.toHaveBeenCalled(); + expect(result.regenAcquisition?.status).toBe("planned"); expect(result.attributions).toHaveLength(1); expect(result.attributions?.[0]).toMatchObject({ userId: "user-a", @@ -157,6 +190,13 @@ describe("MonthlyBatchRetirementExecutor", () => { expect(result.txHash).toBe("TX123"); expect(result.retirementId).toBe("WyRet123"); expect(result.protocolFee?.protocolFeeUsdCents).toBe(30); + expect(planAcquisition).toHaveBeenCalledTimes(1); + expect(executeAcquisition).toHaveBeenCalledWith({ + month: "2026-03", + spendMicro: 300_000n, + spendDenom: "USDC", + }); + expect(result.regenAcquisition?.status).toBe("executed"); expect(result.attributions).toHaveLength(1); const state = await store.readState(); diff --git a/tests/regen-acquisition-provider.test.ts b/tests/regen-acquisition-provider.test.ts new file mode 100644 index 0000000..5e5f6bb --- /dev/null +++ b/tests/regen-acquisition-provider.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + createRegenAcquisitionProvider, +} from "../src/services/regen-acquisition/provider.js"; + +describe("Regen acquisition provider", () => { + it("returns skipped records when provider is disabled", async () => { + const provider = createRegenAcquisitionProvider({ + provider: "disabled", + simulatedRateUregenPerUsdc: 2_000_000, + }); + + const planned = await provider.planAcquisition({ + month: "2026-03", + spendMicro: 300_000n, + spendDenom: "USDC", + }); + + expect(planned.status).toBe("skipped"); + expect(planned.estimatedRegenMicro).toBe("0"); + }); + + it("plans and executes simulated DEX acquisitions", async () => { + const provider = createRegenAcquisitionProvider({ + provider: "simulated", + simulatedRateUregenPerUsdc: 2_000_000, + }); + + const planned = await provider.planAcquisition({ + month: "2026-03", + spendMicro: 300_000n, + spendDenom: "USDC", + }); + expect(planned.status).toBe("planned"); + expect(planned.estimatedRegenMicro).toBe("600000"); + + const executed = await provider.executeAcquisition({ + month: "2026-03", + spendMicro: 300_000n, + spendDenom: "USDC", + }); + expect(executed.status).toBe("executed"); + expect(executed.acquiredRegenMicro).toBe("600000"); + expect(executed.txHash).toContain("sim_dex_"); + }); +}); From 128c24f39c66453783d70ea5a50c9e7ad1670c51 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:34:13 -0800 Subject: [PATCH 09/31] feat: add REGEN burn execution flow for monthly batches --- .env.example | 9 ++ README.md | 7 +- src/config.ts | 20 +++ src/services/batch-retirement/executor.ts | 101 ++++++++++++++ src/services/batch-retirement/types.ts | 14 ++ src/services/regen-burn/provider.ts | 163 ++++++++++++++++++++++ src/tools/monthly-batch-retirement.ts | 16 +++ tests/monthly-batch-executor.test.ts | 58 ++++++++ tests/regen-burn-provider.test.ts | 100 +++++++++++++ 9 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/services/regen-burn/provider.ts create mode 100644 tests/regen-burn-provider.test.ts diff --git a/.env.example b/.env.example index 56211a8..1fdbf32 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,15 @@ REGEN_DEFAULT_JURISDICTION=US # default: 2000000 (2.0 REGEN per 1 USDC) # REGEN_ACQUISITION_RATE_UREGEN_PER_USDC=2000000 +# Optional: REGEN burn execution provider. +# - disabled (default): no burn transaction is attempted +# - simulated: emits simulated burn metadata for local testing +# - onchain: broadcasts a MsgSend burn transfer to REGEN_BURN_ADDRESS +# REGEN_BURN_PROVIDER=disabled + +# Required when REGEN_BURN_PROVIDER=onchain +# REGEN_BURN_ADDRESS=regen1... + # Authentication (OAuth - for user identity on retirement certificates) # OAUTH_CLIENT_ID= # OAUTH_CLIENT_SECRET= diff --git a/README.md b/README.md index 8408051..13b9832 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,8 @@ Supports: - real execution with `dry_run=false`, - duplicate-run protection per month (override with `force=true`), - protocol fee allocation (configurable `8%-12%`, default `10%`) with gross/fee/net budget reporting, -- protocol fee REGEN acquisition adapter output (planned/executed/skipped). +- protocol fee REGEN acquisition adapter output (planned/executed/skipped), +- REGEN burn execution status (planned/executed/skipped/failed). **When it's used:** Running the monthly pooled buy-and-retire process from aggregated subscription funds. @@ -352,6 +353,10 @@ export REGEN_PROTOCOL_FEE_BPS=1000 export REGEN_ACQUISITION_PROVIDER=disabled # optional simulated acquisition rate (micro-REGEN per 1 USDC, default 2000000) export REGEN_ACQUISITION_RATE_UREGEN_PER_USDC=2000000 +# optional REGEN burn provider: disabled | simulated | onchain +export REGEN_BURN_PROVIDER=disabled +# required when REGEN_BURN_PROVIDER=onchain +export REGEN_BURN_ADDRESS=regen1... ``` Note: Stripe mode currently requires USDC-denominated sell orders (`uusdc`) so fiat charges map cleanly to on-chain pricing. diff --git a/src/config.ts b/src/config.ts index 8bcc306..78f2567 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,6 +30,8 @@ export interface Config { ecoBridgeEvmDerivationPath: string; regenAcquisitionProvider: "disabled" | "simulated"; regenAcquisitionRateUregenPerUsdc: number; + regenBurnProvider: "disabled" | "simulated" | "onchain"; + regenBurnAddress: string | undefined; } let _config: Config | undefined; @@ -39,6 +41,7 @@ const MIN_PROTOCOL_FEE_BPS = 800; const MAX_PROTOCOL_FEE_BPS = 1200; const DEFAULT_REGEN_ACQUISITION_PROVIDER = "disabled" as const; const DEFAULT_REGEN_ACQUISITION_RATE_UREGEN_PER_USDC = 2_000_000; +const DEFAULT_REGEN_BURN_PROVIDER = "disabled" as const; function parseProtocolFeeBps(rawValue: string | undefined): number { if (!rawValue) return DEFAULT_PROTOCOL_FEE_BPS; @@ -86,6 +89,21 @@ function parsePositiveInteger( return parsed; } +function parseRegenBurnProvider( + rawValue: string | undefined +): "disabled" | "simulated" | "onchain" { + if (!rawValue) return DEFAULT_REGEN_BURN_PROVIDER; + + const normalized = rawValue.trim().toLowerCase(); + if (normalized === "disabled") return "disabled"; + if (normalized === "simulated") return "simulated"; + if (normalized === "onchain") return "onchain"; + + throw new Error( + "REGEN_BURN_PROVIDER must be one of: disabled, simulated, onchain" + ); +} + export function loadConfig(): Config { if (_config) return _config; @@ -125,6 +143,8 @@ export function loadConfig(): Config { "REGEN_ACQUISITION_RATE_UREGEN_PER_USDC", DEFAULT_REGEN_ACQUISITION_RATE_UREGEN_PER_USDC ), + regenBurnProvider: parseRegenBurnProvider(process.env.REGEN_BURN_PROVIDER), + regenBurnAddress: process.env.REGEN_BURN_ADDRESS?.trim() || undefined, }; return _config; diff --git a/src/services/batch-retirement/executor.ts b/src/services/batch-retirement/executor.ts index bd77a39..2de47f3 100644 --- a/src/services/batch-retirement/executor.ts +++ b/src/services/batch-retirement/executor.ts @@ -10,6 +10,10 @@ import { createRegenAcquisitionProvider, type RegenAcquisitionProvider, } from "../regen-acquisition/provider.js"; +import { + createRegenBurnProvider, + type RegenBurnProvider, +} from "../regen-burn/provider.js"; import { JsonFileBatchExecutionStore } from "./store.js"; import type { BatchExecutionRecord, @@ -31,6 +35,7 @@ export interface MonthlyBatchExecutorDeps { waitForRetirement: typeof waitForRetirement; loadConfig: typeof loadConfig; regenAcquisitionProvider: RegenAcquisitionProvider; + regenBurnProvider: RegenBurnProvider; } function usdToCents(value: number): number { @@ -58,6 +63,7 @@ function buildExecutionRecord( selection: BudgetOrderSelection; protocolFee?: BatchExecutionRecord["protocolFee"]; regenAcquisition?: BatchExecutionRecord["regenAcquisition"]; + regenBurn?: BatchExecutionRecord["regenBurn"]; attributions?: BatchExecutionRecord["attributions"]; txHash?: string; blockHeight?: number; @@ -79,6 +85,7 @@ function buildExecutionRecord( retiredQuantity: input.selection.totalQuantity, protocolFee: input.protocolFee, regenAcquisition: input.regenAcquisition, + regenBurn: input.regenBurn, attributions: input.attributions, txHash: input.txHash, blockHeight: input.blockHeight, @@ -111,6 +118,12 @@ export class MonthlyBatchRetirementExecutor { simulatedRateUregenPerUsdc: configForDeps.regenAcquisitionRateUregenPerUsdc, }), + regenBurnProvider: + deps?.regenBurnProvider || + createRegenBurnProvider({ + provider: configForDeps.regenBurnProvider, + burnAddress: configForDeps.regenBurnAddress, + }), }; } @@ -198,6 +211,23 @@ export class MonthlyBatchRetirementExecutor { }) : undefined; + const plannedRegenBurn = + plannedRegenAcquisition && + BigInt(plannedRegenAcquisition.estimatedRegenMicro) > 0n + ? await this.deps.regenBurnProvider.planBurn({ + month: input.month, + amountMicro: BigInt(plannedRegenAcquisition.estimatedRegenMicro), + }) + : plannedRegenAcquisition + ? { + provider: this.deps.regenBurnProvider.name, + status: "skipped" as const, + amountMicro: "0", + denom: "uregen" as const, + message: `Skipped REGEN burn because acquisition is ${plannedRegenAcquisition.status}.`, + } + : undefined; + const budgetMicro = toBudgetMicro(paymentDenom, protocolFee.creditBudgetUsdCents); if (protocolFee.creditBudgetUsdCents <= 0) { @@ -211,6 +241,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostDenom: paymentDenom, protocolFee, regenAcquisition: plannedRegenAcquisition, + regenBurn: plannedRegenBurn, message: "No credit purchase budget remains after applying protocol fee to this monthly pool.", }; @@ -233,6 +264,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostDenom: selection.paymentDenom, protocolFee, regenAcquisition: plannedRegenAcquisition, + regenBurn: plannedRegenBurn, message: "No eligible sell orders were found for the configured budget and filters.", }; @@ -258,6 +290,7 @@ export class MonthlyBatchRetirementExecutor { selection, protocolFee, regenAcquisition: plannedRegenAcquisition, + regenBurn: plannedRegenBurn, attributions, dryRun: true, }); @@ -272,6 +305,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostDenom: selection.paymentDenom, protocolFee, regenAcquisition: plannedRegenAcquisition, + regenBurn: plannedRegenBurn, attributions, message: "Dry run complete. No on-chain transaction was broadcast.", executionRecord: record, @@ -289,6 +323,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostDenom: selection.paymentDenom, protocolFee, regenAcquisition: plannedRegenAcquisition, + regenBurn: plannedRegenBurn, attributions, message: "Wallet is not configured. Set REGEN_WALLET_MNEMONIC before executing monthly batch retirements.", @@ -335,6 +370,14 @@ export class MonthlyBatchRetirementExecutor { "Skipped REGEN acquisition because the retirement transaction was rejected.", } : undefined, + regenBurn: plannedRegenBurn + ? { + ...plannedRegenBurn, + status: "skipped", + message: + "Skipped REGEN burn because the retirement transaction was rejected.", + } + : undefined, attributions, error: `Transaction rejected (code ${txResult.code}): ${ txResult.rawLog || "unknown error" @@ -353,6 +396,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostDenom: selection.paymentDenom, protocolFee, regenAcquisition: record.regenAcquisition, + regenBurn: record.regenBurn, attributions, message: record.error || "Monthly batch transaction failed.", executionRecord: record, @@ -384,6 +428,50 @@ export class MonthlyBatchRetirementExecutor { } } + let regenBurn = plannedRegenBurn; + if (regenAcquisition) { + if (regenAcquisition.status === "executed") { + const burnAmountMicro = BigInt( + regenAcquisition.acquiredRegenMicro || + regenAcquisition.estimatedRegenMicro + ); + if (burnAmountMicro > 0n) { + try { + regenBurn = await this.deps.regenBurnProvider.executeBurn({ + month: input.month, + amountMicro: burnAmountMicro, + }); + } catch (error) { + const errMsg = + error instanceof Error ? error.message : "Unknown REGEN burn error"; + regenBurn = { + provider: this.deps.regenBurnProvider.name, + status: "failed", + amountMicro: burnAmountMicro.toString(), + denom: "uregen", + message: `REGEN burn failed: ${errMsg}`, + }; + } + } else { + regenBurn = { + provider: this.deps.regenBurnProvider.name, + status: "skipped", + amountMicro: "0", + denom: "uregen", + message: "Skipped REGEN burn because acquisition amount was zero.", + }; + } + } else { + regenBurn = { + provider: this.deps.regenBurnProvider.name, + status: "skipped", + amountMicro: "0", + denom: "uregen", + message: `Skipped REGEN burn because acquisition status is ${regenAcquisition.status}.`, + }; + } + } + const record = buildExecutionRecord("success", { month: input.month, creditType: input.creditType, @@ -392,6 +480,7 @@ export class MonthlyBatchRetirementExecutor { selection, protocolFee, regenAcquisition, + regenBurn, attributions, txHash: txResult.transactionHash, blockHeight: txResult.height, @@ -410,6 +499,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostDenom: selection.paymentDenom, protocolFee, regenAcquisition, + regenBurn, attributions, txHash: txResult.transactionHash, blockHeight: txResult.height, @@ -417,6 +507,8 @@ export class MonthlyBatchRetirementExecutor { message: regenAcquisition?.status === "failed" ? "Monthly batch retirement completed, but REGEN acquisition failed." + : regenBurn?.status === "failed" + ? "Monthly batch retirement completed, but REGEN burn failed." : "Monthly batch retirement completed successfully.", executionRecord: record, }; @@ -438,6 +530,14 @@ export class MonthlyBatchRetirementExecutor { "Skipped REGEN acquisition because monthly retirement execution failed.", } : undefined, + regenBurn: plannedRegenBurn + ? { + ...plannedRegenBurn, + status: "skipped", + message: + "Skipped REGEN burn because monthly retirement execution failed.", + } + : undefined, attributions, error: errMsg, dryRun: false, @@ -454,6 +554,7 @@ export class MonthlyBatchRetirementExecutor { plannedCostDenom: selection.paymentDenom, protocolFee, regenAcquisition: record.regenAcquisition, + regenBurn: record.regenBurn, attributions, message: errMsg, executionRecord: record, diff --git a/src/services/batch-retirement/types.ts b/src/services/batch-retirement/types.ts index 4331540..6f7e6cd 100644 --- a/src/services/batch-retirement/types.ts +++ b/src/services/batch-retirement/types.ts @@ -56,6 +56,18 @@ export interface RegenAcquisitionRecord { message: string; } +export type RegenBurnStatus = "planned" | "executed" | "skipped" | "failed"; + +export interface RegenBurnRecord { + provider: string; + status: RegenBurnStatus; + amountMicro: string; + denom: "uregen"; + burnAddress?: string; + txHash?: string; + message: string; +} + export type BatchExecutionStatus = | "success" | "failed" @@ -74,6 +86,7 @@ export interface BatchExecutionRecord { retiredQuantity: string; protocolFee?: ProtocolFeeBreakdown; regenAcquisition?: RegenAcquisitionRecord; + regenBurn?: RegenBurnRecord; attributions?: ContributorAttribution[]; txHash?: string; blockHeight?: number; @@ -120,6 +133,7 @@ export interface RunMonthlyBatchResult { plannedCostDenom: string; protocolFee?: ProtocolFeeBreakdown; regenAcquisition?: RegenAcquisitionRecord; + regenBurn?: RegenBurnRecord; attributions?: ContributorAttribution[]; txHash?: string; blockHeight?: number; diff --git a/src/services/regen-burn/provider.ts b/src/services/regen-burn/provider.ts new file mode 100644 index 0000000..4e1d2ba --- /dev/null +++ b/src/services/regen-burn/provider.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import type { EncodeObject } from "@cosmjs/proto-signing"; +import { initWallet, signAndBroadcast } from "../wallet.js"; +import type { RegenBurnRecord } from "../batch-retirement/types.js"; + +export interface RegenBurnInput { + month: string; + amountMicro: bigint; +} + +export interface RegenBurnProvider { + name: string; + planBurn(input: RegenBurnInput): Promise; + executeBurn(input: RegenBurnInput): Promise; +} + +export interface RegenBurnProviderConfig { + provider: "disabled" | "simulated" | "onchain"; + burnAddress?: string; +} + +function skippedRecord(provider: string, input: RegenBurnInput, message: string): RegenBurnRecord { + return { + provider, + status: "skipped", + amountMicro: input.amountMicro.toString(), + denom: "uregen", + message, + }; +} + +class DisabledRegenBurnProvider implements RegenBurnProvider { + name = "disabled"; + + async planBurn(input: RegenBurnInput): Promise { + return skippedRecord( + this.name, + input, + "REGEN burn provider is disabled. Set REGEN_BURN_PROVIDER to simulated or onchain to enable burn execution." + ); + } + + async executeBurn(input: RegenBurnInput): Promise { + return this.planBurn(input); + } +} + +class SimulatedRegenBurnProvider implements RegenBurnProvider { + name = "simulated"; + + async planBurn(input: RegenBurnInput): Promise { + if (input.amountMicro <= 0n) { + return skippedRecord( + this.name, + input, + "No REGEN amount available to burn." + ); + } + + return { + provider: this.name, + status: "planned", + amountMicro: input.amountMicro.toString(), + denom: "uregen", + burnAddress: "simulated-burn-address", + message: `Planned simulated REGEN burn for ${input.month}.`, + }; + } + + async executeBurn(input: RegenBurnInput): Promise { + const planned = await this.planBurn(input); + if (planned.status === "skipped") return planned; + + return { + ...planned, + status: "executed", + txHash: `sim_burn_${randomUUID()}`, + message: `Executed simulated REGEN burn for ${input.month}.`, + }; + } +} + +class OnChainRegenBurnProvider implements RegenBurnProvider { + name = "onchain"; + private readonly burnAddress: string; + + constructor(burnAddress: string) { + const normalized = burnAddress.trim(); + if (!normalized) { + throw new Error( + "REGEN_BURN_ADDRESS is required when REGEN_BURN_PROVIDER=onchain" + ); + } + this.burnAddress = normalized; + } + + async planBurn(input: RegenBurnInput): Promise { + if (input.amountMicro <= 0n) { + return skippedRecord( + this.name, + input, + "No REGEN amount available to burn." + ); + } + + return { + provider: this.name, + status: "planned", + amountMicro: input.amountMicro.toString(), + denom: "uregen", + burnAddress: this.burnAddress, + message: `Planned on-chain REGEN burn for ${input.month}.`, + }; + } + + async executeBurn(input: RegenBurnInput): Promise { + const planned = await this.planBurn(input); + if (planned.status === "skipped") return planned; + + const { address } = await initWallet(); + const sendMsg: EncodeObject = { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + fromAddress: address, + toAddress: this.burnAddress, + amount: [ + { + denom: "uregen", + amount: input.amountMicro.toString(), + }, + ], + }, + }; + + const tx = await signAndBroadcast([sendMsg]); + if (tx.code !== 0) { + return { + ...planned, + status: "failed", + message: `REGEN burn transaction failed (code ${tx.code}): ${tx.rawLog || "unknown error"}`, + }; + } + + return { + ...planned, + status: "executed", + txHash: tx.transactionHash, + message: `Executed on-chain REGEN burn for ${input.month}.`, + }; + } +} + +export function createRegenBurnProvider( + config: RegenBurnProviderConfig +): RegenBurnProvider { + if (config.provider === "simulated") { + return new SimulatedRegenBurnProvider(); + } + if (config.provider === "onchain") { + return new OnChainRegenBurnProvider(config.burnAddress || ""); + } + return new DisabledRegenBurnProvider(); +} diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 581fe79..93fce5b 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -86,6 +86,19 @@ export async function runMonthlyBatchRetirementTool( } } + if (result.regenBurn) { + lines.push( + `| REGEN Burn Status | ${result.regenBurn.status} (${result.regenBurn.provider}) |`, + `| REGEN Burn Amount | ${formatMicroAmount(BigInt(result.regenBurn.amountMicro), "REGEN", 6)} |` + ); + if (result.regenBurn.burnAddress) { + lines.push(`| REGEN Burn Address | \`${result.regenBurn.burnAddress}\` |`); + } + if (result.regenBurn.txHash) { + lines.push(`| REGEN Burn Tx | \`${result.regenBurn.txHash}\` |`); + } + } + if (result.txHash) { lines.push(`| Transaction Hash | \`${result.txHash}\` |`); } @@ -121,6 +134,9 @@ export async function runMonthlyBatchRetirementTool( if (result.regenAcquisition) { lines.push(`REGEN acquisition: ${result.regenAcquisition.message}`); } + if (result.regenBurn) { + lines.push(`REGEN burn: ${result.regenBurn.message}`); + } return { content: [{ type: "text" as const, text: lines.join("\n") }] }; } catch (error) { diff --git a/tests/monthly-batch-executor.test.ts b/tests/monthly-batch-executor.test.ts index e3aed51..f5b5c2d 100644 --- a/tests/monthly-batch-executor.test.ts +++ b/tests/monthly-batch-executor.test.ts @@ -5,6 +5,7 @@ import type { BatchExecutionStore, BudgetOrderSelection, RegenAcquisitionRecord, + RegenBurnRecord, } from "../src/services/batch-retirement/types.js"; class InMemoryBatchExecutionStore implements BatchExecutionStore { @@ -28,6 +29,8 @@ describe("MonthlyBatchRetirementExecutor", () => { let getMonthlySummary: ReturnType; let planAcquisition: ReturnType; let executeAcquisition: ReturnType; + let planBurn: ReturnType; + let executeBurn: ReturnType; const selection: BudgetOrderSelection = { orders: [ @@ -78,6 +81,23 @@ describe("MonthlyBatchRetirementExecutor", () => { txHash: "sim_dex_abc", message: "Executed simulated DEX acquisition for 2026-03.", } satisfies RegenAcquisitionRecord); + planBurn = vi.fn().mockResolvedValue({ + provider: "simulated", + status: "planned", + amountMicro: "600000", + denom: "uregen", + burnAddress: "simulated-burn-address", + message: "Planned simulated REGEN burn for 2026-03.", + } satisfies RegenBurnRecord); + executeBurn = vi.fn().mockResolvedValue({ + provider: "simulated", + status: "executed", + amountMicro: "600000", + denom: "uregen", + burnAddress: "simulated-burn-address", + txHash: "sim_burn_abc", + message: "Executed simulated REGEN burn for 2026-03.", + } satisfies RegenBurnRecord); getMonthlySummary = vi.fn().mockResolvedValue({ month: "2026-03", contributionCount: 3, @@ -109,6 +129,11 @@ describe("MonthlyBatchRetirementExecutor", () => { planAcquisition, executeAcquisition, }, + regenBurnProvider: { + name: "simulated", + planBurn, + executeBurn, + }, loadConfig: () => ({ defaultJurisdiction: "US", @@ -159,7 +184,13 @@ describe("MonthlyBatchRetirementExecutor", () => { spendDenom: "USDC", }); expect(executeAcquisition).not.toHaveBeenCalled(); + expect(planBurn).toHaveBeenCalledWith({ + month: "2026-03", + amountMicro: 600_000n, + }); + expect(executeBurn).not.toHaveBeenCalled(); expect(result.regenAcquisition?.status).toBe("planned"); + expect(result.regenBurn?.status).toBe("planned"); expect(result.attributions).toHaveLength(1); expect(result.attributions?.[0]).toMatchObject({ userId: "user-a", @@ -196,7 +227,12 @@ describe("MonthlyBatchRetirementExecutor", () => { spendMicro: 300_000n, spendDenom: "USDC", }); + expect(executeBurn).toHaveBeenCalledWith({ + month: "2026-03", + amountMicro: 600_000n, + }); expect(result.regenAcquisition?.status).toBe("executed"); + expect(result.regenBurn?.status).toBe("executed"); expect(result.attributions).toHaveLength(1); const state = await store.readState(); @@ -234,4 +270,26 @@ describe("MonthlyBatchRetirementExecutor", () => { expect(forced.status).toBe("success"); expect(signAndBroadcast).toHaveBeenCalledTimes(2); }); + + it("returns success with a warning when REGEN burn fails", async () => { + const executor = createExecutor(true); + + executeBurn.mockResolvedValueOnce({ + provider: "simulated", + status: "failed", + amountMicro: "600000", + denom: "uregen", + message: "REGEN burn failed: simulated failure", + } satisfies RegenBurnRecord); + + const result = await executor.runMonthlyBatch({ + month: "2026-04", + dryRun: false, + creditType: "carbon", + }); + + expect(result.status).toBe("success"); + expect(result.regenBurn?.status).toBe("failed"); + expect(result.message).toContain("REGEN burn failed"); + }); }); diff --git a/tests/regen-burn-provider.test.ts b/tests/regen-burn-provider.test.ts new file mode 100644 index 0000000..15e11a2 --- /dev/null +++ b/tests/regen-burn-provider.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + initWallet: vi.fn(), + signAndBroadcast: vi.fn(), +})); + +vi.mock("../src/services/wallet.js", () => ({ + initWallet: mocks.initWallet, + signAndBroadcast: mocks.signAndBroadcast, +})); + +import { createRegenBurnProvider } from "../src/services/regen-burn/provider.js"; + +describe("Regen burn provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.initWallet.mockResolvedValue({ address: "regen1sender" }); + mocks.signAndBroadcast.mockResolvedValue({ + code: 0, + transactionHash: "BURN_TX_1", + height: 123, + rawLog: "", + }); + }); + + it("returns skipped records when provider is disabled", async () => { + const provider = createRegenBurnProvider({ + provider: "disabled", + }); + + const planned = await provider.planBurn({ + month: "2026-03", + amountMicro: 600_000n, + }); + + expect(planned.status).toBe("skipped"); + expect(planned.amountMicro).toBe("600000"); + }); + + it("plans and executes simulated burns", async () => { + const provider = createRegenBurnProvider({ + provider: "simulated", + }); + + const planned = await provider.planBurn({ + month: "2026-03", + amountMicro: 600_000n, + }); + expect(planned.status).toBe("planned"); + + const executed = await provider.executeBurn({ + month: "2026-03", + amountMicro: 600_000n, + }); + expect(executed.status).toBe("executed"); + expect(executed.txHash).toContain("sim_burn_"); + }); + + it("executes on-chain burns via MsgSend", async () => { + const provider = createRegenBurnProvider({ + provider: "onchain", + burnAddress: "regen1burnaddressxyz", + }); + + const executed = await provider.executeBurn({ + month: "2026-03", + amountMicro: 600_000n, + }); + + expect(executed.status).toBe("executed"); + expect(executed.txHash).toBe("BURN_TX_1"); + expect(mocks.signAndBroadcast).toHaveBeenCalledTimes(1); + const messages = mocks.signAndBroadcast.mock.calls[0]?.[0]; + expect(messages[0]?.typeUrl).toBe("/cosmos.bank.v1beta1.MsgSend"); + expect(messages[0]?.value?.toAddress).toBe("regen1burnaddressxyz"); + }); + + it("returns failed status when on-chain burn transaction is rejected", async () => { + mocks.signAndBroadcast.mockResolvedValueOnce({ + code: 5, + transactionHash: "BURN_TX_FAIL", + height: 999, + rawLog: "insufficient fees", + }); + + const provider = createRegenBurnProvider({ + provider: "onchain", + burnAddress: "regen1burnaddressxyz", + }); + + const executed = await provider.executeBurn({ + month: "2026-03", + amountMicro: 600_000n, + }); + + expect(executed.status).toBe("failed"); + expect(executed.message).toContain("insufficient fees"); + }); +}); From b5cf28f815c69f8c6f80ef500acec8200b663f01 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:39:22 -0800 Subject: [PATCH 10/31] feat: add automated credit mix policy for monthly batches --- .env.example | 5 + README.md | 3 + src/config.ts | 19 +++ src/services/batch-retirement/executor.ts | 27 +++- src/services/batch-retirement/policy.ts | 152 ++++++++++++++++++++++ src/services/batch-retirement/types.ts | 18 +++ src/tools/monthly-batch-retirement.ts | 21 +++ tests/batch-credit-mix-policy.test.ts | 150 +++++++++++++++++++++ tests/monthly-batch-executor.test.ts | 1 + 9 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 src/services/batch-retirement/policy.ts create mode 100644 tests/batch-credit-mix-policy.test.ts diff --git a/.env.example b/.env.example index 1fdbf32..a76f1b6 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,11 @@ REGEN_DEFAULT_JURISDICTION=US # Range: 800-1200 (8%-12%), default: 1000 (10%). # REGEN_PROTOCOL_FEE_BPS=1000 +# Optional: monthly batch credit-mix policy when credit_type is omitted. +# - balanced (default): auto split carbon+biodiversity using availability + relative price +# - off: disable policy and use single-pass selection across all eligible credits +# REGEN_BATCH_CREDIT_MIX_POLICY=balanced + # Optional: protocol fee REGEN acquisition provider. # - disabled (default): no REGEN acquisition step is executed # - simulated: uses fixed rate conversion for planning/testing flows diff --git a/README.md b/README.md index 13b9832..d98ce99 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,7 @@ Supports: - `dry_run=true` planning (default, no on-chain transaction), - real execution with `dry_run=false`, - duplicate-run protection per month (override with `force=true`), +- automated carbon+biodiversity mix policy when `credit_type` is omitted, - protocol fee allocation (configurable `8%-12%`, default `10%`) with gross/fee/net budget reporting, - protocol fee REGEN acquisition adapter output (planned/executed/skipped), - REGEN burn execution status (planned/executed/skipped/failed). @@ -349,6 +350,8 @@ export REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json # optional protocol fee basis points for monthly pool budgets (800-1200, default 1000) export REGEN_PROTOCOL_FEE_BPS=1000 +# optional batch credit mix policy when credit_type is omitted: balanced | off +export REGEN_BATCH_CREDIT_MIX_POLICY=balanced # optional protocol fee REGEN acquisition provider: disabled | simulated export REGEN_ACQUISITION_PROVIDER=disabled # optional simulated acquisition rate (micro-REGEN per 1 USDC, default 2000000) diff --git a/src/config.ts b/src/config.ts index 78f2567..cef550a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,6 +30,7 @@ export interface Config { ecoBridgeEvmDerivationPath: string; regenAcquisitionProvider: "disabled" | "simulated"; regenAcquisitionRateUregenPerUsdc: number; + batchCreditMixPolicy: "off" | "balanced"; regenBurnProvider: "disabled" | "simulated" | "onchain"; regenBurnAddress: string | undefined; } @@ -41,6 +42,7 @@ const MIN_PROTOCOL_FEE_BPS = 800; const MAX_PROTOCOL_FEE_BPS = 1200; const DEFAULT_REGEN_ACQUISITION_PROVIDER = "disabled" as const; const DEFAULT_REGEN_ACQUISITION_RATE_UREGEN_PER_USDC = 2_000_000; +const DEFAULT_BATCH_CREDIT_MIX_POLICY = "balanced" as const; const DEFAULT_REGEN_BURN_PROVIDER = "disabled" as const; function parseProtocolFeeBps(rawValue: string | undefined): number { @@ -104,6 +106,20 @@ function parseRegenBurnProvider( ); } +function parseBatchCreditMixPolicy( + rawValue: string | undefined +): "off" | "balanced" { + if (!rawValue) return DEFAULT_BATCH_CREDIT_MIX_POLICY; + + const normalized = rawValue.trim().toLowerCase(); + if (normalized === "off") return "off"; + if (normalized === "balanced") return "balanced"; + + throw new Error( + "REGEN_BATCH_CREDIT_MIX_POLICY must be one of: off, balanced" + ); +} + export function loadConfig(): Config { if (_config) return _config; @@ -143,6 +159,9 @@ export function loadConfig(): Config { "REGEN_ACQUISITION_RATE_UREGEN_PER_USDC", DEFAULT_REGEN_ACQUISITION_RATE_UREGEN_PER_USDC ), + batchCreditMixPolicy: parseBatchCreditMixPolicy( + process.env.REGEN_BATCH_CREDIT_MIX_POLICY + ), regenBurnProvider: parseRegenBurnProvider(process.env.REGEN_BURN_PROVIDER), regenBurnAddress: process.env.REGEN_BURN_ADDRESS?.trim() || undefined, }; diff --git a/src/services/batch-retirement/executor.ts b/src/services/batch-retirement/executor.ts index 2de47f3..a0a961d 100644 --- a/src/services/batch-retirement/executor.ts +++ b/src/services/batch-retirement/executor.ts @@ -5,6 +5,7 @@ import { signAndBroadcast, initWallet } from "../wallet.js"; import { PoolAccountingService } from "../pool-accounting/service.js"; import { buildContributorAttributions } from "./attribution.js"; import { calculateProtocolFee } from "./fee.js"; +import { selectOrdersWithPolicy } from "./policy.js"; import { selectOrdersForBudget } from "./planner.js"; import { createRegenAcquisitionProvider, @@ -16,6 +17,7 @@ import { } from "../regen-burn/provider.js"; import { JsonFileBatchExecutionStore } from "./store.js"; import type { + BatchCreditMixPolicy, BatchExecutionRecord, BatchExecutionStore, BudgetOrderSelection, @@ -61,6 +63,7 @@ function buildExecutionRecord( reason: string; budgetUsdCents: number; selection: BudgetOrderSelection; + creditMix?: BatchExecutionRecord["creditMix"]; protocolFee?: BatchExecutionRecord["protocolFee"]; regenAcquisition?: BatchExecutionRecord["regenAcquisition"]; regenBurn?: BatchExecutionRecord["regenBurn"]; @@ -83,6 +86,7 @@ function buildExecutionRecord( spentMicro: input.selection.totalCostMicro.toString(), spentDenom: input.selection.paymentDenom, retiredQuantity: input.selection.totalQuantity, + creditMix: input.creditMix, protocolFee: input.protocolFee, regenAcquisition: input.regenAcquisition, regenBurn: input.regenBurn, @@ -196,6 +200,9 @@ export class MonthlyBatchRetirementExecutor { const retireReason = input.reason || `Monthly subscription pool retirement (${input.month})`; const paymentDenom = input.paymentDenom || "USDC"; + const mixPolicy: BatchCreditMixPolicy = input.creditType + ? "off" + : config.batchCreditMixPolicy || "balanced"; const protocolFee = calculateProtocolFee({ grossBudgetUsdCents: totalBudgetUsdCents, protocolFeeBps: config.protocolFeeBps, @@ -247,11 +254,13 @@ export class MonthlyBatchRetirementExecutor { }; } - const selection = await this.deps.selectOrdersForBudget( - input.creditType, + const { selection, creditMix } = await selectOrdersWithPolicy({ + explicitCreditType: input.creditType, + policy: mixPolicy, budgetMicro, - paymentDenom - ); + paymentDenom, + selectOrdersForBudget: this.deps.selectOrdersForBudget, + }); if (selection.orders.length === 0) { return { @@ -262,6 +271,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + creditMix, protocolFee, regenAcquisition: plannedRegenAcquisition, regenBurn: plannedRegenBurn, @@ -288,6 +298,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + creditMix, protocolFee, regenAcquisition: plannedRegenAcquisition, regenBurn: plannedRegenBurn, @@ -303,6 +314,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + creditMix, protocolFee, regenAcquisition: plannedRegenAcquisition, regenBurn: plannedRegenBurn, @@ -321,6 +333,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + creditMix, protocolFee, regenAcquisition: plannedRegenAcquisition, regenBurn: plannedRegenBurn, @@ -361,6 +374,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + creditMix, protocolFee, regenAcquisition: plannedRegenAcquisition ? { @@ -394,6 +408,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + creditMix, protocolFee, regenAcquisition: record.regenAcquisition, regenBurn: record.regenBurn, @@ -478,6 +493,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + creditMix, protocolFee, regenAcquisition, regenBurn, @@ -497,6 +513,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + creditMix, protocolFee, regenAcquisition, regenBurn, @@ -521,6 +538,7 @@ export class MonthlyBatchRetirementExecutor { reason: retireReason, budgetUsdCents: totalBudgetUsdCents, selection, + creditMix, protocolFee, regenAcquisition: plannedRegenAcquisition ? { @@ -552,6 +570,7 @@ export class MonthlyBatchRetirementExecutor { plannedQuantity: selection.totalQuantity, plannedCostMicro: selection.totalCostMicro, plannedCostDenom: selection.paymentDenom, + creditMix, protocolFee, regenAcquisition: record.regenAcquisition, regenBurn: record.regenBurn, diff --git a/src/services/batch-retirement/policy.ts b/src/services/batch-retirement/policy.ts new file mode 100644 index 0000000..2f76db9 --- /dev/null +++ b/src/services/batch-retirement/policy.ts @@ -0,0 +1,152 @@ +import type { + BatchCreditMixPolicy, + BudgetOrderSelection, + CreditMixSummary, +} from "./types.js"; + +function parseQuantityToMicro(quantity: string): bigint { + const [wholePart, fracPart = ""] = quantity.split("."); + const whole = BigInt(wholePart || "0"); + const frac = BigInt(fracPart.padEnd(6, "0").slice(0, 6) || "0"); + return whole * 1_000_000n + frac; +} + +function emptySelection(paymentDenom: string): BudgetOrderSelection { + return { + orders: [], + totalQuantity: "0.000000", + totalCostMicro: 0n, + remainingBudgetMicro: 0n, + paymentDenom, + displayDenom: paymentDenom.toUpperCase() === "USDC" ? "USDC" : paymentDenom, + exponent: 6, + exhaustedBudget: false, + }; +} + +function mergeSelections( + first: BudgetOrderSelection, + second: BudgetOrderSelection +): BudgetOrderSelection { + const totalQuantityMicro = + parseQuantityToMicro(first.totalQuantity) + parseQuantityToMicro(second.totalQuantity); + + const whole = totalQuantityMicro / 1_000_000n; + const frac = (totalQuantityMicro % 1_000_000n).toString().padStart(6, "0"); + + return { + orders: [...first.orders, ...second.orders], + totalQuantity: `${whole.toString()}.${frac}`, + totalCostMicro: first.totalCostMicro + second.totalCostMicro, + remainingBudgetMicro: first.remainingBudgetMicro + second.remainingBudgetMicro, + paymentDenom: first.paymentDenom || second.paymentDenom, + displayDenom: first.displayDenom || second.displayDenom, + exponent: first.exponent || second.exponent, + exhaustedBudget: first.exhaustedBudget && second.exhaustedBudget, + }; +} + +function avgMicroPerCredit(selection: BudgetOrderSelection): number | null { + const quantityMicro = parseQuantityToMicro(selection.totalQuantity); + if (quantityMicro <= 0n || selection.totalCostMicro <= 0n) return null; + const microPerCredit = + (Number(selection.totalCostMicro) * 1_000_000) / Number(quantityMicro); + return Number.isFinite(microPerCredit) ? microPerCredit : null; +} + +interface SelectOrdersWithPolicyInput { + explicitCreditType?: "carbon" | "biodiversity"; + policy: BatchCreditMixPolicy; + budgetMicro: bigint; + paymentDenom: "USDC" | "uusdc"; + selectOrdersForBudget: ( + creditType: "carbon" | "biodiversity" | undefined, + budgetMicro: bigint, + preferredDenom?: string + ) => Promise; +} + +export async function selectOrdersWithPolicy( + input: SelectOrdersWithPolicyInput +): Promise<{ selection: BudgetOrderSelection; creditMix?: CreditMixSummary }> { + const { explicitCreditType, policy, budgetMicro, paymentDenom, selectOrdersForBudget } = + input; + + if (explicitCreditType || policy === "off") { + const selection = await selectOrdersForBudget( + explicitCreditType, + budgetMicro, + paymentDenom + ); + return { selection }; + } + + const [carbonProbe, biodiversityProbe] = await Promise.all([ + selectOrdersForBudget("carbon", budgetMicro, paymentDenom), + selectOrdersForBudget("biodiversity", budgetMicro, paymentDenom), + ]); + + const carbonAvailable = carbonProbe.orders.length > 0; + const biodiversityAvailable = biodiversityProbe.orders.length > 0; + + let carbonShareBps = 5000; + let strategy = "Balanced 50/50 split"; + + if (!carbonAvailable && biodiversityAvailable) { + carbonShareBps = 0; + strategy = "100% biodiversity (carbon unavailable)"; + } else if (carbonAvailable && !biodiversityAvailable) { + carbonShareBps = 10_000; + strategy = "100% carbon (biodiversity unavailable)"; + } else if (!carbonAvailable && !biodiversityAvailable) { + strategy = "No eligible carbon or biodiversity orders"; + } else { + const carbonAvg = avgMicroPerCredit(carbonProbe); + const biodiversityAvg = avgMicroPerCredit(biodiversityProbe); + if (carbonAvg !== null && biodiversityAvg !== null) { + if (carbonAvg < biodiversityAvg) { + carbonShareBps = 7000; + strategy = "70/30 toward carbon (cheaper average price)"; + } else if (biodiversityAvg < carbonAvg) { + carbonShareBps = 3000; + strategy = "70/30 toward biodiversity (cheaper average price)"; + } + } + } + + const carbonBudget = (budgetMicro * BigInt(carbonShareBps)) / 10_000n; + const biodiversityBudget = budgetMicro - carbonBudget; + + const [carbonSelection, biodiversitySelection] = await Promise.all([ + carbonBudget > 0n + ? selectOrdersForBudget("carbon", carbonBudget, paymentDenom) + : Promise.resolve(emptySelection(paymentDenom)), + biodiversityBudget > 0n + ? selectOrdersForBudget("biodiversity", biodiversityBudget, paymentDenom) + : Promise.resolve(emptySelection(paymentDenom)), + ]); + + const selection = mergeSelections(carbonSelection, biodiversitySelection); + const creditMix: CreditMixSummary = { + policy, + strategy, + allocations: [ + { + creditType: "carbon", + budgetMicro: carbonBudget.toString(), + spentMicro: carbonSelection.totalCostMicro.toString(), + selectedQuantity: carbonSelection.totalQuantity, + orderCount: carbonSelection.orders.length, + }, + { + creditType: "biodiversity", + budgetMicro: biodiversityBudget.toString(), + spentMicro: biodiversitySelection.totalCostMicro.toString(), + selectedQuantity: biodiversitySelection.totalQuantity, + orderCount: biodiversitySelection.orders.length, + }, + ], + }; + + return { selection, creditMix }; +} diff --git a/src/services/batch-retirement/types.ts b/src/services/batch-retirement/types.ts index 6f7e6cd..7e7e95a 100644 --- a/src/services/batch-retirement/types.ts +++ b/src/services/batch-retirement/types.ts @@ -39,6 +39,22 @@ export interface ProtocolFeeBreakdown { creditBudgetUsdCents: number; } +export type BatchCreditMixPolicy = "off" | "balanced"; + +export interface CreditMixAllocation { + creditType: "carbon" | "biodiversity"; + budgetMicro: string; + spentMicro: string; + selectedQuantity: string; + orderCount: number; +} + +export interface CreditMixSummary { + policy: BatchCreditMixPolicy; + strategy: string; + allocations: CreditMixAllocation[]; +} + export type RegenAcquisitionStatus = | "planned" | "executed" @@ -84,6 +100,7 @@ export interface BatchExecutionRecord { spentMicro: string; spentDenom: string; retiredQuantity: string; + creditMix?: CreditMixSummary; protocolFee?: ProtocolFeeBreakdown; regenAcquisition?: RegenAcquisitionRecord; regenBurn?: RegenBurnRecord; @@ -131,6 +148,7 @@ export interface RunMonthlyBatchResult { plannedQuantity: string; plannedCostMicro: bigint; plannedCostDenom: string; + creditMix?: CreditMixSummary; protocolFee?: ProtocolFeeBreakdown; regenAcquisition?: RegenAcquisitionRecord; regenBurn?: RegenBurnRecord; diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 93fce5b..6bf1af1 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -67,6 +67,13 @@ export async function runMonthlyBatchRetirementTool( ); } + if (result.creditMix) { + lines.push( + `| Credit Mix Policy | ${result.creditMix.policy} |`, + `| Credit Mix Strategy | ${result.creditMix.strategy} |` + ); + } + if (result.regenAcquisition) { lines.push( `| REGEN Acquisition Status | ${result.regenAcquisition.status} (${result.regenAcquisition.provider}) |`, @@ -130,6 +137,20 @@ export async function runMonthlyBatchRetirementTool( } } + if (result.creditMix) { + lines.push( + "", + "### Credit Mix Allocation", + "", + "| Credit Type | Budget | Spent | Quantity | Orders |", + "|-------------|--------|-------|----------|--------|", + ...result.creditMix.allocations.map( + (item) => + `| ${item.creditType} | ${formatMicroAmount(BigInt(item.budgetMicro), result.plannedCostDenom, denomExponent(result.plannedCostDenom))} | ${formatMicroAmount(BigInt(item.spentMicro), result.plannedCostDenom, denomExponent(result.plannedCostDenom))} | ${item.selectedQuantity} | ${item.orderCount} |` + ) + ); + } + lines.push("", result.message); if (result.regenAcquisition) { lines.push(`REGEN acquisition: ${result.regenAcquisition.message}`); diff --git a/tests/batch-credit-mix-policy.test.ts b/tests/batch-credit-mix-policy.test.ts new file mode 100644 index 0000000..f11d8b7 --- /dev/null +++ b/tests/batch-credit-mix-policy.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; +import { + selectOrdersWithPolicy, +} from "../src/services/batch-retirement/policy.js"; +import type { BudgetOrderSelection } from "../src/services/batch-retirement/types.js"; + +function selection( + input: Partial & { totalCostMicro: bigint; totalQuantity: string } +): BudgetOrderSelection { + return { + orders: + input.orders ?? + [ + { + sellOrderId: "1", + batchDenom: "C01-001-2026", + quantity: input.totalQuantity, + askAmount: "1000000", + askDenom: "uusdc", + costMicro: input.totalCostMicro, + }, + ], + totalQuantity: input.totalQuantity, + totalCostMicro: input.totalCostMicro, + remainingBudgetMicro: input.remainingBudgetMicro ?? 0n, + paymentDenom: input.paymentDenom ?? "uusdc", + displayDenom: input.displayDenom ?? "USDC", + exponent: input.exponent ?? 6, + exhaustedBudget: input.exhaustedBudget ?? false, + }; +} + +describe("selectOrdersWithPolicy", () => { + it("delegates to direct selection when policy is off", async () => { + const selectOrdersForBudget = vi + .fn() + .mockResolvedValue(selection({ totalCostMicro: 2_000_000n, totalQuantity: "1.000000" })); + + const result = await selectOrdersWithPolicy({ + explicitCreditType: undefined, + policy: "off", + budgetMicro: 2_000_000n, + paymentDenom: "USDC", + selectOrdersForBudget, + }); + + expect(result.creditMix).toBeUndefined(); + expect(selectOrdersForBudget).toHaveBeenCalledTimes(1); + expect(selectOrdersForBudget).toHaveBeenCalledWith(undefined, 2_000_000n, "USDC"); + }); + + it("uses a 70/30 split toward cheaper type under balanced policy", async () => { + const selectOrdersForBudget = vi.fn( + async (creditType: "carbon" | "biodiversity" | undefined, budget: bigint) => { + if (creditType === "carbon" && budget === 10_000_000n) { + return selection({ totalCostMicro: 10_000_000n, totalQuantity: "5.000000" }); + } + if (creditType === "biodiversity" && budget === 10_000_000n) { + return selection({ + totalCostMicro: 10_000_000n, + totalQuantity: "3.333333", + orders: [ + { + sellOrderId: "2", + batchDenom: "BT01-001-2026", + quantity: "3.333333", + askAmount: "3000000", + askDenom: "uusdc", + costMicro: 10_000_000n, + }, + ], + }); + } + if (creditType === "carbon" && budget === 7_000_000n) { + return selection({ totalCostMicro: 6_900_000n, totalQuantity: "3.450000" }); + } + if (creditType === "biodiversity" && budget === 3_000_000n) { + return selection({ + totalCostMicro: 2_700_000n, + totalQuantity: "0.900000", + orders: [ + { + sellOrderId: "3", + batchDenom: "BT01-001-2026", + quantity: "0.900000", + askAmount: "3000000", + askDenom: "uusdc", + costMicro: 2_700_000n, + }, + ], + }); + } + return selection({ totalCostMicro: 0n, totalQuantity: "0.000000", orders: [] }); + } + ); + + const result = await selectOrdersWithPolicy({ + policy: "balanced", + budgetMicro: 10_000_000n, + paymentDenom: "USDC", + selectOrdersForBudget, + }); + + expect(result.creditMix?.policy).toBe("balanced"); + expect(result.creditMix?.strategy).toContain("carbon"); + expect(result.creditMix?.allocations[0]?.budgetMicro).toBe("7000000"); + expect(result.creditMix?.allocations[1]?.budgetMicro).toBe("3000000"); + expect(result.selection.orders).toHaveLength(2); + expect(result.selection.totalCostMicro).toBe(9_600_000n); + }); + + it("routes 100% to available type when the other has no inventory", async () => { + const selectOrdersForBudget = vi.fn( + async (creditType: "carbon" | "biodiversity" | undefined, budget: bigint) => { + if (creditType === "carbon") { + return selection({ totalCostMicro: 0n, totalQuantity: "0.000000", orders: [] }); + } + if (creditType === "biodiversity" && budget === 8_000_000n) { + return selection({ + totalCostMicro: 7_500_000n, + totalQuantity: "2.500000", + orders: [ + { + sellOrderId: "9", + batchDenom: "BT01-001-2026", + quantity: "2.500000", + askAmount: "3000000", + askDenom: "uusdc", + costMicro: 7_500_000n, + }, + ], + }); + } + return selection({ totalCostMicro: 0n, totalQuantity: "0.000000", orders: [] }); + } + ); + + const result = await selectOrdersWithPolicy({ + policy: "balanced", + budgetMicro: 8_000_000n, + paymentDenom: "USDC", + selectOrdersForBudget, + }); + + expect(result.creditMix?.strategy).toContain("biodiversity"); + expect(result.creditMix?.allocations[0]?.budgetMicro).toBe("0"); + expect(result.creditMix?.allocations[1]?.budgetMicro).toBe("8000000"); + expect(result.selection.orders).toHaveLength(1); + }); +}); diff --git a/tests/monthly-batch-executor.test.ts b/tests/monthly-batch-executor.test.ts index f5b5c2d..e60979a 100644 --- a/tests/monthly-batch-executor.test.ts +++ b/tests/monthly-batch-executor.test.ts @@ -138,6 +138,7 @@ describe("MonthlyBatchRetirementExecutor", () => { ({ defaultJurisdiction: "US", protocolFeeBps: 1000, + batchCreditMixPolicy: "off", }) as any, }); } From 8f0cc4396c429a653d3f832901e0b82d918d11bb Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:42:59 -0800 Subject: [PATCH 11/31] feat: add subscriber certificate frontend publishing tool --- .env.example | 6 + README.md | 11 + src/config.ts | 7 + src/index.ts | 40 +++ src/services/certificate-frontend/service.ts | 324 +++++++++++++++++++ src/tools/certificate-frontend.ts | 68 ++++ tests/certificate-frontend.test.ts | 112 +++++++ 7 files changed, 568 insertions(+) create mode 100644 src/services/certificate-frontend/service.ts create mode 100644 src/tools/certificate-frontend.ts create mode 100644 tests/certificate-frontend.test.ts diff --git a/.env.example b/.env.example index a76f1b6..a8ba1d3 100644 --- a/.env.example +++ b/.env.example @@ -83,3 +83,9 @@ REGEN_DEFAULT_JURISDICTION=US # Optional: monthly batch execution history file path (default: ./data/monthly-batch-executions.json) # REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json + +# Optional: subscriber certificate frontend settings +# Base URL prefix returned by publish_subscriber_certificate_page +# REGEN_CERTIFICATE_BASE_URL=https://regen.network/certificate +# Output directory for generated certificate HTML pages +# REGEN_CERTIFICATE_OUTPUT_DIR=./data/certificates diff --git a/README.md b/README.md index d98ce99..033dd30 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,14 @@ Supports: **When it's used:** Running the monthly pooled buy-and-retire process from aggregated subscription funds. +### `publish_subscriber_certificate_page` + +Generates a user-facing HTML certificate page for a subscriber's monthly fractional attribution record and returns: +- a public URL (based on `REGEN_CERTIFICATE_BASE_URL`), and +- the local generated file path (under `REGEN_CERTIFICATE_OUTPUT_DIR`). + +**When it's used:** Publishing shareable certificate pages for dashboards, receipts, and attribution history links. + ### `retire_credits` Retires ecocredits on Regen Network. Operates in two modes: @@ -348,6 +356,9 @@ export STRIPE_PRICE_ID_IMPACT=price_... export REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json # optional custom history path for monthly batch executions export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json +# optional subscriber certificate frontend settings +export REGEN_CERTIFICATE_BASE_URL=https://regen.network/certificate +export REGEN_CERTIFICATE_OUTPUT_DIR=./data/certificates # optional protocol fee basis points for monthly pool budgets (800-1200, default 1000) export REGEN_PROTOCOL_FEE_BPS=1000 # optional batch credit mix policy when credit_type is omitted: balanced | off diff --git a/src/config.ts b/src/config.ts index cef550a..1974534 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,8 @@ export interface Config { walletMnemonic: string | undefined; paymentProvider: "crypto" | "stripe"; defaultJurisdiction: string; + certificateBaseUrl: string; + certificateOutputDir: string; protocolFeeBps: number; // ecoBridge integration (Phase 1.5) @@ -138,6 +140,11 @@ export function loadConfig(): Config { paymentProvider: (process.env.REGEN_PAYMENT_PROVIDER as "crypto" | "stripe") || "crypto", defaultJurisdiction: process.env.REGEN_DEFAULT_JURISDICTION || "US", + certificateBaseUrl: + process.env.REGEN_CERTIFICATE_BASE_URL || + "https://regen.network/certificate", + certificateOutputDir: + process.env.REGEN_CERTIFICATE_OUTPUT_DIR || "data/certificates", protocolFeeBps: parseProtocolFeeBps(process.env.REGEN_PROTOCOL_FEE_BPS), ecoBridgeApiUrl: diff --git a/src/index.ts b/src/index.ts index ccb96d9..db30807 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { getSubscriberAttributionCertificateTool, getSubscriberImpactDashboardTool, } from "./tools/attribution-dashboard.js"; +import { publishSubscriberCertificatePageTool } from "./tools/certificate-frontend.js"; import { loadConfig, isWalletConfigured } from "./config.js"; import { fetchRegistry, @@ -106,6 +107,7 @@ const server = new McpServer( "6. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", "7. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", "8. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", + "9. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", "", ...(walletMode ? [ @@ -118,6 +120,7 @@ const server = new McpServer( "Pool accounting tools support per-user contribution tracking and monthly aggregation summaries.", "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", + "Certificate frontend tool publishes shareable subscriber certificate pages to a configurable URL/path.", ].join("\n"), } ); @@ -507,6 +510,43 @@ server.tool( } ); +// Tool: Publish subscriber certificate frontend page +server.tool( + "publish_subscriber_certificate_page", + "Publishes a user-facing HTML certificate page for a subscriber's monthly fractional attribution, returning both a public URL and local file path.", + { + month: z + .string() + .describe("Target month in YYYY-MM format"), + user_id: z + .string() + .optional() + .describe("Internal user ID"), + email: z + .string() + .optional() + .describe("User email"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async ({ month, user_id, email, customer_id }) => { + return publishSubscriberCertificatePageTool( + month, + user_id, + email, + customer_id + ); + } +); + // Tool: Retire credits — either direct on-chain execution or marketplace link server.tool( "retire_credits", diff --git a/src/services/certificate-frontend/service.ts b/src/services/certificate-frontend/service.ts new file mode 100644 index 0000000..5b218ee --- /dev/null +++ b/src/services/certificate-frontend/service.ts @@ -0,0 +1,324 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { loadConfig } from "../../config.js"; +import { AttributionDashboardService } from "../attribution/dashboard.js"; +import type { SubscriberAttributionCertificate } from "../attribution/dashboard.js"; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "entry"; +} + +function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +function formatPercent(ppm: number): string { + return `${(ppm / 10_000).toFixed(2)}%`; +} + +function buildPageHtml( + certificate: SubscriberAttributionCertificate, + publicUrl: string, + generatedAt: string +): string { + const share = formatPercent(certificate.execution.sharePpm); + const reason = escapeHtml(certificate.execution.reason); + const retirementId = certificate.execution.retirementId + ? escapeHtml(certificate.execution.retirementId) + : "N/A"; + const txHash = certificate.execution.txHash + ? escapeHtml(certificate.execution.txHash) + : "N/A"; + const email = certificate.email ? escapeHtml(certificate.email) : "N/A"; + const customerId = certificate.customerId + ? escapeHtml(certificate.customerId) + : "N/A"; + + return ` + + + + + Regenerative Contribution Certificate + + + + + + + +
+
+ Regenerative AI Membership +

Subscriber Attribution Certificate

+

+ This page verifies your fractional attribution from the pooled monthly retirement run. + Credits were retired on Regen Ledger with transparent budgeting and protocol fee accounting. +

+
+
+
Month${escapeHtml(certificate.month)}
+
Contribution${formatUsd(certificate.contributionUsdCents)}
+
Attribution Share${share}
+
Attributed Budget${formatUsd(certificate.execution.attributedBudgetUsdCents)}
+
Attributed Quantity${escapeHtml(certificate.execution.attributedQuantity)} credits
+
Execution Status${escapeHtml(certificate.execution.executionStatus)}
+
+
+

Certificate Details

+
+
User ID
${escapeHtml(certificate.userId)}
+
Email
${email}
+
Customer ID
${customerId}
+
Execution ID
${escapeHtml(certificate.execution.executionId)}
+
Retirement ID
${retirementId}
+
Transaction Hash
${txHash}
+
Retirement Reason
${reason}
+ +
+
+
+ Generated at ${escapeHtml(generatedAt)}. This attribution reflects pooled monthly retirement accounting and on-chain retirement data. +
+
+ +`; +} + +export interface PublishSubscriberCertificatePageInput { + month: string; + userId?: string; + email?: string; + customerId?: string; +} + +export interface PublishedSubscriberCertificatePage { + pageId: string; + month: string; + filePath: string; + publicUrl: string; + generatedAt: string; + certificate: SubscriberAttributionCertificate; +} + +export interface CertificateFrontendDeps { + attributionDashboard: Pick< + AttributionDashboardService, + "getSubscriberCertificateForMonth" + >; + outputDir: string; + publicBaseUrl: string; + now: () => Date; +} + +export class CertificateFrontendService { + private readonly deps: CertificateFrontendDeps; + + constructor(deps?: Partial) { + const config = loadConfig(); + this.deps = { + attributionDashboard: + deps?.attributionDashboard || new AttributionDashboardService(), + outputDir: deps?.outputDir || config.certificateOutputDir, + publicBaseUrl: deps?.publicBaseUrl || config.certificateBaseUrl, + now: deps?.now || (() => new Date()), + }; + } + + private resolvedOutputDir(): string { + return path.isAbsolute(this.deps.outputDir) + ? this.deps.outputDir + : path.resolve(process.cwd(), this.deps.outputDir); + } + + private buildPageId(certificate: SubscriberAttributionCertificate): string { + return [ + slugify(certificate.month), + slugify(certificate.userId), + slugify(certificate.execution.executionId), + ].join("-"); + } + + private buildPublicUrl(pageId: string): string { + return `${this.deps.publicBaseUrl.replace(/\/+$/, "")}/${pageId}`; + } + + async publishSubscriberCertificatePage( + input: PublishSubscriberCertificatePageInput + ): Promise { + const certificate = + await this.deps.attributionDashboard.getSubscriberCertificateForMonth({ + month: input.month, + userId: input.userId, + email: input.email, + customerId: input.customerId, + }); + + if (!certificate) return null; + + const generatedAt = this.deps.now().toISOString(); + const pageId = this.buildPageId(certificate); + const publicUrl = this.buildPublicUrl(pageId); + const html = buildPageHtml(certificate, publicUrl, generatedAt); + + const outputDir = this.resolvedOutputDir(); + await mkdir(outputDir, { recursive: true }); + const filePath = path.join(outputDir, `${pageId}.html`); + await writeFile(filePath, html, "utf8"); + + return { + pageId, + month: certificate.month, + filePath, + publicUrl, + generatedAt, + certificate, + }; + } +} diff --git a/src/tools/certificate-frontend.ts b/src/tools/certificate-frontend.ts new file mode 100644 index 0000000..58d5c23 --- /dev/null +++ b/src/tools/certificate-frontend.ts @@ -0,0 +1,68 @@ +import { CertificateFrontendService } from "../services/certificate-frontend/service.js"; + +const certificateFrontend = new CertificateFrontendService(); + +function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +export async function publishSubscriberCertificatePageTool( + month: string, + userId?: string, + email?: string, + customerId?: string +) { + try { + const published = await certificateFrontend.publishSubscriberCertificatePage({ + month, + userId, + email, + customerId, + }); + + if (!published) { + return { + content: [ + { + type: "text" as const, + text: + "No attribution certificate found for the provided user/month combination, so no certificate page was published.", + }, + ], + }; + } + + const sharePercent = (published.certificate.execution.sharePpm / 10_000).toFixed(2); + const lines = [ + "## Subscriber Certificate Page Published", + "", + "| Field | Value |", + "|-------|-------|", + `| Page ID | ${published.pageId} |`, + `| Month | ${published.month} |`, + `| User ID | ${published.certificate.userId} |`, + `| Attribution Share | ${sharePercent}% |`, + `| Attributed Budget | ${formatUsd(published.certificate.execution.attributedBudgetUsdCents)} |`, + `| Attributed Quantity | ${published.certificate.execution.attributedQuantity} credits |`, + `| Public URL | ${published.publicUrl} |`, + `| Local File | ${published.filePath} |`, + `| Generated At | ${published.generatedAt} |`, + "", + "Use this page URL in user-facing dashboards, emails, or receipts.", + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown certificate page error"; + return { + content: [ + { + type: "text" as const, + text: `Failed to publish subscriber certificate page: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/tests/certificate-frontend.test.ts b/tests/certificate-frontend.test.ts new file mode 100644 index 0000000..b475217 --- /dev/null +++ b/tests/certificate-frontend.test.ts @@ -0,0 +1,112 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { CertificateFrontendService } from "../src/services/certificate-frontend/service.js"; + +describe("CertificateFrontendService", () => { + it("publishes a subscriber certificate HTML page to disk", async () => { + const outputDir = await mkdtemp(path.join(tmpdir(), "regen-cert-")); + const getSubscriberCertificateForMonth = vi.fn().mockResolvedValue({ + userId: "user-123", + email: "user@example.com", + customerId: "cus_123", + month: "2026-03", + contributionUsdCents: 500, + contributionUsd: 5, + execution: { + month: "2026-03", + executionId: "batch_abc", + executionStatus: "success", + executedAt: "2026-03-31T00:00:00.000Z", + reason: "Monthly pool retirement", + sharePpm: 500000, + attributedBudgetUsdCents: 250, + attributedCostMicro: "2500000", + paymentDenom: "uusdc", + attributedQuantity: "1.250000", + retirementId: "WyRet123", + txHash: "TX123", + }, + }); + + const service = new CertificateFrontendService({ + attributionDashboard: { getSubscriberCertificateForMonth }, + outputDir, + publicBaseUrl: "https://regen.network/certificate", + now: () => new Date("2026-04-01T12:00:00.000Z"), + }); + + const published = await service.publishSubscriberCertificatePage({ + month: "2026-03", + userId: "user-123", + }); + + expect(published).not.toBeNull(); + expect(published?.pageId).toBe("2026-03-user-123-batch-abc"); + expect(published?.publicUrl).toBe( + "https://regen.network/certificate/2026-03-user-123-batch-abc" + ); + + const html = await readFile(published!.filePath, "utf8"); + expect(html).toContain("Subscriber Attribution Certificate"); + expect(html).toContain("Monthly pool retirement"); + expect(html).toContain("2026-03-user-123-batch-abc"); + }); + + it("returns null when no matching attribution certificate exists", async () => { + const outputDir = await mkdtemp(path.join(tmpdir(), "regen-cert-empty-")); + const getSubscriberCertificateForMonth = vi.fn().mockResolvedValue(null); + const service = new CertificateFrontendService({ + attributionDashboard: { getSubscriberCertificateForMonth }, + outputDir, + publicBaseUrl: "https://regen.network/certificate", + }); + + const published = await service.publishSubscriberCertificatePage({ + month: "2026-03", + userId: "missing-user", + }); + + expect(published).toBeNull(); + }); + + it("escapes HTML content in rendered fields", async () => { + const outputDir = await mkdtemp(path.join(tmpdir(), "regen-cert-escape-")); + const getSubscriberCertificateForMonth = vi.fn().mockResolvedValue({ + userId: "user-esc", + email: "user@example.com", + customerId: "cus_esc", + month: "2026-03", + contributionUsdCents: 300, + contributionUsd: 3, + execution: { + month: "2026-03", + executionId: "batch_escape", + executionStatus: "success", + executedAt: "2026-03-31T00:00:00.000Z", + reason: "", + sharePpm: 1000000, + attributedBudgetUsdCents: 300, + attributedCostMicro: "3000000", + paymentDenom: "uusdc", + attributedQuantity: "1.500000", + }, + }); + + const service = new CertificateFrontendService({ + attributionDashboard: { getSubscriberCertificateForMonth }, + outputDir, + publicBaseUrl: "https://regen.network/certificate", + }); + + const published = await service.publishSubscriberCertificatePage({ + month: "2026-03", + userId: "user-esc", + }); + const html = await readFile(published!.filePath, "utf8"); + + expect(html).toContain("<script>alert('x')</script>"); + expect(html).not.toContain(""); + }); +}); From fe64a3ab1bd63accedc7818101a6535a457040a3 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:46:53 -0800 Subject: [PATCH 12/31] feat: add subscriber dashboard frontend publishing tool --- .env.example | 4 + README.md | 17 + src/config.ts | 6 + src/index.ts | 32 ++ src/services/dashboard-frontend/service.ts | 419 +++++++++++++++++++++ src/tools/dashboard-frontend.ts | 78 ++++ tests/dashboard-frontend.test.ts | 141 +++++++ 7 files changed, 697 insertions(+) create mode 100644 src/services/dashboard-frontend/service.ts create mode 100644 src/tools/dashboard-frontend.ts create mode 100644 tests/dashboard-frontend.test.ts diff --git a/.env.example b/.env.example index a8ba1d3..88557f4 100644 --- a/.env.example +++ b/.env.example @@ -89,3 +89,7 @@ REGEN_DEFAULT_JURISDICTION=US # REGEN_CERTIFICATE_BASE_URL=https://regen.network/certificate # Output directory for generated certificate HTML pages # REGEN_CERTIFICATE_OUTPUT_DIR=./data/certificates +# Base URL prefix returned by publish_subscriber_dashboard_page +# REGEN_DASHBOARD_BASE_URL=https://regen.network/dashboard +# Output directory for generated dashboard HTML pages +# REGEN_DASHBOARD_OUTPUT_DIR=./data/dashboards diff --git a/README.md b/README.md index 033dd30..d985fc7 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,20 @@ Generates a user-facing HTML certificate page for a subscriber's monthly fractio **When it's used:** Publishing shareable certificate pages for dashboards, receipts, and attribution history links. +### `publish_subscriber_dashboard_page` + +Generates a user-facing HTML dashboard page for a subscriber and returns: +- a public URL (based on `REGEN_DASHBOARD_BASE_URL`), and +- the local generated file path (under `REGEN_DASHBOARD_OUTPUT_DIR`). + +Dashboard page content includes: +- lifetime contribution and attributed impact totals, +- monthly contribution/attribution history, +- recent attribution executions with certificate links, +- subscription status snapshot when Stripe lookup is available. + +**When it's used:** Publishing the user account impact dashboard page for web navigation or direct sharing. + ### `retire_credits` Retires ecocredits on Regen Network. Operates in two modes: @@ -359,6 +373,9 @@ export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json # optional subscriber certificate frontend settings export REGEN_CERTIFICATE_BASE_URL=https://regen.network/certificate export REGEN_CERTIFICATE_OUTPUT_DIR=./data/certificates +# optional subscriber dashboard frontend settings +export REGEN_DASHBOARD_BASE_URL=https://regen.network/dashboard +export REGEN_DASHBOARD_OUTPUT_DIR=./data/dashboards # optional protocol fee basis points for monthly pool budgets (800-1200, default 1000) export REGEN_PROTOCOL_FEE_BPS=1000 # optional batch credit mix policy when credit_type is omitted: balanced | off diff --git a/src/config.ts b/src/config.ts index 1974534..591fc5c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,8 @@ export interface Config { defaultJurisdiction: string; certificateBaseUrl: string; certificateOutputDir: string; + dashboardBaseUrl: string; + dashboardOutputDir: string; protocolFeeBps: number; // ecoBridge integration (Phase 1.5) @@ -145,6 +147,10 @@ export function loadConfig(): Config { "https://regen.network/certificate", certificateOutputDir: process.env.REGEN_CERTIFICATE_OUTPUT_DIR || "data/certificates", + dashboardBaseUrl: + process.env.REGEN_DASHBOARD_BASE_URL || "https://regen.network/dashboard", + dashboardOutputDir: + process.env.REGEN_DASHBOARD_OUTPUT_DIR || "data/dashboards", protocolFeeBps: parseProtocolFeeBps(process.env.REGEN_PROTOCOL_FEE_BPS), ecoBridgeApiUrl: diff --git a/src/index.ts b/src/index.ts index db30807..e8edc36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { getSubscriberImpactDashboardTool, } from "./tools/attribution-dashboard.js"; import { publishSubscriberCertificatePageTool } from "./tools/certificate-frontend.js"; +import { publishSubscriberDashboardPageTool } from "./tools/dashboard-frontend.js"; import { loadConfig, isWalletConfigured } from "./config.js"; import { fetchRegistry, @@ -108,6 +109,7 @@ const server = new McpServer( "7. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", "8. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", "9. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", + "10. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", "", ...(walletMode ? [ @@ -121,6 +123,7 @@ const server = new McpServer( "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", "Certificate frontend tool publishes shareable subscriber certificate pages to a configurable URL/path.", + "Dashboard frontend tool publishes shareable subscriber impact dashboard pages to a configurable URL/path.", ].join("\n"), } ); @@ -547,6 +550,35 @@ server.tool( } ); +// Tool: Publish subscriber dashboard frontend page +server.tool( + "publish_subscriber_dashboard_page", + "Publishes a user-facing HTML dashboard page with contribution totals, attribution history, and subscription state, returning both a public URL and local file path.", + { + user_id: z + .string() + .optional() + .describe("Internal user ID"), + email: z + .string() + .optional() + .describe("User email"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async ({ user_id, email, customer_id }) => { + return publishSubscriberDashboardPageTool(user_id, email, customer_id); + } +); + // Tool: Retire credits — either direct on-chain execution or marketplace link server.tool( "retire_credits", diff --git a/src/services/dashboard-frontend/service.ts b/src/services/dashboard-frontend/service.ts new file mode 100644 index 0000000..f22f72b --- /dev/null +++ b/src/services/dashboard-frontend/service.ts @@ -0,0 +1,419 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { loadConfig } from "../../config.js"; +import { + AttributionDashboardService, + type SubscriberImpactDashboard, +} from "../attribution/dashboard.js"; +import { + StripeSubscriptionService, +} from "../subscription/stripe.js"; +import type { SubscriptionState } from "../subscription/types.js"; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "dashboard"; +} + +function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +function formatPercent(ppm: number): string { + return `${(ppm / 10_000).toFixed(2)}%`; +} + +function buildSubscriptionSummary( + state: SubscriptionState | null, + errorMessage?: string +): { + status: string; + tier: string; + periodEnd: string; + customerId: string; + note: string; +} { + if (!state && errorMessage) { + return { + status: "unavailable", + tier: "N/A", + periodEnd: "N/A", + customerId: "N/A", + note: `Subscription status unavailable: ${errorMessage}`, + }; + } + + const status = state?.status || "none"; + const tier = state?.tierId || "N/A"; + const periodEnd = state?.currentPeriodEnd || "N/A"; + const customerId = state?.customerId || "N/A"; + const note = + status === "none" + ? "No active Stripe subscription found for this identity." + : state?.cancelAtPeriodEnd + ? "Subscription is set to cancel at period end." + : "Subscription is active in recurring mode."; + + return { + status, + tier, + periodEnd, + customerId, + note, + }; +} + +function buildDashboardHtml(input: { + dashboard: SubscriberImpactDashboard; + publicUrl: string; + generatedAt: string; + subscriptionSummary: { + status: string; + tier: string; + periodEnd: string; + customerId: string; + note: string; + }; + certificateBaseUrl: string; +}): string { + const { dashboard, publicUrl, generatedAt, subscriptionSummary, certificateBaseUrl } = + input; + + const attributionRows = dashboard.attributions + .slice(0, 24) + .map((entry) => { + const certUrl = `${certificateBaseUrl.replace(/\/+$/, "")}/${escapeHtml(`${slugify(entry.month)}-${slugify(dashboard.userId)}-${slugify(entry.executionId)}`)}`; + return ` + ${escapeHtml(entry.month)} + ${formatPercent(entry.sharePpm)} + ${formatUsd(entry.attributedBudgetUsdCents)} + ${escapeHtml(entry.attributedQuantity)} + ${entry.retirementId ? escapeHtml(entry.retirementId) : "N/A"} + certificate + `; + }) + .join(""); + + const monthlyRows = dashboard.byMonth + .map( + (month) => ` + ${escapeHtml(month.month)} + ${formatUsd(month.contributionUsdCents)} + ${formatUsd(month.attributedBudgetUsdCents)} + ${escapeHtml(month.attributedQuantity)} + ` + ) + .join(""); + + return ` + + + + + Subscriber Impact Dashboard + + + + + + + +
+
+ Regenerative AI Membership +

Subscriber Impact Dashboard

+

+ Unified view of contributions, monthly pooled retirement attribution, and current subscription status. +

+
+
+
User ID${escapeHtml(dashboard.userId)}
+
Total Contributed${formatUsd(dashboard.totalContributedUsdCents)}
+
Attributed Budget${formatUsd(dashboard.totalAttributedBudgetUsdCents)}
+
Attributed Quantity${escapeHtml(dashboard.totalAttributedQuantity)} credits
+
+
+

Subscription Status

+ + + + + + + + + +
StatusTierCustomer IDPeriod EndNotes
${escapeHtml(subscriptionSummary.status)}${escapeHtml(subscriptionSummary.tier)}${escapeHtml(subscriptionSummary.customerId)}${escapeHtml(subscriptionSummary.periodEnd)}${escapeHtml(subscriptionSummary.note)}
+
+
+

Monthly Contribution History

+ + + ${monthlyRows} +
MonthContributedAttributed BudgetAttributed Quantity
+
+
+

Recent Attribution Executions

+ + + ${attributionRows || ``} +
MonthShareAttributed BudgetQuantityRetirement IDCertificate
No attribution executions yet.
+
+
+ Dashboard URL: ${escapeHtml(publicUrl)}
+ Email: ${dashboard.email ? escapeHtml(dashboard.email) : "N/A"} · Customer ID: ${dashboard.customerId ? escapeHtml(dashboard.customerId) : "N/A"}
+ Generated at ${escapeHtml(generatedAt)}. +
+
+ +`; +} + +export interface PublishSubscriberDashboardPageInput { + userId?: string; + email?: string; + customerId?: string; +} + +export interface PublishedSubscriberDashboardPage { + pageId: string; + filePath: string; + publicUrl: string; + generatedAt: string; + dashboard: SubscriberImpactDashboard; + subscriptionState?: SubscriptionState | null; + subscriptionError?: string; +} + +export interface DashboardFrontendDeps { + attributionDashboard: Pick; + subscriptions: Pick; + outputDir: string; + publicBaseUrl: string; + certificateBaseUrl: string; + now: () => Date; +} + +export class DashboardFrontendService { + private readonly deps: DashboardFrontendDeps; + + constructor(deps?: Partial) { + const config = loadConfig(); + this.deps = { + attributionDashboard: + deps?.attributionDashboard || new AttributionDashboardService(), + subscriptions: deps?.subscriptions || new StripeSubscriptionService(), + outputDir: deps?.outputDir || config.dashboardOutputDir, + publicBaseUrl: deps?.publicBaseUrl || config.dashboardBaseUrl, + certificateBaseUrl: deps?.certificateBaseUrl || config.certificateBaseUrl, + now: deps?.now || (() => new Date()), + }; + } + + private resolvedOutputDir(): string { + return path.isAbsolute(this.deps.outputDir) + ? this.deps.outputDir + : path.resolve(process.cwd(), this.deps.outputDir); + } + + private buildPageId(dashboard: SubscriberImpactDashboard): string { + return `subscriber-${slugify(dashboard.userId)}`; + } + + private buildPublicUrl(pageId: string): string { + return `${this.deps.publicBaseUrl.replace(/\/+$/, "")}/${pageId}`; + } + + async publishSubscriberDashboardPage( + input: PublishSubscriberDashboardPageInput + ): Promise { + const dashboard = await this.deps.attributionDashboard.getSubscriberDashboard({ + userId: input.userId, + email: input.email, + customerId: input.customerId, + }); + if (!dashboard) return null; + + let subscriptionState: SubscriptionState | null = null; + let subscriptionError: string | undefined; + try { + subscriptionState = await this.deps.subscriptions.getSubscriptionState({ + email: dashboard.email, + customerId: dashboard.customerId, + }); + } catch (error) { + subscriptionError = + error instanceof Error ? error.message : "Unknown subscription error"; + } + + const generatedAt = this.deps.now().toISOString(); + const pageId = this.buildPageId(dashboard); + const publicUrl = this.buildPublicUrl(pageId); + const subscriptionSummary = buildSubscriptionSummary( + subscriptionState, + subscriptionError + ); + const html = buildDashboardHtml({ + dashboard, + publicUrl, + generatedAt, + subscriptionSummary, + certificateBaseUrl: this.deps.certificateBaseUrl, + }); + + const outputDir = this.resolvedOutputDir(); + await mkdir(outputDir, { recursive: true }); + const filePath = path.join(outputDir, `${pageId}.html`); + await writeFile(filePath, html, "utf8"); + + return { + pageId, + filePath, + publicUrl, + generatedAt, + dashboard, + subscriptionState, + subscriptionError, + }; + } +} diff --git a/src/tools/dashboard-frontend.ts b/src/tools/dashboard-frontend.ts new file mode 100644 index 0000000..29a4014 --- /dev/null +++ b/src/tools/dashboard-frontend.ts @@ -0,0 +1,78 @@ +import { DashboardFrontendService } from "../services/dashboard-frontend/service.js"; + +const dashboardFrontend = new DashboardFrontendService(); + +function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +export async function publishSubscriberDashboardPageTool( + userId?: string, + email?: string, + customerId?: string +) { + try { + const published = await dashboardFrontend.publishSubscriberDashboardPage({ + userId, + email, + customerId, + }); + + if (!published) { + return { + content: [ + { + type: "text" as const, + text: + "No subscriber dashboard data found for the provided identifier, so no dashboard page was published.", + }, + ], + }; + } + + const lines = [ + "## Subscriber Dashboard Page Published", + "", + "| Field | Value |", + "|-------|-------|", + `| Page ID | ${published.pageId} |`, + `| User ID | ${published.dashboard.userId} |`, + `| Contribution Count | ${published.dashboard.contributionCount} |`, + `| Total Contributed | ${formatUsd(published.dashboard.totalContributedUsdCents)} |`, + `| Total Attributed Budget | ${formatUsd(published.dashboard.totalAttributedBudgetUsdCents)} |`, + `| Attributed Quantity | ${published.dashboard.totalAttributedQuantity} credits |`, + `| Public URL | ${published.publicUrl} |`, + `| Local File | ${published.filePath} |`, + `| Generated At | ${published.generatedAt} |`, + ]; + + if (published.subscriptionState) { + lines.push( + `| Subscription Status | ${published.subscriptionState.status} |`, + `| Subscription Tier | ${published.subscriptionState.tierId || "N/A"} |`, + `| Subscription Period End | ${published.subscriptionState.currentPeriodEnd || "N/A"} |` + ); + } else if (published.subscriptionError) { + lines.push(`| Subscription Status | unavailable (${published.subscriptionError}) |`); + } + + lines.push( + "", + "Use this page URL in product navigation and user account surfaces." + ); + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown dashboard page error"; + return { + content: [ + { + type: "text" as const, + text: `Failed to publish subscriber dashboard page: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/tests/dashboard-frontend.test.ts b/tests/dashboard-frontend.test.ts new file mode 100644 index 0000000..f06cf40 --- /dev/null +++ b/tests/dashboard-frontend.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { DashboardFrontendService } from "../src/services/dashboard-frontend/service.js"; + +describe("DashboardFrontendService", () => { + it("publishes a subscriber dashboard page with subscription status", async () => { + const outputDir = await mkdtemp(path.join(tmpdir(), "regen-dash-")); + const getSubscriberDashboard = vi.fn().mockResolvedValue({ + userId: "user-123", + email: "user@example.com", + customerId: "cus_123", + contributionCount: 4, + totalContributedUsdCents: 1200, + totalContributedUsd: 12, + totalAttributedBudgetUsdCents: 980, + totalAttributedBudgetUsd: 9.8, + totalAttributedQuantity: "4.200000", + attributionCount: 2, + byMonth: [ + { + month: "2026-03", + contributionUsdCents: 600, + contributionUsd: 6, + attributedBudgetUsdCents: 490, + attributedBudgetUsd: 4.9, + attributedQuantity: "2.100000", + }, + ], + attributions: [ + { + month: "2026-03", + executionId: "batch_abc", + executionStatus: "success", + executedAt: "2026-03-31T00:00:00.000Z", + reason: "Monthly pool retirement", + sharePpm: 500000, + attributedBudgetUsdCents: 490, + attributedCostMicro: "4900000", + paymentDenom: "uusdc", + attributedQuantity: "2.100000", + retirementId: "WyRet1", + txHash: "TX1", + }, + ], + }); + const getSubscriptionState = vi.fn().mockResolvedValue({ + customerId: "cus_123", + email: "user@example.com", + subscriptionId: "sub_123", + status: "active", + tierId: "growth", + priceId: "price_growth", + cancelAtPeriodEnd: false, + currentPeriodEnd: "2026-05-01T00:00:00.000Z", + }); + + const service = new DashboardFrontendService({ + attributionDashboard: { getSubscriberDashboard }, + subscriptions: { getSubscriptionState }, + outputDir, + publicBaseUrl: "https://regen.network/dashboard", + certificateBaseUrl: "https://regen.network/certificate", + now: () => new Date("2026-04-01T12:00:00.000Z"), + }); + + const published = await service.publishSubscriberDashboardPage({ + userId: "user-123", + }); + + expect(published).not.toBeNull(); + expect(published?.pageId).toBe("subscriber-user-123"); + expect(published?.publicUrl).toBe("https://regen.network/dashboard/subscriber-user-123"); + expect(published?.subscriptionState?.status).toBe("active"); + + const html = await readFile(published!.filePath, "utf8"); + expect(html).toContain("Subscriber Impact Dashboard"); + expect(html).toContain("Subscription Status"); + expect(html).toContain("growth"); + expect(html).toContain("certificate"); + }); + + it("returns null when no subscriber impact dashboard exists", async () => { + const outputDir = await mkdtemp(path.join(tmpdir(), "regen-dash-empty-")); + const getSubscriberDashboard = vi.fn().mockResolvedValue(null); + const getSubscriptionState = vi.fn(); + const service = new DashboardFrontendService({ + attributionDashboard: { getSubscriberDashboard }, + subscriptions: { getSubscriptionState }, + outputDir, + publicBaseUrl: "https://regen.network/dashboard", + certificateBaseUrl: "https://regen.network/certificate", + }); + + const published = await service.publishSubscriberDashboardPage({ + userId: "missing-user", + }); + + expect(published).toBeNull(); + expect(getSubscriptionState).not.toHaveBeenCalled(); + }); + + it("still publishes when subscription lookup fails", async () => { + const outputDir = await mkdtemp(path.join(tmpdir(), "regen-dash-suberr-")); + const getSubscriberDashboard = vi.fn().mockResolvedValue({ + userId: "user-abc", + contributionCount: 1, + totalContributedUsdCents: 100, + totalContributedUsd: 1, + totalAttributedBudgetUsdCents: 90, + totalAttributedBudgetUsd: 0.9, + totalAttributedQuantity: "0.500000", + attributionCount: 0, + byMonth: [], + attributions: [], + }); + const getSubscriptionState = vi + .fn() + .mockRejectedValue(new Error("Missing STRIPE_SECRET_KEY")); + + const service = new DashboardFrontendService({ + attributionDashboard: { getSubscriberDashboard }, + subscriptions: { getSubscriptionState }, + outputDir, + publicBaseUrl: "https://regen.network/dashboard", + certificateBaseUrl: "https://regen.network/certificate", + }); + + const published = await service.publishSubscriberDashboardPage({ + userId: "user-abc", + }); + + expect(published).not.toBeNull(); + expect(published?.subscriptionState).toBeNull(); + expect(published?.subscriptionError).toContain("Missing STRIPE_SECRET_KEY"); + + const html = await readFile(published!.filePath, "utf8"); + expect(html).toContain("Subscription status unavailable"); + }); +}); From 8023cd37154c2af346d496b188a024fbad12557b Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:55:16 -0800 Subject: [PATCH 13/31] feat: complete unit 16 auth hardening and retire integration --- .env.example | 16 +- README.md | 44 ++- src/index.ts | 187 ++++++++++- src/services/auth/service.ts | 584 +++++++++++++++++++++++++++++++++++ src/services/auth/store.ts | 116 +++++++ src/tools/auth.ts | 325 +++++++++++++++++++ src/tools/retire.ts | 57 +++- tests/auth-session.test.ts | 219 +++++++++++++ tests/retire-credits.test.ts | 92 ++++++ 9 files changed, 1624 insertions(+), 16 deletions(-) create mode 100644 src/services/auth/service.ts create mode 100644 src/services/auth/store.ts create mode 100644 src/tools/auth.ts create mode 100644 tests/auth-session.test.ts diff --git a/.env.example b/.env.example index 88557f4..5681c18 100644 --- a/.env.example +++ b/.env.example @@ -57,9 +57,19 @@ REGEN_DEFAULT_JURISDICTION=US # Required when REGEN_BURN_PROVIDER=onchain # REGEN_BURN_ADDRESS=regen1... -# Authentication (OAuth - for user identity on retirement certificates) -# OAUTH_CLIENT_ID= -# OAUTH_CLIENT_SECRET= +# Identity auth hardening (email/OAuth verification sessions + recovery) +# Optional: auth state JSON file path (default: ./data/auth-state.json) +# REGEN_AUTH_STORE_PATH=./data/auth-state.json +# Strongly recommended: shared secret used for hashing/signing auth codes/state/tokens +# REGEN_AUTH_SECRET=replace-with-long-random-secret +# Optional: auth session TTL in seconds (default: 900) +# REGEN_AUTH_SESSION_TTL_SECONDS=900 +# Optional: max verification attempts per session before lock (default: 5) +# REGEN_AUTH_MAX_ATTEMPTS=5 +# Optional: recovery token TTL in seconds (default: 86400) +# REGEN_AUTH_RECOVERY_TTL_SECONDS=86400 +# Optional: allowed OAuth providers CSV (default: google,github) +# REGEN_AUTH_ALLOWED_OAUTH_PROVIDERS=google,github # Optional: Stripe payment intent settings (required if REGEN_PAYMENT_PROVIDER=stripe) # STRIPE_SECRET_KEY= diff --git a/README.md b/README.md index d985fc7..58a215e 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,34 @@ Dashboard page content includes: **When it's used:** Publishing the user account impact dashboard page for web navigation or direct sharing. +### `start_identity_auth_session` + +Starts a hardened identity verification session using either: +- `method=email` (issues a one-time verification code), or +- `method=oauth` (issues a signed OAuth state token). + +### `verify_identity_auth_session` + +Verifies a pending identity session: +- email sessions require `verification_code`, +- oauth sessions require `oauth_state_token`, `auth_provider`, and `auth_subject`. + +Returns a verified `auth_session_id` that can be passed into `retire_credits`. + +### `get_identity_auth_session` + +Returns session status (`pending`, `verified`, `expired`, `locked`) and attribution metadata. + +### `link_identity_session` + +Links a verified auth session to an internal `user_id` for long-lived attribution continuity. + +### `recover_identity_session` + +Provides recovery flow for verified identities: +- `action=start` issues a recovery token for a verified email identity, +- `action=complete` consumes that token and mints a fresh verified auth session. + ### `retire_credits` Retires ecocredits on Regen Network. Operates in two modes: @@ -258,11 +286,13 @@ When `ECOBRIDGE_ENABLED=true`, the fallback message also suggests `retire_via_ec | `beneficiary_email` | Email for retirement attribution metadata. Optional. | | `auth_provider` | OAuth provider for identity attribution (e.g., `google`, `github`). Optional (requires `auth_subject`). | | `auth_subject` | OAuth user subject/ID for attribution metadata. Optional (requires `auth_provider`). | +| `auth_session_id` | Verified identity auth session ID. When provided, verified session identity overrides direct auth/email fields. | | `jurisdiction` | Retirement jurisdiction (ISO 3166-1, e.g., 'US', 'DE'). Optional. | | `reason` | Reason for retiring credits (recorded on-chain). Optional. | **When it's used:** The user wants to take action and actually fund ecological regeneration. -When provided, identity attribution fields are embedded into retirement reason metadata so `get_retirement_certificate` can display user attribution details later. + +Identity attribution fields are embedded into retirement reason metadata so `get_retirement_certificate` can display user attribution details later. ### `browse_ecobridge_tokens` @@ -376,6 +406,18 @@ export REGEN_CERTIFICATE_OUTPUT_DIR=./data/certificates # optional subscriber dashboard frontend settings export REGEN_DASHBOARD_BASE_URL=https://regen.network/dashboard export REGEN_DASHBOARD_OUTPUT_DIR=./data/dashboards +# optional identity auth store path +export REGEN_AUTH_STORE_PATH=./data/auth-state.json +# strongly recommended shared secret for auth code/state/recovery signing +export REGEN_AUTH_SECRET=replace-with-long-random-secret +# optional auth session TTL in seconds (default 900) +export REGEN_AUTH_SESSION_TTL_SECONDS=900 +# optional max verification attempts before lock (default 5) +export REGEN_AUTH_MAX_ATTEMPTS=5 +# optional recovery token TTL in seconds (default 86400) +export REGEN_AUTH_RECOVERY_TTL_SECONDS=86400 +# optional allowed OAuth providers CSV (default google,github) +export REGEN_AUTH_ALLOWED_OAUTH_PROVIDERS=google,github # optional protocol fee basis points for monthly pool budgets (800-1200, default 1000) export REGEN_PROTOCOL_FEE_BPS=1000 # optional batch credit mix policy when credit_type is omitted: balanced | off diff --git a/src/index.ts b/src/index.ts index e8edc36..20aec8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,13 @@ import { } from "./tools/attribution-dashboard.js"; import { publishSubscriberCertificatePageTool } from "./tools/certificate-frontend.js"; import { publishSubscriberDashboardPageTool } from "./tools/dashboard-frontend.js"; +import { + getIdentityAuthSessionTool, + linkIdentitySessionTool, + recoverIdentitySessionTool, + startIdentityAuthSessionTool, + verifyIdentityAuthSessionTool, +} from "./tools/auth.js"; import { loadConfig, isWalletConfigured } from "./config.js"; import { fetchRegistry, @@ -110,6 +117,8 @@ const server = new McpServer( "8. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", "9. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", "10. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", + "11. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", + "12. link_identity_session / recover_identity_session — identity linking and recovery flows", "", ...(walletMode ? [ @@ -124,6 +133,7 @@ const server = new McpServer( "Subscriber dashboard tools expose fractional attribution and impact history per user.", "Certificate frontend tool publishes shareable subscriber certificate pages to a configurable URL/path.", "Dashboard frontend tool publishes shareable subscriber impact dashboard pages to a configurable URL/path.", + "Identity auth tools support verified email/OAuth attribution sessions with expiry, attempt limits, linking, and recovery.", ].join("\n"), } ); @@ -579,6 +589,173 @@ server.tool( } ); +// Tool: Start identity auth session +server.tool( + "start_identity_auth_session", + "Starts an identity verification session using email or OAuth. Returns session metadata and the challenge material needed to verify.", + { + method: z + .enum(["email", "oauth"]) + .describe("Auth method to start"), + beneficiary_email: z + .string() + .optional() + .describe("Beneficiary email (required for method=email, optional for method=oauth)"), + beneficiary_name: z + .string() + .optional() + .describe("Optional beneficiary display name"), + auth_provider: z + .string() + .optional() + .describe("OAuth provider (required for method=oauth)"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async ({ method, beneficiary_email, beneficiary_name, auth_provider }) => { + return startIdentityAuthSessionTool( + method, + beneficiary_email, + beneficiary_name, + auth_provider + ); + } +); + +// Tool: Verify identity auth session +server.tool( + "verify_identity_auth_session", + "Verifies an identity auth session. Email sessions require verification_code; OAuth sessions require oauth_state_token + auth_provider + auth_subject.", + { + session_id: z + .string() + .describe("Identity auth session ID"), + method: z + .enum(["email", "oauth"]) + .describe("Session method"), + verification_code: z + .string() + .optional() + .describe("Email verification code (required for method=email)"), + oauth_state_token: z + .string() + .optional() + .describe("OAuth state token issued at session start (required for method=oauth)"), + auth_provider: z + .string() + .optional() + .describe("OAuth provider (required for method=oauth)"), + auth_subject: z + .string() + .optional() + .describe("OAuth subject/user ID (required for method=oauth)"), + beneficiary_email: z + .string() + .optional() + .describe("Optional verified email to store during oauth verification"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async ({ + session_id, + method, + verification_code, + oauth_state_token, + auth_provider, + auth_subject, + beneficiary_email, + }) => { + return verifyIdentityAuthSessionTool({ + sessionId: session_id, + method, + verificationCode: verification_code, + oauthStateToken: oauth_state_token, + authProvider: auth_provider, + authSubject: auth_subject, + beneficiaryEmail: beneficiary_email, + }); + } +); + +// Tool: Get identity auth session status +server.tool( + "get_identity_auth_session", + "Returns status and metadata for an identity auth session (pending/verified/expired/locked).", + { + session_id: z + .string() + .describe("Identity auth session ID"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async ({ session_id }) => { + return getIdentityAuthSessionTool(session_id); + } +); + +// Tool: Link verified identity session to internal user +server.tool( + "link_identity_session", + "Links a verified identity auth session to an internal user ID for attribution continuity.", + { + session_id: z + .string() + .describe("Verified auth session ID"), + user_id: z + .string() + .describe("Internal user ID to link"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async ({ session_id, user_id }) => { + return linkIdentitySessionTool(session_id, user_id); + } +); + +// Tool: Start or complete identity recovery +server.tool( + "recover_identity_session", + "Handles identity session recovery. action=start issues a recovery token by verified email; action=complete consumes token and issues a fresh verified session.", + { + action: z + .enum(["start", "complete"]) + .describe("Recovery action"), + beneficiary_email: z + .string() + .optional() + .describe("Verified beneficiary email (required for action=start)"), + recovery_token: z + .string() + .optional() + .describe("Recovery token (required for action=complete)"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async ({ action, beneficiary_email, recovery_token }) => { + return recoverIdentitySessionTool(action, beneficiary_email, recovery_token); + } +); + // Tool: Retire credits — either direct on-chain execution or marketplace link server.tool( "retire_credits", @@ -612,6 +789,12 @@ server.tool( .string() .optional() .describe("OAuth subject/user ID for identity attribution"), + auth_session_id: z + .string() + .optional() + .describe( + "Verified identity auth session ID from verify_identity_auth_session; overrides direct auth/email fields when present" + ), jurisdiction: z .string() .optional() @@ -636,6 +819,7 @@ server.tool( beneficiary_email, auth_provider, auth_subject, + auth_session_id, jurisdiction, reason, }) => { @@ -647,7 +831,8 @@ server.tool( reason, beneficiary_email, auth_provider, - auth_subject + auth_subject, + auth_session_id ); } ); diff --git a/src/services/auth/service.ts b/src/services/auth/service.ts new file mode 100644 index 0000000..49cc6f0 --- /dev/null +++ b/src/services/auth/service.ts @@ -0,0 +1,584 @@ +import { + createHash, + createHmac, + randomInt, + randomUUID, + timingSafeEqual, +} from "node:crypto"; +import { captureIdentity, type IdentityAttribution } from "../identity.js"; +import { + JsonFileAuthStore, + type AuthSessionMethod, + type AuthSessionRecord, + type AuthSessionStatus, + type AuthState, + type AuthStore, + type IdentityLinkRecord, + type RecoveryTokenRecord, +} from "./store.js"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const DEFAULT_SESSION_TTL_SECONDS = 15 * 60; +const DEFAULT_RECOVERY_TTL_SECONDS = 24 * 60 * 60; +const DEFAULT_MAX_ATTEMPTS = 5; +const DEFAULT_ALLOWED_OAUTH_PROVIDERS = ["google", "github"]; +const DEFAULT_AUTH_SECRET = "regen-dev-auth-secret-change-me"; + +function normalize(value?: string | null): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeEmail(value?: string | null): string | undefined { + const email = normalize(value)?.toLowerCase(); + if (!email) return undefined; + return email; +} + +function parseIntEnv(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function splitCsv(value?: string): string[] { + return (value || "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function normalizeProviders(value?: string[]): string[] { + return (value || []) + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function toIsoDate(now: Date, secondsToAdd: number): string { + return new Date(now.getTime() + secondsToAdd * 1000).toISOString(); +} + +function isExpired(now: Date, expiresAt: string): boolean { + return new Date(expiresAt).getTime() <= now.getTime(); +} + +function toBuffer(input: string): Buffer { + return Buffer.from(input, "utf8"); +} + +function safeEquals(a: string, b: string): boolean { + const aBuf = toBuffer(a); + const bBuf = toBuffer(b); + if (aBuf.length !== bBuf.length) return false; + return timingSafeEqual(aBuf, bBuf); +} + +export interface AuthSessionPublic { + id: string; + method: AuthSessionMethod; + status: AuthSessionStatus; + createdAt: string; + expiresAt: string; + verifiedAt?: string; + beneficiaryName?: string; + beneficiaryEmail?: string; + authProvider?: string; + authSubject?: string; + verificationAttempts: number; + maxVerificationAttempts: number; +} + +export interface StartEmailAuthResult { + session: AuthSessionPublic; + verificationCode: string; +} + +export interface StartOAuthAuthResult { + session: AuthSessionPublic; + oauthStateToken: string; +} + +export interface VerifyIdentityResult { + session: AuthSessionPublic; + identity: IdentityAttribution; +} + +export interface StartRecoveryResult { + recoveryToken: string; + expiresAt: string; + sessionId: string; +} + +export interface RecoverIdentityResult { + session: AuthSessionPublic; +} + +export interface AuthServiceDeps { + store: AuthStore; + now: () => Date; + secret: string; + sessionTtlSeconds: number; + recoveryTtlSeconds: number; + maxVerificationAttempts: number; + allowedOauthProviders: string[]; +} + +function stripSession(session: AuthSessionRecord): AuthSessionPublic { + return { + id: session.id, + method: session.method, + status: session.status, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + verifiedAt: session.verifiedAt, + beneficiaryName: session.beneficiaryName, + beneficiaryEmail: session.beneficiaryEmail, + authProvider: session.authProvider, + authSubject: session.authSubject, + verificationAttempts: session.verificationAttempts, + maxVerificationAttempts: session.maxVerificationAttempts, + }; +} + +export class AuthSessionService { + private readonly deps: AuthServiceDeps; + + constructor(deps?: Partial) { + const depAllowedProviders = normalizeProviders(deps?.allowedOauthProviders); + const envAllowedProviders = splitCsv(process.env.REGEN_AUTH_ALLOWED_OAUTH_PROVIDERS); + const allowedOauthProviders = + depAllowedProviders.length > 0 + ? depAllowedProviders + : envAllowedProviders.length > 0 + ? envAllowedProviders + : DEFAULT_ALLOWED_OAUTH_PROVIDERS; + + this.deps = { + store: deps?.store || new JsonFileAuthStore(), + now: deps?.now || (() => new Date()), + secret: + normalize(deps?.secret) || + normalize(process.env.REGEN_AUTH_SECRET) || + DEFAULT_AUTH_SECRET, + sessionTtlSeconds: + deps?.sessionTtlSeconds || + parseIntEnv("REGEN_AUTH_SESSION_TTL_SECONDS", DEFAULT_SESSION_TTL_SECONDS), + recoveryTtlSeconds: + deps?.recoveryTtlSeconds || + parseIntEnv( + "REGEN_AUTH_RECOVERY_TTL_SECONDS", + DEFAULT_RECOVERY_TTL_SECONDS + ), + maxVerificationAttempts: + deps?.maxVerificationAttempts || + parseIntEnv("REGEN_AUTH_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS), + allowedOauthProviders, + }; + } + + private sessionId(): string { + return `auth_${randomUUID()}`; + } + + private recoveryId(): string { + return `recovery_${randomUUID()}`; + } + + private hash(value: string): string { + return createHash("sha256") + .update(this.deps.secret) + .update(":") + .update(value) + .digest("hex"); + } + + private sign(payload: string): string { + return createHmac("sha256", this.deps.secret).update(payload).digest("base64url"); + } + + private issueOauthStateToken(input: { sessionId: string; expiresAt: string }): string { + const payload = Buffer.from( + JSON.stringify({ sid: input.sessionId, exp: input.expiresAt }), + "utf8" + ).toString("base64url"); + const sig = this.sign(payload); + return `${payload}.${sig}`; + } + + private validateOauthStateToken(session: AuthSessionRecord, token: string): void { + const [payload, sig] = token.split("."); + if (!payload || !sig) throw new Error("Invalid oauth_state_token format"); + const expected = this.sign(payload); + if (!safeEquals(sig, expected)) { + throw new Error("Invalid oauth_state_token signature"); + } + const decoded = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8") + ) as { sid?: string; exp?: string }; + if (!decoded.sid || decoded.sid !== session.id) { + throw new Error("oauth_state_token session mismatch"); + } + if (!decoded.exp || isExpired(this.deps.now(), decoded.exp)) { + throw new Error("oauth_state_token is expired"); + } + } + + private createEmailCode(): string { + const value = randomInt(0, 1_000_000); + return value.toString().padStart(6, "0"); + } + + private requireSession(state: AuthState, sessionId: string): AuthSessionRecord { + const session = state.sessions.find((item) => item.id === sessionId); + if (!session) { + throw new Error(`Unknown auth session: ${sessionId}`); + } + return session; + } + + private mutateSessionStatusForTime(session: AuthSessionRecord): void { + if ( + session.status === "pending" && + isExpired(this.deps.now(), session.expiresAt) + ) { + session.status = "expired"; + } + } + + private ensureVerifiableSession(session: AuthSessionRecord, method: AuthSessionMethod): void { + this.mutateSessionStatusForTime(session); + + if (session.method !== method) { + throw new Error(`Auth session method mismatch. Expected ${method}.`); + } + if (session.status === "locked") { + throw new Error("Auth session is locked after too many failed attempts"); + } + if (session.status === "expired") { + throw new Error("Auth session is expired"); + } + if (session.status === "verified") { + throw new Error("Auth session is already verified"); + } + } + + private buildIdentityFromSession(session: AuthSessionRecord): IdentityAttribution { + const identity = captureIdentity({ + beneficiaryName: session.beneficiaryName, + beneficiaryEmail: session.beneficiaryEmail, + authProvider: session.authProvider, + authSubject: session.authSubject, + }); + if (identity.authMethod === "none") { + throw new Error("Verified auth session has no identity payload"); + } + return identity; + } + + async startEmailAuth(input: { + beneficiaryEmail: string; + beneficiaryName?: string; + }): Promise { + const email = normalizeEmail(input.beneficiaryEmail); + if (!email || !EMAIL_PATTERN.test(email)) { + throw new Error("beneficiary_email must be a valid email"); + } + + const now = this.deps.now(); + const code = this.createEmailCode(); + const session: AuthSessionRecord = { + id: this.sessionId(), + method: "email", + status: "pending", + createdAt: now.toISOString(), + expiresAt: toIsoDate(now, this.deps.sessionTtlSeconds), + beneficiaryName: normalize(input.beneficiaryName), + beneficiaryEmail: email, + emailCodeHash: this.hash(`${code}:${email}`), + verificationAttempts: 0, + maxVerificationAttempts: this.deps.maxVerificationAttempts, + }; + + const state = await this.deps.store.readState(); + state.sessions.push(session); + await this.deps.store.writeState(state); + + return { + session: stripSession(session), + verificationCode: code, + }; + } + + async verifyEmailAuth(input: { + sessionId: string; + verificationCode: string; + }): Promise { + const code = normalize(input.verificationCode); + if (!code || !/^\d{6}$/.test(code)) { + throw new Error("verification_code must be a 6-digit code"); + } + + const state = await this.deps.store.readState(); + const session = this.requireSession(state, input.sessionId); + this.ensureVerifiableSession(session, "email"); + + const email = session.beneficiaryEmail; + if (!email || !session.emailCodeHash) { + throw new Error("Email auth session is missing challenge data"); + } + + const actual = this.hash(`${code}:${email}`); + if (!safeEquals(actual, session.emailCodeHash)) { + session.verificationAttempts += 1; + if (session.verificationAttempts >= session.maxVerificationAttempts) { + session.status = "locked"; + } + await this.deps.store.writeState(state); + throw new Error("Invalid verification_code"); + } + + session.status = "verified"; + session.verifiedAt = this.deps.now().toISOString(); + await this.deps.store.writeState(state); + + return { + session: stripSession(session), + identity: this.buildIdentityFromSession(session), + }; + } + + async startOAuthAuth(input: { + authProvider: string; + beneficiaryName?: string; + beneficiaryEmail?: string; + }): Promise { + const provider = normalize(input.authProvider)?.toLowerCase(); + if (!provider) { + throw new Error("auth_provider is required"); + } + if (!this.deps.allowedOauthProviders.includes(provider)) { + throw new Error( + `Unsupported auth_provider '${provider}'. Allowed providers: ${this.deps.allowedOauthProviders.join(", ")}` + ); + } + + const email = normalizeEmail(input.beneficiaryEmail); + if (email && !EMAIL_PATTERN.test(email)) { + throw new Error("beneficiary_email must be a valid email"); + } + + const now = this.deps.now(); + const session: AuthSessionRecord = { + id: this.sessionId(), + method: "oauth", + status: "pending", + createdAt: now.toISOString(), + expiresAt: toIsoDate(now, this.deps.sessionTtlSeconds), + beneficiaryName: normalize(input.beneficiaryName), + beneficiaryEmail: email, + authProvider: provider, + verificationAttempts: 0, + maxVerificationAttempts: this.deps.maxVerificationAttempts, + }; + + const oauthStateToken = this.issueOauthStateToken({ + sessionId: session.id, + expiresAt: session.expiresAt, + }); + session.oauthStateToken = oauthStateToken; + + const state = await this.deps.store.readState(); + state.sessions.push(session); + await this.deps.store.writeState(state); + + return { + session: stripSession(session), + oauthStateToken, + }; + } + + async verifyOAuthAuth(input: { + sessionId: string; + oauthStateToken: string; + authProvider: string; + authSubject: string; + beneficiaryEmail?: string; + }): Promise { + const provider = normalize(input.authProvider)?.toLowerCase(); + const subject = normalize(input.authSubject); + const email = normalizeEmail(input.beneficiaryEmail); + if (!provider) throw new Error("auth_provider is required"); + if (!subject) throw new Error("auth_subject is required"); + if (email && !EMAIL_PATTERN.test(email)) { + throw new Error("beneficiary_email must be a valid email"); + } + + const state = await this.deps.store.readState(); + const session = this.requireSession(state, input.sessionId); + this.ensureVerifiableSession(session, "oauth"); + + if (session.authProvider !== provider) { + throw new Error( + `OAuth provider mismatch: expected ${session.authProvider || "unknown"}` + ); + } + if (!session.oauthStateToken) { + throw new Error("OAuth session is missing oauth_state_token"); + } + this.validateOauthStateToken(session, input.oauthStateToken); + + session.authSubject = subject; + if (email) session.beneficiaryEmail = email; + session.status = "verified"; + session.verifiedAt = this.deps.now().toISOString(); + await this.deps.store.writeState(state); + + return { + session: stripSession(session), + identity: this.buildIdentityFromSession(session), + }; + } + + async getSession(sessionId: string): Promise { + const state = await this.deps.store.readState(); + const session = state.sessions.find((item) => item.id === sessionId); + if (!session) return null; + this.mutateSessionStatusForTime(session); + await this.deps.store.writeState(state); + return stripSession(session); + } + + async linkSessionToUser(input: { + sessionId: string; + userId: string; + }): Promise { + const userId = normalize(input.userId); + if (!userId) throw new Error("user_id is required"); + + const state = await this.deps.store.readState(); + const session = this.requireSession(state, input.sessionId); + this.mutateSessionStatusForTime(session); + if (session.status !== "verified") { + throw new Error("Auth session must be verified before linking"); + } + + const existing = state.links.find((item) => item.userId === userId); + const linked: IdentityLinkRecord = { + userId, + sessionId: session.id, + method: session.method, + beneficiaryEmail: session.beneficiaryEmail, + authProvider: session.authProvider, + authSubject: session.authSubject, + linkedAt: this.deps.now().toISOString(), + }; + + if (existing) { + Object.assign(existing, linked); + } else { + state.links.push(linked); + } + await this.deps.store.writeState(state); + return linked; + } + + async startRecovery(input: { + beneficiaryEmail: string; + }): Promise { + const email = normalizeEmail(input.beneficiaryEmail); + if (!email || !EMAIL_PATTERN.test(email)) { + throw new Error("beneficiary_email must be a valid email"); + } + + const state = await this.deps.store.readState(); + const verifiedSessions = state.sessions + .filter( + (item) => + item.status === "verified" && item.beneficiaryEmail?.toLowerCase() === email + ) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const source = verifiedSessions[0]; + if (!source) { + throw new Error("No verified auth session found for this email"); + } + + const now = this.deps.now(); + const recoveryToken = `recover_${randomUUID().replace(/-/g, "")}`; + const record: RecoveryTokenRecord = { + id: this.recoveryId(), + tokenHash: this.hash(recoveryToken), + sessionId: source.id, + beneficiaryEmail: email, + createdAt: now.toISOString(), + expiresAt: toIsoDate(now, this.deps.recoveryTtlSeconds), + }; + + state.recoveries.push(record); + await this.deps.store.writeState(state); + + return { + recoveryToken, + expiresAt: record.expiresAt, + sessionId: source.id, + }; + } + + async recoverWithToken(input: { + recoveryToken: string; + }): Promise { + const token = normalize(input.recoveryToken); + if (!token) throw new Error("recovery_token is required"); + + const state = await this.deps.store.readState(); + const recovery = state.recoveries.find((item) => + safeEquals(item.tokenHash, this.hash(token)) + ); + if (!recovery) throw new Error("Invalid recovery_token"); + if (recovery.consumedAt) throw new Error("recovery_token has already been used"); + if (isExpired(this.deps.now(), recovery.expiresAt)) { + throw new Error("recovery_token is expired"); + } + + const source = this.requireSession(state, recovery.sessionId); + if (source.status !== "verified") { + throw new Error("Recovery source session is not verified"); + } + + const now = this.deps.now(); + const recovered: AuthSessionRecord = { + id: this.sessionId(), + method: source.method, + status: "verified", + createdAt: now.toISOString(), + expiresAt: toIsoDate(now, this.deps.sessionTtlSeconds), + verifiedAt: now.toISOString(), + beneficiaryName: source.beneficiaryName, + beneficiaryEmail: source.beneficiaryEmail, + authProvider: source.authProvider, + authSubject: source.authSubject, + verificationAttempts: 0, + maxVerificationAttempts: this.deps.maxVerificationAttempts, + }; + recovery.consumedAt = now.toISOString(); + state.sessions.push(recovered); + await this.deps.store.writeState(state); + + return { session: stripSession(recovered) }; + } + + async resolveVerifiedIdentity(sessionId: string): Promise { + const state = await this.deps.store.readState(); + const session = this.requireSession(state, sessionId); + this.mutateSessionStatusForTime(session); + await this.deps.store.writeState(state); + if (session.status !== "verified") { + throw new Error("auth_session_id is not verified"); + } + return this.buildIdentityFromSession(session); + } +} diff --git a/src/services/auth/store.ts b/src/services/auth/store.ts new file mode 100644 index 0000000..62c8f46 --- /dev/null +++ b/src/services/auth/store.ts @@ -0,0 +1,116 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const DEFAULT_RELATIVE_AUTH_PATH = "data/auth-state.json"; + +function resolveAuthPath(): string { + const configured = process.env.REGEN_AUTH_STORE_PATH?.trim(); + const target = configured || DEFAULT_RELATIVE_AUTH_PATH; + if (path.isAbsolute(target)) return target; + return path.resolve(process.cwd(), target); +} + +export type AuthSessionMethod = "email" | "oauth"; +export type AuthSessionStatus = "pending" | "verified" | "expired" | "locked"; + +export interface AuthSessionRecord { + id: string; + method: AuthSessionMethod; + status: AuthSessionStatus; + createdAt: string; + expiresAt: string; + verifiedAt?: string; + beneficiaryName?: string; + beneficiaryEmail?: string; + authProvider?: string; + authSubject?: string; + emailCodeHash?: string; + oauthStateToken?: string; + verificationAttempts: number; + maxVerificationAttempts: number; +} + +export interface IdentityLinkRecord { + userId: string; + sessionId: string; + method: AuthSessionMethod; + beneficiaryEmail?: string; + authProvider?: string; + authSubject?: string; + linkedAt: string; +} + +export interface RecoveryTokenRecord { + id: string; + tokenHash: string; + sessionId: string; + beneficiaryEmail: string; + createdAt: string; + expiresAt: string; + consumedAt?: string; +} + +export interface AuthState { + version: 1; + sessions: AuthSessionRecord[]; + links: IdentityLinkRecord[]; + recoveries: RecoveryTokenRecord[]; +} + +export interface AuthStore { + readState(): Promise; + writeState(state: AuthState): Promise; +} + +function defaultState(): AuthState { + return { + version: 1, + sessions: [], + links: [], + recoveries: [], + }; +} + +function parseState(raw: string): AuthState { + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + !Array.isArray(parsed.sessions) || + !Array.isArray(parsed.links) || + !Array.isArray(parsed.recoveries) + ) { + throw new Error("Invalid auth state file format"); + } + return { + version: 1, + sessions: parsed.sessions as AuthSessionRecord[], + links: parsed.links as IdentityLinkRecord[], + recoveries: parsed.recoveries as RecoveryTokenRecord[], + }; +} + +export class JsonFileAuthStore implements AuthStore { + private readonly filePath: string; + + constructor(filePath?: string) { + this.filePath = filePath ? path.resolve(filePath) : resolveAuthPath(); + } + + async readState(): Promise { + try { + const raw = await readFile(this.filePath, "utf8"); + return parseState(raw); + } catch (error: any) { + if (error && error.code === "ENOENT") { + return defaultState(); + } + throw error; + } + } + + async writeState(state: AuthState): Promise { + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true }); + await writeFile(this.filePath, JSON.stringify(state, null, 2), "utf8"); + } +} diff --git a/src/tools/auth.ts b/src/tools/auth.ts new file mode 100644 index 0000000..d484159 --- /dev/null +++ b/src/tools/auth.ts @@ -0,0 +1,325 @@ +import { AuthSessionService } from "../services/auth/service.js"; + +const auth = new AuthSessionService(); + +function optional(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export async function startIdentityAuthSessionTool( + method: "email" | "oauth", + beneficiaryEmail?: string, + beneficiaryName?: string, + authProvider?: string +) { + try { + if (method === "email") { + if (!beneficiaryEmail?.trim()) { + return { + content: [ + { + type: "text" as const, + text: "beneficiary_email is required when method=email.", + }, + ], + isError: true, + }; + } + + const started = await auth.startEmailAuth({ + beneficiaryEmail, + beneficiaryName, + }); + + const lines = [ + "## Identity Auth Session Started", + "", + "| Field | Value |", + "|-------|-------|", + `| Method | email |`, + `| Session ID | ${started.session.id} |`, + `| Status | ${started.session.status} |`, + `| Expires At | ${started.session.expiresAt} |`, + `| Verification Code | ${started.verificationCode} |`, + "", + "Use `verify_identity_auth_session` with this session ID and verification code.", + ]; + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } + + if (!authProvider?.trim()) { + return { + content: [ + { + type: "text" as const, + text: "auth_provider is required when method=oauth.", + }, + ], + isError: true, + }; + } + + const started = await auth.startOAuthAuth({ + authProvider, + beneficiaryEmail, + beneficiaryName, + }); + const lines = [ + "## Identity Auth Session Started", + "", + "| Field | Value |", + "|-------|-------|", + `| Method | oauth |`, + `| Session ID | ${started.session.id} |`, + `| Status | ${started.session.status} |`, + `| Auth Provider | ${started.session.authProvider || "N/A"} |`, + `| Expires At | ${started.session.expiresAt} |`, + `| OAuth State Token | ${started.oauthStateToken} |`, + "", + "Complete OAuth externally, then call `verify_identity_auth_session` with session ID, oauth_state_token, auth_provider, and auth_subject.", + ]; + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown auth start error"; + return { + content: [ + { type: "text" as const, text: `Failed to start auth session: ${message}` }, + ], + isError: true, + }; + } +} + +export async function verifyIdentityAuthSessionTool(input: { + sessionId: string; + method: "email" | "oauth"; + verificationCode?: string; + oauthStateToken?: string; + authProvider?: string; + authSubject?: string; + beneficiaryEmail?: string; +}) { + try { + let result; + if (input.method === "email") { + if (!input.verificationCode) { + return { + content: [ + { + type: "text" as const, + text: "verification_code is required for email session verification.", + }, + ], + isError: true, + }; + } + result = await auth.verifyEmailAuth({ + sessionId: input.sessionId, + verificationCode: input.verificationCode, + }); + } else { + if (!input.oauthStateToken || !input.authProvider || !input.authSubject) { + return { + content: [ + { + type: "text" as const, + text: + "oauth_state_token, auth_provider, and auth_subject are required for oauth session verification.", + }, + ], + isError: true, + }; + } + result = await auth.verifyOAuthAuth({ + sessionId: input.sessionId, + oauthStateToken: input.oauthStateToken, + authProvider: input.authProvider, + authSubject: input.authSubject, + beneficiaryEmail: input.beneficiaryEmail, + }); + } + + const lines = [ + "## Identity Auth Session Verified", + "", + "| Field | Value |", + "|-------|-------|", + `| Session ID | ${result.session.id} |`, + `| Method | ${result.session.method} |`, + `| Status | ${result.session.status} |`, + `| Beneficiary Email | ${result.identity.beneficiaryEmail || "N/A"} |`, + `| Auth Provider | ${result.identity.authProvider || "N/A"} |`, + `| Auth Subject | ${result.identity.authSubject || "N/A"} |`, + "", + "You can now pass this `auth_session_id` to `retire_credits`.", + ]; + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown auth verify error"; + return { + content: [ + { type: "text" as const, text: `Failed to verify auth session: ${message}` }, + ], + isError: true, + }; + } +} + +export async function getIdentityAuthSessionTool(sessionId: string) { + try { + const session = await auth.getSession(sessionId); + if (!session) { + return { + content: [ + { + type: "text" as const, + text: `No auth session found for session_id=${sessionId}.`, + }, + ], + }; + } + + const lines = [ + "## Identity Auth Session", + "", + "| Field | Value |", + "|-------|-------|", + `| Session ID | ${session.id} |`, + `| Method | ${session.method} |`, + `| Status | ${session.status} |`, + `| Created At | ${session.createdAt} |`, + `| Expires At | ${session.expiresAt} |`, + `| Verified At | ${session.verifiedAt || "N/A"} |`, + `| Beneficiary Email | ${session.beneficiaryEmail || "N/A"} |`, + `| Auth Provider | ${session.authProvider || "N/A"} |`, + `| Auth Subject | ${session.authSubject || "N/A"} |`, + `| Attempts | ${session.verificationAttempts}/${session.maxVerificationAttempts} |`, + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown auth status error"; + return { + content: [ + { type: "text" as const, text: `Failed to get auth session: ${message}` }, + ], + isError: true, + }; + } +} + +export async function linkIdentitySessionTool(sessionId: string, userId: string) { + try { + const link = await auth.linkSessionToUser({ + sessionId: optional(sessionId) || "", + userId: optional(userId) || "", + }); + + const lines = [ + "## Identity Session Linked", + "", + "| Field | Value |", + "|-------|-------|", + `| User ID | ${link.userId} |`, + `| Session ID | ${link.sessionId} |`, + `| Method | ${link.method} |`, + `| Beneficiary Email | ${link.beneficiaryEmail || "N/A"} |`, + `| Auth Provider | ${link.authProvider || "N/A"} |`, + `| Auth Subject | ${link.authSubject || "N/A"} |`, + `| Linked At | ${link.linkedAt} |`, + ]; + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown identity link error"; + return { + content: [ + { type: "text" as const, text: `Failed to link identity session: ${message}` }, + ], + isError: true, + }; + } +} + +export async function recoverIdentitySessionTool( + action: "start" | "complete", + beneficiaryEmail?: string, + recoveryToken?: string +) { + try { + if (action === "start") { + if (!beneficiaryEmail?.trim()) { + return { + content: [ + { + type: "text" as const, + text: "beneficiary_email is required when action=start.", + }, + ], + isError: true, + }; + } + const result = await auth.startRecovery({ beneficiaryEmail }); + const lines = [ + "## Identity Recovery Started", + "", + "| Field | Value |", + "|-------|-------|", + `| Session ID | ${result.sessionId} |`, + `| Recovery Token | ${result.recoveryToken} |`, + `| Expires At | ${result.expiresAt} |`, + "", + "Use `recover_identity_session` with action=complete and recovery_token to mint a fresh verified auth session.", + ]; + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } + + if (!recoveryToken?.trim()) { + return { + content: [ + { + type: "text" as const, + text: "recovery_token is required when action=complete.", + }, + ], + isError: true, + }; + } + + const result = await auth.recoverWithToken({ + recoveryToken, + }); + const lines = [ + "## Identity Recovery Complete", + "", + "| Field | Value |", + "|-------|-------|", + `| New Session ID | ${result.session.id} |`, + `| Status | ${result.session.status} |`, + `| Method | ${result.session.method} |`, + `| Beneficiary Email | ${result.session.beneficiaryEmail || "N/A"} |`, + `| Auth Provider | ${result.session.authProvider || "N/A"} |`, + `| Auth Subject | ${result.session.authSubject || "N/A"} |`, + `| Expires At | ${result.session.expiresAt} |`, + "", + "You can now use this new verified session in `retire_credits` via `auth_session_id`.", + ]; + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown identity recovery error"; + return { + content: [ + { + type: "text" as const, + text: `Failed to recover identity session: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/retire.ts b/src/tools/retire.ts index e795e29..bc0dda2 100644 --- a/src/tools/retire.ts +++ b/src/tools/retire.ts @@ -16,6 +16,9 @@ import { CryptoPaymentProvider } from "../services/payment/crypto.js"; import { StripePaymentProvider } from "../services/payment/stripe-stub.js"; import type { PaymentProvider } from "../services/payment/types.js"; import { appendIdentityToReason, captureIdentity } from "../services/identity.js"; +import { AuthSessionService } from "../services/auth/service.js"; + +const authSessions = new AuthSessionService(); function getMarketplaceLink(): string { const config = loadConfig(); @@ -93,15 +96,47 @@ export async function retireCredits( reason?: string, beneficiaryEmail?: string, authProvider?: string, - authSubject?: string + authSubject?: string, + authSessionId?: string ): Promise<{ content: Array<{ type: "text"; text: string }> }> { + const providedAuthSessionId = authSessionId?.trim(); + let verifiedSessionIdentity: ReturnType | undefined; + if (providedAuthSessionId) { + try { + verifiedSessionIdentity = + await authSessions.resolveVerifiedIdentity(providedAuthSessionId); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return marketplaceFallback( + `Identity auth session failed: ${errMsg}`, + creditClass, + quantity, + beneficiaryName, + beneficiaryEmail, + authProvider + ); + } + } + + const effectiveBeneficiaryName = + verifiedSessionIdentity?.beneficiaryName || beneficiaryName; + const effectiveBeneficiaryEmail = providedAuthSessionId + ? verifiedSessionIdentity?.beneficiaryEmail + : beneficiaryEmail; + const effectiveAuthProvider = providedAuthSessionId + ? verifiedSessionIdentity?.authProvider + : authProvider; + const effectiveAuthSubject = providedAuthSessionId + ? verifiedSessionIdentity?.authSubject + : authSubject; + let identity: ReturnType = { authMethod: "none" }; try { identity = captureIdentity({ - beneficiaryName, - beneficiaryEmail, - authProvider, - authSubject, + beneficiaryName: effectiveBeneficiaryName, + beneficiaryEmail: effectiveBeneficiaryEmail, + authProvider: effectiveAuthProvider, + authSubject: effectiveAuthSubject, }); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); @@ -109,9 +144,9 @@ export async function retireCredits( `Identity capture failed: ${errMsg}`, creditClass, quantity, - beneficiaryName, - beneficiaryEmail, - authProvider + effectiveBeneficiaryName, + effectiveBeneficiaryEmail, + effectiveAuthProvider ); } @@ -327,9 +362,9 @@ export async function retireCredits( `Direct retirement failed: ${errMsg}`, creditClass, quantity, - identity.beneficiaryName || beneficiaryName, - identity.beneficiaryEmail || beneficiaryEmail, - identity.authProvider || authProvider + identity.beneficiaryName || effectiveBeneficiaryName, + identity.beneficiaryEmail || effectiveBeneficiaryEmail, + identity.authProvider || effectiveAuthProvider ); } } diff --git a/tests/auth-session.test.ts b/tests/auth-session.test.ts new file mode 100644 index 0000000..04decab --- /dev/null +++ b/tests/auth-session.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { AuthSessionService } from "../src/services/auth/service.js"; +import type { AuthState, AuthStore } from "../src/services/auth/store.js"; + +class InMemoryAuthStore implements AuthStore { + private state: AuthState = { + version: 1, + sessions: [], + links: [], + recoveries: [], + }; + + async readState(): Promise { + return structuredClone(this.state); + } + + async writeState(state: AuthState): Promise { + this.state = structuredClone(state); + } +} + +describe("AuthSessionService", () => { + let store: InMemoryAuthStore; + let now: Date; + let service: AuthSessionService; + + const nowFn = () => new Date(now); + const advanceSeconds = (seconds: number) => { + now = new Date(now.getTime() + seconds * 1000); + }; + + beforeEach(() => { + store = new InMemoryAuthStore(); + now = new Date("2026-02-24T00:00:00.000Z"); + service = new AuthSessionService({ + store, + now: nowFn, + secret: "unit-test-auth-secret", + sessionTtlSeconds: 60, + recoveryTtlSeconds: 300, + maxVerificationAttempts: 2, + allowedOauthProviders: ["google", "github"], + }); + }); + + it("starts and verifies an email auth session", async () => { + const started = await service.startEmailAuth({ + beneficiaryEmail: "ALICE@Example.com", + beneficiaryName: " Alice ", + }); + + expect(started.session.status).toBe("pending"); + expect(started.session.beneficiaryEmail).toBe("alice@example.com"); + expect(started.verificationCode).toMatch(/^\d{6}$/); + + const verified = await service.verifyEmailAuth({ + sessionId: started.session.id, + verificationCode: started.verificationCode, + }); + + expect(verified.session.status).toBe("verified"); + expect(verified.identity).toMatchObject({ + authMethod: "email", + beneficiaryName: "Alice", + beneficiaryEmail: "alice@example.com", + }); + + const resolved = await service.resolveVerifiedIdentity(started.session.id); + expect(resolved).toMatchObject({ + authMethod: "email", + beneficiaryEmail: "alice@example.com", + }); + }); + + it("locks email session after max failed verification attempts", async () => { + const started = await service.startEmailAuth({ + beneficiaryEmail: "alice@example.com", + }); + const wrongCode = + started.verificationCode === "000000" ? "999999" : "000000"; + + await expect( + service.verifyEmailAuth({ + sessionId: started.session.id, + verificationCode: wrongCode, + }) + ).rejects.toThrow("Invalid verification_code"); + + await expect( + service.verifyEmailAuth({ + sessionId: started.session.id, + verificationCode: wrongCode, + }) + ).rejects.toThrow("Invalid verification_code"); + + const session = await service.getSession(started.session.id); + expect(session?.status).toBe("locked"); + + await expect( + service.verifyEmailAuth({ + sessionId: started.session.id, + verificationCode: started.verificationCode, + }) + ).rejects.toThrow("Auth session is locked after too many failed attempts"); + }); + + it("verifies oauth sessions with signed state token and provider allowlist", async () => { + await expect( + service.startOAuthAuth({ + authProvider: "discord", + }) + ).rejects.toThrow("Unsupported auth_provider 'discord'"); + + const started = await service.startOAuthAuth({ + authProvider: "google", + beneficiaryEmail: "oauth@example.com", + beneficiaryName: "OAuth User", + }); + + await expect( + service.verifyOAuthAuth({ + sessionId: started.session.id, + oauthStateToken: `${started.oauthStateToken}x`, + authProvider: "google", + authSubject: "google-sub-1", + }) + ).rejects.toThrow("Invalid oauth_state_token signature"); + + const verified = await service.verifyOAuthAuth({ + sessionId: started.session.id, + oauthStateToken: started.oauthStateToken, + authProvider: "google", + authSubject: "google-sub-1", + }); + + expect(verified.session.status).toBe("verified"); + expect(verified.identity).toMatchObject({ + authMethod: "oauth", + beneficiaryEmail: "oauth@example.com", + authProvider: "google", + authSubject: "google-sub-1", + }); + }); + + it("marks pending sessions as expired and blocks verification", async () => { + const started = await service.startEmailAuth({ + beneficiaryEmail: "alice@example.com", + }); + + advanceSeconds(61); + + const session = await service.getSession(started.session.id); + expect(session?.status).toBe("expired"); + + await expect( + service.verifyEmailAuth({ + sessionId: started.session.id, + verificationCode: started.verificationCode, + }) + ).rejects.toThrow("Auth session is expired"); + }); + + it("supports recovery token flow and enforces single use", async () => { + const started = await service.startEmailAuth({ + beneficiaryEmail: "alice@example.com", + beneficiaryName: "Alice", + }); + + await service.verifyEmailAuth({ + sessionId: started.session.id, + verificationCode: started.verificationCode, + }); + + const recovery = await service.startRecovery({ + beneficiaryEmail: "alice@example.com", + }); + + const recovered = await service.recoverWithToken({ + recoveryToken: recovery.recoveryToken, + }); + + expect(recovered.session.id).not.toBe(started.session.id); + expect(recovered.session.status).toBe("verified"); + + await expect( + service.recoverWithToken({ + recoveryToken: recovery.recoveryToken, + }) + ).rejects.toThrow("recovery_token has already been used"); + }); + + it("links verified sessions to user IDs", async () => { + const started = await service.startOAuthAuth({ + authProvider: "github", + beneficiaryEmail: "dev@example.com", + }); + + await service.verifyOAuthAuth({ + sessionId: started.session.id, + oauthStateToken: started.oauthStateToken, + authProvider: "github", + authSubject: "gh-123", + }); + + const linked = await service.linkSessionToUser({ + sessionId: started.session.id, + userId: "user_123", + }); + + expect(linked).toMatchObject({ + userId: "user_123", + sessionId: started.session.id, + method: "oauth", + authProvider: "github", + authSubject: "gh-123", + beneficiaryEmail: "dev@example.com", + }); + }); +}); diff --git a/tests/retire-credits.test.ts b/tests/retire-credits.test.ts index d2d9fea..9b414ad 100644 --- a/tests/retire-credits.test.ts +++ b/tests/retire-credits.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ signAndBroadcast: vi.fn(), selectBestOrders: vi.fn(), waitForRetirement: vi.fn(), + resolveVerifiedIdentity: vi.fn(), cryptoAuthorizePayment: vi.fn(), cryptoCapturePayment: vi.fn(), cryptoRefundPayment: vi.fn(), @@ -34,6 +35,14 @@ vi.mock("../src/services/indexer.js", () => ({ waitForRetirement: mocks.waitForRetirement, })); +vi.mock("../src/services/auth/service.js", () => ({ + AuthSessionService: class { + resolveVerifiedIdentity(sessionId: string) { + return mocks.resolveVerifiedIdentity(sessionId); + } + }, +})); + vi.mock("../src/services/payment/crypto.js", () => ({ CryptoPaymentProvider: class { name = "crypto"; @@ -129,6 +138,9 @@ describe("retireCredits", () => { status: "captured", }); mocks.stripeRefundPayment.mockResolvedValue(undefined); + mocks.resolveVerifiedIdentity.mockResolvedValue({ + authMethod: "none", + }); mocks.selectBestOrders.mockResolvedValue({ orders: [ @@ -356,4 +368,84 @@ describe("retireCredits", () => { ); expect(mocks.stripeCapturePayment).toHaveBeenCalledWith("stripe-auth-1"); }); + + it("uses verified identity session data when auth_session_id is provided", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + mocks.resolveVerifiedIdentity.mockResolvedValue({ + authMethod: "oauth", + beneficiaryName: "Session Alice", + beneficiaryEmail: "session-alice@example.com", + authProvider: "github", + authSubject: "gh-user-123", + }); + mocks.initWallet.mockResolvedValue({ address: "regen1buyer" }); + mocks.signAndBroadcast.mockResolvedValue({ + code: 0, + transactionHash: "ABC123", + height: 12345, + rawLog: "", + }); + mocks.waitForRetirement.mockResolvedValue({ nodeId: "WyCert123" }); + + const result = await retireCredits( + "C01", + 1, + "Manual Alice", + "US-CA", + "Unit test retirement", + "manual@example.com", + "google", + "google-manual-sub", + "auth_session_123" + ); + const text = responseText(result); + + expect(mocks.resolveVerifiedIdentity).toHaveBeenCalledWith("auth_session_123"); + + const messages = mocks.signAndBroadcast.mock.calls[0]?.[0]; + const retirementReason = + messages?.[0]?.value?.orders?.[0]?.retirementReason; + const parsedReason = parseAttributedReason(retirementReason); + expect(parsedReason.identity).toMatchObject({ + method: "oauth", + name: "Session Alice", + email: "session-alice@example.com", + provider: "github", + subject: "gh-user-123", + }); + + expect(text).toContain("| Beneficiary Name | Session Alice |"); + expect(text).toContain("| Beneficiary Email | session-alice@example.com |"); + expect(text).toContain("| Auth Provider | github |"); + expect(text).toContain("| Auth Subject | gh-user-123 |"); + expect(text).not.toContain("manual@example.com"); + expect(text).not.toContain("google-manual-sub"); + }); + + it("falls back when auth_session_id resolution fails", async () => { + mocks.isWalletConfigured.mockReturnValue(true); + mocks.resolveVerifiedIdentity.mockRejectedValue( + new Error("auth_session_id is not verified") + ); + + const result = await retireCredits( + "C01", + 1, + "Alice", + "US-CA", + "Unit test retirement", + "alice@example.com", + "google", + "google-sub-123", + "auth_bad" + ); + const text = responseText(result); + + expect(text).toContain( + "Identity auth session failed: auth_session_id is not verified" + ); + expect(text).toContain("## Retire Ecocredits on Regen Network"); + expect(mocks.initWallet).not.toHaveBeenCalled(); + expect(mocks.signAndBroadcast).not.toHaveBeenCalled(); + }); }); From 8cdc244028e7af5b2ea42b60c191539b0b61a21d Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:02:45 -0800 Subject: [PATCH 14/31] feat: add idempotent Stripe invoice sync for pool accounting --- README.md | 18 ++++ src/index.ts | 67 ++++++++++-- src/services/pool-accounting/service.ts | 21 ++++ src/services/pool-accounting/types.ts | 3 + src/services/subscription/pool-sync.ts | 129 ++++++++++++++++++++++++ src/services/subscription/stripe.ts | 86 ++++++++++++++++ src/tools/pool-accounting.ts | 9 +- src/tools/subscriptions.ts | 69 +++++++++++++ tests/pool-accounting.test.ts | 26 +++++ tests/subscription-pool-sync.test.ts | 113 +++++++++++++++++++++ tests/subscription-service.test.ts | 71 +++++++++++++ 11 files changed, 604 insertions(+), 8 deletions(-) create mode 100644 src/services/subscription/pool-sync.ts create mode 100644 tests/subscription-pool-sync.test.ts diff --git a/README.md b/README.md index 58a215e..139bb8f 100644 --- a/README.md +++ b/README.md @@ -188,12 +188,30 @@ Creates, updates, checks, or cancels Stripe subscription state for a customer. | `full_name` | Name to use when creating a Stripe customer. | | `payment_method_id` | Stripe PaymentMethod to set as default when subscribing. | +### `sync_subscription_pool_contributions` + +Fetches paid Stripe invoices for a customer and records them into pool accounting with duplicate protection by invoice ID (`stripe_invoice:`). + +**When it's used:** Subscription reconciliation and safe reruns of invoice ingestion without double-counting. + +**Parameters:** + +| Parameter | Description | +|-----------|-------------| +| `month` | Optional month filter in `YYYY-MM` format. | +| `email` | Stripe customer email (alternative to `customer_id`). | +| `customer_id` | Stripe customer ID (alternative to `email`). | +| `user_id` | Optional internal user ID override for attribution. | +| `limit` | Optional max invoices to fetch (`1-100`, default `100`). | + ### `record_pool_contribution` Records a contribution event in the pool ledger for per-user accounting and monthly aggregation. **When it's used:** Internal/admin workflows that ingest paid subscription events into the retirement pool ledger. +Supports optional `source_event_id` for idempotent writes when ingesting external payment events. + ### `get_pool_accounting_summary` Returns either: diff --git a/src/index.ts b/src/index.ts index 20aec8a..db15d9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { retireCredits } from "./tools/retire.js"; import { listSubscriptionTiersTool, manageSubscriptionTool, + syncSubscriptionPoolContributionsTool, } from "./tools/subscriptions.js"; import { getPoolAccountingSummaryTool, @@ -112,13 +113,14 @@ const server = new McpServer( ] : []), "5. list_subscription_tiers / manage_subscription — manage $1/$3/$5 recurring contribution plans", - "6. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", - "7. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", - "8. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", - "9. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", - "10. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", - "11. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", - "12. link_identity_session / recover_identity_session — identity linking and recovery flows", + "6. sync_subscription_pool_contributions — ingest paid Stripe invoices into pool accounting with idempotency", + "7. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", + "8. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", + "9. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", + "10. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", + "11. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", + "12. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", + "13. link_identity_session / recover_identity_session — identity linking and recovery flows", "", ...(walletMode ? [ @@ -128,6 +130,7 @@ const server = new McpServer( "Without a wallet, retire_credits returns marketplace links instead of broadcasting on-chain transactions.", ]), "The manage_subscription tool can create, update, or cancel Stripe subscriptions.", + "The sync_subscription_pool_contributions tool ingests paid Stripe invoices into the pool ledger safely (duplicate-safe via invoice IDs).", "Pool accounting tools support per-user contribution tracking and monthly aggregation summaries.", "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", @@ -288,6 +291,50 @@ server.tool( } ); +// Tool: Sync paid Stripe invoices into pool accounting +server.tool( + "sync_subscription_pool_contributions", + "Ingests paid Stripe invoices into pool accounting for a customer/email with idempotent deduplication by invoice ID. Useful for recurring subscription reconciliation.", + { + month: z + .string() + .optional() + .describe("Optional month filter in YYYY-MM format"), + email: z + .string() + .optional() + .describe("Customer email used for Stripe customer lookup"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID (optional alternative to email)"), + user_id: z + .string() + .optional() + .describe("Optional internal user ID override for pool attribution"), + limit: z + .number() + .int() + .optional() + .describe("Max invoices to fetch from Stripe (1-100, default 100)"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + async ({ month, email, customer_id, user_id, limit }) => { + return syncSubscriptionPoolContributionsTool( + month, + email, + customer_id, + user_id, + limit + ); + } +); + // Tool: Record a contribution entry in the pool accounting ledger server.tool( "record_pool_contribution", @@ -309,6 +356,10 @@ server.tool( .string() .optional() .describe("Stripe subscription ID associated with this contribution"), + source_event_id: z + .string() + .optional() + .describe("External event ID for idempotency (e.g., stripe invoice ID)"), tier: z .enum(["starter", "growth", "impact"]) .optional() @@ -342,6 +393,7 @@ server.tool( email, customer_id, subscription_id, + source_event_id, tier, amount_usd, amount_usd_cents, @@ -353,6 +405,7 @@ server.tool( email, customerId: customer_id, subscriptionId: subscription_id, + externalEventId: source_event_id, tierId: tier, amountUsd: amount_usd, amountUsdCents: amount_usd_cents, diff --git a/src/services/pool-accounting/service.ts b/src/services/pool-accounting/service.ts index d498524..9b639cd 100644 --- a/src/services/pool-accounting/service.ts +++ b/src/services/pool-accounting/service.ts @@ -191,6 +191,25 @@ export class PoolAccountingService { async recordContribution(input: ContributionInput): Promise { const state = await this.store.readState(); + const externalEventId = normalize(input.externalEventId); + + if (externalEventId) { + const existing = state.contributions.find( + (item) => item.externalEventId === externalEventId + ); + if (existing) { + const userRecords = state.contributions.filter( + (item) => item.userId === existing.userId + ); + return { + record: existing, + duplicate: true, + userSummary: summarizeUserRecords(existing.userId, userRecords), + monthSummary: summarizeMonth(existing.month, state.contributions), + }; + } + } + const userId = resolveUserId(input); const contributedAt = toIsoTimestamp(input.contributedAt); const month = toMonth(contributedAt); @@ -202,6 +221,7 @@ export class PoolAccountingService { email: normalizeEmail(input.email), customerId: normalize(input.customerId), subscriptionId: normalize(input.subscriptionId), + externalEventId, tierId: input.tierId, amountUsdCents, contributedAt, @@ -221,6 +241,7 @@ export class PoolAccountingService { const userRecords = state.contributions.filter((item) => item.userId === userId); return { record, + duplicate: false, userSummary: summarizeUserRecords(userId, userRecords), monthSummary: summarizeMonth(month, state.contributions), }; diff --git a/src/services/pool-accounting/types.ts b/src/services/pool-accounting/types.ts index 06b19aa..bd0b25a 100644 --- a/src/services/pool-accounting/types.ts +++ b/src/services/pool-accounting/types.ts @@ -10,6 +10,7 @@ export interface ContributionInput { email?: string; customerId?: string; subscriptionId?: string; + externalEventId?: string; tierId?: SubscriptionTierId; amountUsd?: number; amountUsdCents?: number; @@ -24,6 +25,7 @@ export interface ContributionRecord { email?: string; customerId?: string; subscriptionId?: string; + externalEventId?: string; tierId?: SubscriptionTierId; amountUsdCents: number; contributedAt: string; @@ -80,6 +82,7 @@ export interface PoolAccountingStore { export interface ContributionReceipt { record: ContributionRecord; + duplicate: boolean; userSummary: UserContributionSummary; monthSummary: MonthlyPoolSummary; } diff --git a/src/services/subscription/pool-sync.ts b/src/services/subscription/pool-sync.ts new file mode 100644 index 0000000..ed09f00 --- /dev/null +++ b/src/services/subscription/pool-sync.ts @@ -0,0 +1,129 @@ +import { PoolAccountingService } from "../pool-accounting/service.js"; +import { StripeSubscriptionService } from "./stripe.js"; +import { getTierIdForStripePrice } from "./tiers.js"; +import type { SubscriptionIdentityInput } from "./types.js"; + +const MONTH_REGEX = /^\d{4}-\d{2}$/; + +function normalize(value?: string): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeEmail(value?: string): string | undefined { + const email = normalize(value); + return email ? email.toLowerCase() : undefined; +} + +export interface SubscriptionPoolSyncInput extends SubscriptionIdentityInput { + userId?: string; + month?: string; + limit?: number; +} + +export interface SyncedSubscriptionContribution { + invoiceId: string; + contributionId: string; + duplicated: boolean; + amountUsdCents: number; + paidAt: string; +} + +export interface SubscriptionPoolSyncResult { + customerId?: string; + email?: string; + month?: string; + fetchedInvoiceCount: number; + processedInvoiceCount: number; + syncedCount: number; + duplicateCount: number; + skippedCount: number; + records: SyncedSubscriptionContribution[]; +} + +export class SubscriptionPoolSyncService { + constructor( + private readonly subscriptions: Pick< + StripeSubscriptionService, + "listPaidInvoices" + > = new StripeSubscriptionService(), + private readonly poolAccounting: PoolAccountingService = new PoolAccountingService() + ) {} + + async syncPaidInvoices( + input: SubscriptionPoolSyncInput + ): Promise { + const identity: SubscriptionIdentityInput = { + customerId: normalize(input.customerId), + email: normalizeEmail(input.email), + }; + const userId = normalize(input.userId); + const month = normalize(input.month); + + if (!identity.customerId && !identity.email) { + throw new Error("Provide at least one of customerId or email"); + } + if (month && !MONTH_REGEX.test(month)) { + throw new Error("month must be in YYYY-MM format"); + } + + const fetched = await this.subscriptions.listPaidInvoices(identity, { + limit: input.limit, + }); + const invoices = month + ? fetched.filter((invoice) => invoice.paidAt.slice(0, 7) === month) + : fetched; + + const records: SyncedSubscriptionContribution[] = []; + let syncedCount = 0; + let duplicateCount = 0; + + for (const invoice of invoices) { + const tierId = getTierIdForStripePrice(invoice.priceId); + const receipt = await this.poolAccounting.recordContribution({ + userId, + email: invoice.customerEmail || identity.email, + customerId: invoice.customerId || identity.customerId, + subscriptionId: invoice.subscriptionId, + externalEventId: `stripe_invoice:${invoice.invoiceId}`, + amountUsdCents: invoice.amountPaidCents, + contributedAt: invoice.paidAt, + source: "subscription", + tierId, + metadata: { + stripe_invoice_id: invoice.invoiceId, + stripe_price_id: invoice.priceId || "", + synced_by: "sync_subscription_pool_contributions", + }, + }); + + if (receipt.duplicate) { + duplicateCount += 1; + } else { + syncedCount += 1; + } + + records.push({ + invoiceId: invoice.invoiceId, + contributionId: receipt.record.id, + duplicated: receipt.duplicate, + amountUsdCents: receipt.record.amountUsdCents, + paidAt: receipt.record.contributedAt, + }); + } + + return { + customerId: + invoices[0]?.customerId || fetched[0]?.customerId || identity.customerId, + email: identity.email || invoices[0]?.customerEmail || fetched[0]?.customerEmail, + month, + fetchedInvoiceCount: fetched.length, + processedInvoiceCount: invoices.length, + syncedCount, + duplicateCount, + skippedCount: fetched.length - invoices.length, + records, + }; + } +} diff --git a/src/services/subscription/stripe.ts b/src/services/subscription/stripe.ts index fc36247..069ebcb 100644 --- a/src/services/subscription/stripe.ts +++ b/src/services/subscription/stripe.ts @@ -37,6 +37,26 @@ interface StripeSubscriptionItem { price?: StripePrice; } +interface StripeInvoiceLine { + id?: string; + price?: StripePrice; +} + +interface StripeInvoice { + id: string; + customer?: string; + customer_email?: string; + subscription?: string; + amount_paid?: number; + currency?: string; + status_transitions?: { + paid_at?: number | null; + }; + lines?: { + data: StripeInvoiceLine[]; + }; +} + interface StripeSubscription { id: string; status: string; @@ -52,6 +72,16 @@ interface StripeListResponse { data: T[]; } +export interface PaidInvoice { + invoiceId: string; + customerId: string; + customerEmail?: string; + subscriptionId?: string; + priceId?: string; + amountPaidCents: number; + paidAt: string; +} + function trimOrUndefined(value?: string): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -408,4 +438,60 @@ export class StripeSubscriptionService { ); return toState(customer, canceled); } + + async listPaidInvoices( + input: SubscriptionIdentityInput, + options?: { limit?: number } + ): Promise { + const customer = await this.resolveCustomer(input, false); + if (!customer) { + return []; + } + + const rawLimit = options?.limit; + const limit = + typeof rawLimit === "number" && Number.isInteger(rawLimit) + ? Math.min(100, Math.max(1, rawLimit)) + : 100; + + const invoices = await this.stripeRequest>( + "GET", + "/invoices", + { + customer: customer.id, + status: "paid", + limit, + } + ); + + return invoices.data + .map((invoice): PaidInvoice | null => { + const paidAtEpoch = invoice.status_transitions?.paid_at; + const amountPaidCents = invoice.amount_paid; + const currency = (invoice.currency || "").toLowerCase(); + if ( + !paidAtEpoch || + !amountPaidCents || + amountPaidCents <= 0 || + currency !== "usd" + ) { + return null; + } + + const paidAtIso = new Date(paidAtEpoch * 1000).toISOString(); + const linePriceId = invoice.lines?.data?.[0]?.price?.id; + + return { + invoiceId: invoice.id, + customerId: invoice.customer || customer.id, + customerEmail: invoice.customer_email || customer.email, + subscriptionId: invoice.subscription, + priceId: linePriceId, + amountPaidCents, + paidAt: paidAtIso, + }; + }) + .filter((item): item is PaidInvoice => Boolean(item)) + .sort((a, b) => a.paidAt.localeCompare(b.paidAt)); + } } diff --git a/src/tools/pool-accounting.ts b/src/tools/pool-accounting.ts index 471db26..dbfaf65 100644 --- a/src/tools/pool-accounting.ts +++ b/src/tools/pool-accounting.ts @@ -10,9 +10,12 @@ function renderMoney(cents: number): string { export async function recordPoolContributionTool(input: ContributionInput) { try { const receipt = await poolAccounting.recordContribution(input); + const title = receipt.duplicate + ? "## Pool Contribution Already Recorded" + : "## Pool Contribution Recorded"; const lines = [ - "## Pool Contribution Recorded", + title, "", `| Field | Value |`, `|-------|-------|`, @@ -21,9 +24,13 @@ export async function recordPoolContributionTool(input: ContributionInput) { `| Amount | ${renderMoney(receipt.record.amountUsdCents)} |`, `| Month | ${receipt.record.month} |`, `| Source | ${receipt.record.source} |`, + `| Duplicate | ${receipt.duplicate ? "Yes" : "No"} |`, `| User Lifetime Total | ${renderMoney(receipt.userSummary.totalUsdCents)} |`, `| Month Pool Total | ${renderMoney(receipt.monthSummary.totalUsdCents)} |`, ]; + if (receipt.record.externalEventId) { + lines.splice(9, 0, `| External Event ID | ${receipt.record.externalEventId} |`); + } return { content: [{ type: "text" as const, text: lines.join("\n") }] }; } catch (error) { diff --git a/src/tools/subscriptions.ts b/src/tools/subscriptions.ts index f004558..0d75e7c 100644 --- a/src/tools/subscriptions.ts +++ b/src/tools/subscriptions.ts @@ -1,4 +1,5 @@ import { StripeSubscriptionService } from "../services/subscription/stripe.js"; +import { SubscriptionPoolSyncService } from "../services/subscription/pool-sync.js"; import type { SubscriptionIdentityInput, SubscriptionState, @@ -6,6 +7,7 @@ import type { } from "../services/subscription/types.js"; const subscriptions = new StripeSubscriptionService(); +const poolSync = new SubscriptionPoolSyncService(); function renderState(title: string, state: SubscriptionState): string { const lines: string[] = [ @@ -145,3 +147,70 @@ export async function manageSubscriptionTool( }; } } + +function formatMoney(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +export async function syncSubscriptionPoolContributionsTool( + month?: string, + email?: string, + customerId?: string, + userId?: string, + limit?: number +) { + try { + const result = await poolSync.syncPaidInvoices({ + month, + email, + customerId, + userId, + limit, + }); + + const lines: string[] = [ + "## Subscription Pool Sync", + "", + "| Field | Value |", + "|-------|-------|", + `| Customer ID | ${result.customerId || "N/A"} |`, + `| Email | ${result.email || "N/A"} |`, + `| Month Filter | ${result.month || "none"} |`, + `| Invoices Fetched | ${result.fetchedInvoiceCount} |`, + `| Invoices Processed | ${result.processedInvoiceCount} |`, + `| Synced | ${result.syncedCount} |`, + `| Duplicates | ${result.duplicateCount} |`, + `| Skipped (month filter) | ${result.skippedCount} |`, + ]; + + if (result.records.length > 0) { + lines.push( + "", + "### Processed Invoices", + "", + "| Invoice ID | Contribution ID | Amount | Duplicate | Paid At |", + "|------------|------------------|--------|-----------|---------|", + ...result.records.map( + (record) => + `| ${record.invoiceId} | ${record.contributionId} | ${formatMoney(record.amountUsdCents)} | ${record.duplicated ? "Yes" : "No"} | ${record.paidAt} |` + ) + ); + } else { + lines.push("", "No paid invoices matched the provided filter."); + } + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown sync error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Subscription pool sync failed: ${message}`, + }, + ], + isError: true, + }; + } +} diff --git a/tests/pool-accounting.test.ts b/tests/pool-accounting.test.ts index 5c69b89..fbd8b0c 100644 --- a/tests/pool-accounting.test.ts +++ b/tests/pool-accounting.test.ts @@ -166,4 +166,30 @@ describe("PoolAccountingService", () => { const months = await service.listAvailableMonths(); expect(months).toEqual(["2026-03", "2026-02", "2026-01"]); }); + + it("deduplicates records when externalEventId is reused", async () => { + const first = await service.recordContribution({ + customerId: "cus_123", + amountUsdCents: 300, + contributedAt: "2026-03-10T00:00:00.000Z", + externalEventId: "stripe_invoice:in_123", + source: "subscription", + }); + + const second = await service.recordContribution({ + customerId: "cus_123", + amountUsdCents: 300, + contributedAt: "2026-03-10T00:00:00.000Z", + externalEventId: "stripe_invoice:in_123", + source: "subscription", + }); + + expect(first.duplicate).toBe(false); + expect(second.duplicate).toBe(true); + expect(second.record.id).toBe(first.record.id); + + const march = await service.getMonthlySummary("2026-03"); + expect(march.contributionCount).toBe(1); + expect(march.totalUsdCents).toBe(300); + }); }); diff --git a/tests/subscription-pool-sync.test.ts b/tests/subscription-pool-sync.test.ts new file mode 100644 index 0000000..f1c169e --- /dev/null +++ b/tests/subscription-pool-sync.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { PoolAccountingService } from "../src/services/pool-accounting/service.js"; +import type { + PoolAccountingState, + PoolAccountingStore, +} from "../src/services/pool-accounting/types.js"; +import { SubscriptionPoolSyncService } from "../src/services/subscription/pool-sync.js"; +import type { PaidInvoice } from "../src/services/subscription/stripe.js"; + +class InMemoryPoolAccountingStore implements PoolAccountingStore { + private state: PoolAccountingState = { version: 1, contributions: [] }; + + async readState(): Promise { + return JSON.parse(JSON.stringify(this.state)) as PoolAccountingState; + } + + async writeState(state: PoolAccountingState): Promise { + this.state = JSON.parse(JSON.stringify(state)) as PoolAccountingState; + } +} + +class StubSubscriptionService { + constructor(private readonly invoices: PaidInvoice[]) {} + + async listPaidInvoices( + _input?: unknown, + _options?: unknown + ): Promise { + return this.invoices; + } +} + +describe("SubscriptionPoolSyncService", () => { + let poolAccounting: PoolAccountingService; + + beforeEach(() => { + poolAccounting = new PoolAccountingService(new InMemoryPoolAccountingStore()); + }); + + it("syncs paid invoices into pool accounting and deduplicates on rerun", async () => { + const invoices: PaidInvoice[] = [ + { + invoiceId: "in_march", + customerId: "cus_123", + customerEmail: "alice@example.com", + subscriptionId: "sub_123", + priceId: "price_growth", + amountPaidCents: 300, + paidAt: "2026-03-15T00:00:00.000Z", + }, + { + invoiceId: "in_april", + customerId: "cus_123", + customerEmail: "alice@example.com", + subscriptionId: "sub_123", + priceId: "price_growth", + amountPaidCents: 300, + paidAt: "2026-04-15T00:00:00.000Z", + }, + ]; + + const sync = new SubscriptionPoolSyncService( + new StubSubscriptionService(invoices), + poolAccounting + ); + + const first = await sync.syncPaidInvoices({ + customerId: "cus_123", + month: "2026-03", + }); + + expect(first.fetchedInvoiceCount).toBe(2); + expect(first.processedInvoiceCount).toBe(1); + expect(first.syncedCount).toBe(1); + expect(first.duplicateCount).toBe(0); + expect(first.skippedCount).toBe(1); + expect(first.records[0]).toMatchObject({ + invoiceId: "in_march", + duplicated: false, + amountUsdCents: 300, + }); + + const second = await sync.syncPaidInvoices({ + customerId: "cus_123", + month: "2026-03", + }); + + expect(second.syncedCount).toBe(0); + expect(second.duplicateCount).toBe(1); + expect(second.records[0]).toMatchObject({ + invoiceId: "in_march", + duplicated: true, + }); + + const march = await poolAccounting.getMonthlySummary("2026-03"); + expect(march.totalUsdCents).toBe(300); + expect(march.contributionCount).toBe(1); + }); + + it("requires an identity and validates month format", async () => { + const sync = new SubscriptionPoolSyncService( + new StubSubscriptionService([]), + poolAccounting + ); + + await expect(sync.syncPaidInvoices({})).rejects.toThrow( + "Provide at least one of customerId or email" + ); + await expect( + sync.syncPaidInvoices({ customerId: "cus_123", month: "03-2026" }) + ).rejects.toThrow("month must be in YYYY-MM format"); + }); +}); diff --git a/tests/subscription-service.test.ts b/tests/subscription-service.test.ts index 456834c..88ae3d0 100644 --- a/tests/subscription-service.test.ts +++ b/tests/subscription-service.test.ts @@ -152,4 +152,75 @@ describe("StripeSubscriptionService", () => { expect(cancelUrl).toContain("/subscriptions/sub_123"); expect(String(cancelInit.body)).toContain("cancel_at_period_end=true"); }); + + it("lists paid usd invoices normalized for pool sync", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse(200, { + data: [{ id: "cus_123", email: "alice@example.com" }], + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + data: [ + { + id: "in_2", + customer: "cus_123", + customer_email: "alice@example.com", + subscription: "sub_123", + amount_paid: 500, + currency: "usd", + status_transitions: { paid_at: 1_707_000_100 }, + lines: { data: [{ id: "il_2", price: { id: "price_growth" } }] }, + }, + { + id: "in_skip_currency", + customer: "cus_123", + amount_paid: 500, + currency: "eur", + status_transitions: { paid_at: 1_707_000_200 }, + lines: { data: [{ id: "il_3", price: { id: "price_growth" } }] }, + }, + { + id: "in_1", + customer: "cus_123", + customer_email: "alice@example.com", + subscription: "sub_123", + amount_paid: 100, + currency: "usd", + status_transitions: { paid_at: 1_707_000_000 }, + lines: { data: [{ id: "il_1", price: { id: "price_starter" } }] }, + }, + ], + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new StripeSubscriptionService(); + const invoices = await service.listPaidInvoices( + { email: "alice@example.com" }, + { limit: 5 } + ); + + expect(invoices).toHaveLength(2); + expect(invoices[0]).toMatchObject({ + invoiceId: "in_1", + customerId: "cus_123", + customerEmail: "alice@example.com", + subscriptionId: "sub_123", + amountPaidCents: 100, + priceId: "price_starter", + }); + expect(invoices[1]).toMatchObject({ + invoiceId: "in_2", + amountPaidCents: 500, + priceId: "price_growth", + }); + + const [invoicesUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(invoicesUrl).toContain( + "/invoices?customer=cus_123&status=paid&limit=5" + ); + }); }); From 6e1d06c9d98965fa1ac354cb8e76a35e633f8416 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:08:04 -0800 Subject: [PATCH 15/31] feat: add account-wide subscription invoice reconciliation tool --- README.md | 14 +++ src/index.ts | 48 +++++++-- src/services/subscription/pool-sync.ts | 24 +++-- src/services/subscription/stripe.ts | 111 +++++++++++++++------ src/tools/subscriptions.ts | 131 +++++++++++++++++++------ tests/subscription-pool-sync.test.ts | 58 ++++++++++- tests/subscription-service.test.ts | 75 ++++++++++++++ 7 files changed, 385 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 139bb8f..40d7d87 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,20 @@ Fetches paid Stripe invoices for a customer and records them into pool accountin | `user_id` | Optional internal user ID override for attribution. | | `limit` | Optional max invoices to fetch (`1-100`, default `100`). | +### `sync_all_subscription_pool_contributions` + +Fetches paid Stripe invoices account-wide (across customers) and records them into pool accounting with duplicate protection by invoice ID (`stripe_invoice:`). + +**When it's used:** Monthly reconciliation across the full subscription base before running pooled batch retirement. + +**Parameters:** + +| Parameter | Description | +|-----------|-------------| +| `month` | Optional month filter in `YYYY-MM` format. | +| `limit` | Per-page Stripe invoice fetch size (`1-100`, default `100`). | +| `max_pages` | Max invoice pages to fetch (`1-50`, default `10`). | + ### `record_pool_contribution` Records a contribution event in the pool ledger for per-user accounting and monthly aggregation. diff --git a/src/index.ts b/src/index.ts index db15d9d..bf80634 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { retireCredits } from "./tools/retire.js"; import { listSubscriptionTiersTool, manageSubscriptionTool, + syncAllSubscriptionPoolContributionsTool, syncSubscriptionPoolContributionsTool, } from "./tools/subscriptions.js"; import { @@ -114,13 +115,14 @@ const server = new McpServer( : []), "5. list_subscription_tiers / manage_subscription — manage $1/$3/$5 recurring contribution plans", "6. sync_subscription_pool_contributions — ingest paid Stripe invoices into pool accounting with idempotency", - "7. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", - "8. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", - "9. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", - "10. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", - "11. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", - "12. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", - "13. link_identity_session / recover_identity_session — identity linking and recovery flows", + "7. sync_all_subscription_pool_contributions — account-wide Stripe paid-invoice reconciliation with pagination", + "8. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", + "9. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", + "10. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", + "11. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", + "12. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", + "13. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", + "14. link_identity_session / recover_identity_session — identity linking and recovery flows", "", ...(walletMode ? [ @@ -131,6 +133,7 @@ const server = new McpServer( ]), "The manage_subscription tool can create, update, or cancel Stripe subscriptions.", "The sync_subscription_pool_contributions tool ingests paid Stripe invoices into the pool ledger safely (duplicate-safe via invoice IDs).", + "The sync_all_subscription_pool_contributions tool performs account-wide paid invoice ingestion across customers with pagination controls.", "Pool accounting tools support per-user contribution tracking and monthly aggregation summaries.", "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", @@ -335,6 +338,37 @@ server.tool( } ); +// Tool: Sync paid Stripe invoices across all customers into pool accounting +server.tool( + "sync_all_subscription_pool_contributions", + "Ingests paid Stripe invoices account-wide into pool accounting with idempotent deduplication by invoice ID. Supports pagination controls for large invoice sets.", + { + month: z + .string() + .optional() + .describe("Optional month filter in YYYY-MM format"), + limit: z + .number() + .int() + .optional() + .describe("Per-page Stripe invoice fetch size (1-100, default 100)"), + max_pages: z + .number() + .int() + .optional() + .describe("Maximum pages to fetch from Stripe invoices API (1-50, default 10)"), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + async ({ month, limit, max_pages }) => { + return syncAllSubscriptionPoolContributionsTool(month, limit, max_pages); + } +); + // Tool: Record a contribution entry in the pool accounting ledger server.tool( "record_pool_contribution", diff --git a/src/services/subscription/pool-sync.ts b/src/services/subscription/pool-sync.ts index ed09f00..c73bd7e 100644 --- a/src/services/subscription/pool-sync.ts +++ b/src/services/subscription/pool-sync.ts @@ -20,6 +20,8 @@ export interface SubscriptionPoolSyncInput extends SubscriptionIdentityInput { userId?: string; month?: string; limit?: number; + maxPages?: number; + allCustomers?: boolean; } export interface SyncedSubscriptionContribution { @@ -31,6 +33,7 @@ export interface SyncedSubscriptionContribution { } export interface SubscriptionPoolSyncResult { + scope: "customer" | "all_customers"; customerId?: string; email?: string; month?: string; @@ -46,7 +49,7 @@ export class SubscriptionPoolSyncService { constructor( private readonly subscriptions: Pick< StripeSubscriptionService, - "listPaidInvoices" + "listPaidInvoices" | "listPaidInvoicesAcrossCustomers" > = new StripeSubscriptionService(), private readonly poolAccounting: PoolAccountingService = new PoolAccountingService() ) {} @@ -60,17 +63,23 @@ export class SubscriptionPoolSyncService { }; const userId = normalize(input.userId); const month = normalize(input.month); + const allCustomers = Boolean(input.allCustomers); - if (!identity.customerId && !identity.email) { + if (!allCustomers && !identity.customerId && !identity.email) { throw new Error("Provide at least one of customerId or email"); } if (month && !MONTH_REGEX.test(month)) { throw new Error("month must be in YYYY-MM format"); } - const fetched = await this.subscriptions.listPaidInvoices(identity, { - limit: input.limit, - }); + const fetched = allCustomers + ? await this.subscriptions.listPaidInvoicesAcrossCustomers({ + limit: input.limit, + maxPages: input.maxPages, + }) + : await this.subscriptions.listPaidInvoices(identity, { + limit: input.limit, + }); const invoices = month ? fetched.filter((invoice) => invoice.paidAt.slice(0, 7) === month) : fetched; @@ -94,7 +103,9 @@ export class SubscriptionPoolSyncService { metadata: { stripe_invoice_id: invoice.invoiceId, stripe_price_id: invoice.priceId || "", - synced_by: "sync_subscription_pool_contributions", + synced_by: allCustomers + ? "sync_all_subscription_pool_contributions" + : "sync_subscription_pool_contributions", }, }); @@ -114,6 +125,7 @@ export class SubscriptionPoolSyncService { } return { + scope: allCustomers ? "all_customers" : "customer", customerId: invoices[0]?.customerId || fetched[0]?.customerId || identity.customerId, email: identity.email || invoices[0]?.customerEmail || fetched[0]?.customerEmail, diff --git a/src/services/subscription/stripe.ts b/src/services/subscription/stripe.ts index 069ebcb..a01fed4 100644 --- a/src/services/subscription/stripe.ts +++ b/src/services/subscription/stripe.ts @@ -70,6 +70,7 @@ interface StripeSubscription { interface StripeListResponse { data: T[]; + has_more?: boolean; } export interface PaidInvoice { @@ -322,6 +323,45 @@ export class StripeSubscriptionService { return list.data; } + private clampInvoiceFetchLimit(rawLimit?: number): number { + return typeof rawLimit === "number" && Number.isInteger(rawLimit) + ? Math.min(100, Math.max(1, rawLimit)) + : 100; + } + + private normalizePaidInvoice( + invoice: StripeInvoice, + fallbackCustomer?: StripeCustomer + ): PaidInvoice | null { + const paidAtEpoch = invoice.status_transitions?.paid_at; + const amountPaidCents = invoice.amount_paid; + const currency = (invoice.currency || "").toLowerCase(); + const customerId = invoice.customer || fallbackCustomer?.id; + + if ( + !customerId || + !paidAtEpoch || + !amountPaidCents || + amountPaidCents <= 0 || + currency !== "usd" + ) { + return null; + } + + const paidAtIso = new Date(paidAtEpoch * 1000).toISOString(); + const linePriceId = invoice.lines?.data?.[0]?.price?.id; + + return { + invoiceId: invoice.id, + customerId, + customerEmail: invoice.customer_email || fallbackCustomer?.email, + subscriptionId: invoice.subscription, + priceId: linePriceId, + amountPaidCents, + paidAt: paidAtIso, + }; + } + async ensureSubscription( tierId: SubscriptionTierId, input: SubscriptionIdentityInput @@ -448,11 +488,7 @@ export class StripeSubscriptionService { return []; } - const rawLimit = options?.limit; - const limit = - typeof rawLimit === "number" && Number.isInteger(rawLimit) - ? Math.min(100, Math.max(1, rawLimit)) - : 100; + const limit = this.clampInvoiceFetchLimit(options?.limit); const invoices = await this.stripeRequest>( "GET", @@ -465,32 +501,49 @@ export class StripeSubscriptionService { ); return invoices.data - .map((invoice): PaidInvoice | null => { - const paidAtEpoch = invoice.status_transitions?.paid_at; - const amountPaidCents = invoice.amount_paid; - const currency = (invoice.currency || "").toLowerCase(); - if ( - !paidAtEpoch || - !amountPaidCents || - amountPaidCents <= 0 || - currency !== "usd" - ) { - return null; + .map((invoice) => this.normalizePaidInvoice(invoice, customer)) + .filter((item): item is PaidInvoice => Boolean(item)) + .sort((a, b) => a.paidAt.localeCompare(b.paidAt)); + } + + async listPaidInvoicesAcrossCustomers(options?: { + limit?: number; + maxPages?: number; + }): Promise { + const limit = this.clampInvoiceFetchLimit(options?.limit); + const maxPages = + typeof options?.maxPages === "number" && Number.isInteger(options.maxPages) + ? Math.min(50, Math.max(1, options.maxPages)) + : 10; + + const results: PaidInvoice[] = []; + let startingAfter: string | undefined; + + for (let page = 0; page < maxPages; page += 1) { + const invoices = await this.stripeRequest>( + "GET", + "/invoices", + { + status: "paid", + limit, + starting_after: startingAfter, } + ); + + results.push( + ...invoices.data + .map((invoice) => this.normalizePaidInvoice(invoice)) + .filter((item): item is PaidInvoice => Boolean(item)) + ); + + const lastId = invoices.data[invoices.data.length - 1]?.id; + if (!invoices.has_more || !lastId) { + break; + } + startingAfter = lastId; + } - const paidAtIso = new Date(paidAtEpoch * 1000).toISOString(); - const linePriceId = invoice.lines?.data?.[0]?.price?.id; - - return { - invoiceId: invoice.id, - customerId: invoice.customer || customer.id, - customerEmail: invoice.customer_email || customer.email, - subscriptionId: invoice.subscription, - priceId: linePriceId, - amountPaidCents, - paidAt: paidAtIso, - }; - }) + return results .filter((item): item is PaidInvoice => Boolean(item)) .sort((a, b) => a.paidAt.localeCompare(b.paidAt)); } diff --git a/src/tools/subscriptions.ts b/src/tools/subscriptions.ts index 0d75e7c..9a56fa7 100644 --- a/src/tools/subscriptions.ts +++ b/src/tools/subscriptions.ts @@ -152,6 +152,62 @@ function formatMoney(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } +function renderPoolSyncResult( + title: string, + result: { + scope: "customer" | "all_customers"; + customerId?: string; + email?: string; + month?: string; + fetchedInvoiceCount: number; + processedInvoiceCount: number; + syncedCount: number; + duplicateCount: number; + skippedCount: number; + records: Array<{ + invoiceId: string; + contributionId: string; + amountUsdCents: number; + duplicated: boolean; + paidAt: string; + }>; + } +): string { + const lines: string[] = [ + `## ${title}`, + "", + "| Field | Value |", + "|-------|-------|", + `| Scope | ${result.scope === "all_customers" ? "all_customers" : "customer"} |`, + `| Customer ID | ${result.customerId || "N/A"} |`, + `| Email | ${result.email || "N/A"} |`, + `| Month Filter | ${result.month || "none"} |`, + `| Invoices Fetched | ${result.fetchedInvoiceCount} |`, + `| Invoices Processed | ${result.processedInvoiceCount} |`, + `| Synced | ${result.syncedCount} |`, + `| Duplicates | ${result.duplicateCount} |`, + `| Skipped (month filter) | ${result.skippedCount} |`, + ]; + + if (result.records.length > 0) { + lines.push( + "", + "### Processed Invoices", + "", + "| Invoice ID | Contribution ID | Amount | Duplicate | Paid At |", + "|------------|------------------|--------|-----------|---------|", + ...result.records.map( + (record) => + `| ${record.invoiceId} | ${record.contributionId} | ${formatMoney(record.amountUsdCents)} | ${record.duplicated ? "Yes" : "No"} | ${record.paidAt} |` + ) + ); + } else { + lines.push("", "No paid invoices matched the provided filter."); + } + + return lines.join("\n"); +} + export async function syncSubscriptionPoolContributionsTool( month?: string, email?: string, @@ -167,39 +223,50 @@ export async function syncSubscriptionPoolContributionsTool( userId, limit, }); + return { + content: [ + { + type: "text" as const, + text: renderPoolSyncResult("Subscription Pool Sync", result), + }, + ], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown sync error occurred"; + return { + content: [ + { + type: "text" as const, + text: `Subscription pool sync failed: ${message}`, + }, + ], + isError: true, + }; + } +} - const lines: string[] = [ - "## Subscription Pool Sync", - "", - "| Field | Value |", - "|-------|-------|", - `| Customer ID | ${result.customerId || "N/A"} |`, - `| Email | ${result.email || "N/A"} |`, - `| Month Filter | ${result.month || "none"} |`, - `| Invoices Fetched | ${result.fetchedInvoiceCount} |`, - `| Invoices Processed | ${result.processedInvoiceCount} |`, - `| Synced | ${result.syncedCount} |`, - `| Duplicates | ${result.duplicateCount} |`, - `| Skipped (month filter) | ${result.skippedCount} |`, - ]; - - if (result.records.length > 0) { - lines.push( - "", - "### Processed Invoices", - "", - "| Invoice ID | Contribution ID | Amount | Duplicate | Paid At |", - "|------------|------------------|--------|-----------|---------|", - ...result.records.map( - (record) => - `| ${record.invoiceId} | ${record.contributionId} | ${formatMoney(record.amountUsdCents)} | ${record.duplicated ? "Yes" : "No"} | ${record.paidAt} |` - ) - ); - } else { - lines.push("", "No paid invoices matched the provided filter."); - } +export async function syncAllSubscriptionPoolContributionsTool( + month?: string, + limit?: number, + maxPages?: number +) { + try { + const result = await poolSync.syncPaidInvoices({ + month, + limit, + maxPages, + allCustomers: true, + }); - return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + return { + content: [ + { + type: "text" as const, + text: renderPoolSyncResult("All-Customer Subscription Pool Sync", result), + }, + ], + }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown sync error occurred"; @@ -207,7 +274,7 @@ export async function syncSubscriptionPoolContributionsTool( content: [ { type: "text" as const, - text: `Subscription pool sync failed: ${message}`, + text: `All-customer subscription pool sync failed: ${message}`, }, ], isError: true, diff --git a/tests/subscription-pool-sync.test.ts b/tests/subscription-pool-sync.test.ts index f1c169e..e6c5d82 100644 --- a/tests/subscription-pool-sync.test.ts +++ b/tests/subscription-pool-sync.test.ts @@ -20,13 +20,22 @@ class InMemoryPoolAccountingStore implements PoolAccountingStore { } class StubSubscriptionService { - constructor(private readonly invoices: PaidInvoice[]) {} + constructor( + private readonly customerInvoices: PaidInvoice[], + private readonly allInvoices: PaidInvoice[] = [] + ) {} async listPaidInvoices( _input?: unknown, _options?: unknown ): Promise { - return this.invoices; + return this.customerInvoices; + } + + async listPaidInvoicesAcrossCustomers( + _options?: unknown + ): Promise { + return this.allInvoices; } } @@ -110,4 +119,49 @@ describe("SubscriptionPoolSyncService", () => { sync.syncPaidInvoices({ customerId: "cus_123", month: "03-2026" }) ).rejects.toThrow("month must be in YYYY-MM format"); }); + + it("supports all-customer sync without identity", async () => { + const invoices: PaidInvoice[] = [ + { + invoiceId: "in_global_1", + customerId: "cus_a", + customerEmail: "a@example.com", + subscriptionId: "sub_a", + priceId: "price_starter", + amountPaidCents: 100, + paidAt: "2026-03-01T00:00:00.000Z", + }, + { + invoiceId: "in_global_2", + customerId: "cus_b", + customerEmail: "b@example.com", + subscriptionId: "sub_b", + priceId: "price_growth", + amountPaidCents: 300, + paidAt: "2026-03-10T00:00:00.000Z", + }, + ]; + + const sync = new SubscriptionPoolSyncService( + new StubSubscriptionService([], invoices), + poolAccounting + ); + + const result = await sync.syncPaidInvoices({ + allCustomers: true, + month: "2026-03", + limit: 50, + maxPages: 3, + }); + + expect(result.scope).toBe("all_customers"); + expect(result.fetchedInvoiceCount).toBe(2); + expect(result.processedInvoiceCount).toBe(2); + expect(result.syncedCount).toBe(2); + expect(result.duplicateCount).toBe(0); + + const march = await poolAccounting.getMonthlySummary("2026-03"); + expect(march.contributionCount).toBe(2); + expect(march.totalUsdCents).toBe(400); + }); }); diff --git a/tests/subscription-service.test.ts b/tests/subscription-service.test.ts index 88ae3d0..cc862e3 100644 --- a/tests/subscription-service.test.ts +++ b/tests/subscription-service.test.ts @@ -223,4 +223,79 @@ describe("StripeSubscriptionService", () => { "/invoices?customer=cus_123&status=paid&limit=5" ); }); + + it("lists paid invoices across all customers with pagination", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse(200, { + has_more: true, + data: [ + { + id: "in_3", + customer: "cus_999", + customer_email: "c@example.com", + subscription: "sub_999", + amount_paid: 500, + currency: "usd", + status_transitions: { paid_at: 1_707_000_200 }, + lines: { data: [{ id: "il_3", price: { id: "price_growth" } }] }, + }, + { + id: "in_2", + customer: "cus_123", + customer_email: "b@example.com", + subscription: "sub_123", + amount_paid: 100, + currency: "usd", + status_transitions: { paid_at: 1_707_000_100 }, + lines: { data: [{ id: "il_2", price: { id: "price_starter" } }] }, + }, + ], + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + has_more: false, + data: [ + { + id: "in_1", + customer: "cus_321", + customer_email: "a@example.com", + subscription: "sub_321", + amount_paid: 300, + currency: "usd", + status_transitions: { paid_at: 1_707_000_000 }, + lines: { data: [{ id: "il_1", price: { id: "price_impact" } }] }, + }, + ], + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new StripeSubscriptionService(); + const invoices = await service.listPaidInvoicesAcrossCustomers({ + limit: 2, + maxPages: 5, + }); + + expect(invoices).toHaveLength(3); + expect(invoices[0]).toMatchObject({ + invoiceId: "in_1", + customerId: "cus_321", + amountPaidCents: 300, + }); + expect(invoices[2]).toMatchObject({ + invoiceId: "in_3", + customerId: "cus_999", + amountPaidCents: 500, + }); + + const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(firstUrl).toContain("/invoices?status=paid&limit=2"); + const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(secondUrl).toContain( + "/invoices?status=paid&limit=2&starting_after=in_2" + ); + }); }); From d8c71354eb662b13151c8bb68819ae63fb7a9450 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:22:32 -0800 Subject: [PATCH 16/31] feat: add monthly reconciliation orchestration tool --- README.md | 13 + src/index.ts | 118 ++++++- src/tools/monthly-batch-retirement.ts | 386 +++++++++++++++------- tests/monthly-reconciliation-tool.test.ts | 145 ++++++++ 4 files changed, 544 insertions(+), 118 deletions(-) create mode 100644 tests/monthly-reconciliation-tool.test.ts diff --git a/README.md b/README.md index 40d7d87..97bb040 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,19 @@ Supports: **When it's used:** Running the monthly pooled buy-and-retire process from aggregated subscription funds. +### `run_monthly_reconciliation` + +Runs an operator workflow in one call: +1. Optional contribution sync from Stripe paid invoices, then +2. Monthly batch retirement planning/execution. + +Supports all `run_monthly_batch_retirement` execution parameters plus sync controls: +- `sync_scope`: `none` | `customer` | `all_customers` (default), +- `email` / `customer_id` / `user_id` for customer-scoped sync, +- `invoice_limit` and `invoice_max_pages` for Stripe pagination control. + +**When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. + ### `publish_subscriber_certificate_page` Generates a user-facing HTML certificate page for a subscriber's monthly fractional attribution record and returns: diff --git a/src/index.ts b/src/index.ts index bf80634..7336a5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,10 @@ import { getPoolAccountingSummaryTool, recordPoolContributionTool, } from "./tools/pool-accounting.js"; -import { runMonthlyBatchRetirementTool } from "./tools/monthly-batch-retirement.js"; +import { + runMonthlyBatchRetirementTool, + runMonthlyReconciliationTool, +} from "./tools/monthly-batch-retirement.js"; import { getSubscriberAttributionCertificateTool, getSubscriberImpactDashboardTool, @@ -118,11 +121,12 @@ const server = new McpServer( "7. sync_all_subscription_pool_contributions — account-wide Stripe paid-invoice reconciliation with pagination", "8. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", "9. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", - "10. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", - "11. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", - "12. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", - "13. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", - "14. link_identity_session / recover_identity_session — identity linking and recovery flows", + "10. run_monthly_reconciliation — optional contribution sync + monthly batch in one operator workflow", + "11. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", + "12. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", + "13. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", + "14. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", + "15. link_identity_session / recover_identity_session — identity linking and recovery flows", "", ...(walletMode ? [ @@ -136,6 +140,7 @@ const server = new McpServer( "The sync_all_subscription_pool_contributions tool performs account-wide paid invoice ingestion across customers with pagination controls.", "Pool accounting tools support per-user contribution tracking and monthly aggregation summaries.", "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", + "The run_monthly_reconciliation tool orchestrates contribution sync and monthly batch execution in one call.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", "Certificate frontend tool publishes shareable subscriber certificate pages to a configurable URL/path.", "Dashboard frontend tool publishes shareable subscriber impact dashboard pages to a configurable URL/path.", @@ -544,6 +549,107 @@ server.tool( } ); +// Tool: Reconcile contributions and run monthly batch in one operation +server.tool( + "run_monthly_reconciliation", + "Runs monthly pool reconciliation workflow. Optionally syncs paid Stripe invoices first, then executes monthly pooled retirement planning/execution.", + { + month: z + .string() + .describe("Target month in YYYY-MM format, e.g. 2026-03"), + credit_type: z + .enum(["carbon", "biodiversity"]) + .optional() + .describe("Optional credit type filter for the batch retirement"), + max_budget_usd: z + .number() + .optional() + .describe("Optional max budget in USD for this run"), + dry_run: z + .boolean() + .optional() + .default(true) + .describe("If true, plans the batch without broadcasting a transaction"), + force: z + .boolean() + .optional() + .default(false) + .describe("If true, allows rerunning a month even if a prior success exists"), + reason: z + .string() + .optional() + .describe("Optional retirement reason override"), + jurisdiction: z + .string() + .optional() + .describe("Optional retirement jurisdiction override"), + sync_scope: z + .enum(["none", "customer", "all_customers"]) + .optional() + .default("all_customers") + .describe("Contribution sync scope before batch execution"), + email: z + .string() + .optional() + .describe("Customer email for sync_scope=customer"), + customer_id: z + .string() + .optional() + .describe("Stripe customer ID for sync_scope=customer"), + user_id: z + .string() + .optional() + .describe("Optional internal user ID override for synced contributions"), + invoice_limit: z + .number() + .int() + .optional() + .describe("Invoice fetch size per request (1-100)"), + invoice_max_pages: z + .number() + .int() + .optional() + .describe("Max pages for account-wide sync (1-50)"), + }, + { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + async ({ + month, + credit_type, + max_budget_usd, + dry_run, + force, + reason, + jurisdiction, + sync_scope, + email, + customer_id, + user_id, + invoice_limit, + invoice_max_pages, + }) => { + return runMonthlyReconciliationTool({ + month, + creditType: credit_type, + maxBudgetUsd: max_budget_usd, + dryRun: dry_run, + force, + reason, + jurisdiction, + syncScope: sync_scope, + email, + customerId: customer_id, + userId: user_id, + invoiceLimit: invoice_limit, + invoiceMaxPages: invoice_max_pages, + }); + } +); + // Tool: User-facing fractional impact dashboard server.tool( "get_subscriber_impact_dashboard", diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 6bf1af1..8d505b5 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -1,6 +1,30 @@ import { MonthlyBatchRetirementExecutor } from "../services/batch-retirement/executor.js"; +import type { RunMonthlyBatchResult } from "../services/batch-retirement/types.js"; +import { + SubscriptionPoolSyncService, + type SubscriptionPoolSyncResult, +} from "../services/subscription/pool-sync.js"; const executor = new MonthlyBatchRetirementExecutor(); +const poolSync = new SubscriptionPoolSyncService(); + +type SyncScope = "none" | "customer" | "all_customers"; + +export interface RunMonthlyReconciliationInput { + month: string; + creditType?: "carbon" | "biodiversity"; + maxBudgetUsd?: number; + dryRun?: boolean; + force?: boolean; + reason?: string; + jurisdiction?: string; + syncScope?: SyncScope; + email?: string; + customerId?: string; + userId?: string; + invoiceLimit?: number; + invoiceMaxPages?: number; +} function formatUsd(cents: number): string { return `$${(cents / 100).toFixed(2)}`; @@ -23,6 +47,181 @@ function formatRegenMicro(value: string): string { return formatMicroAmount(amount, "REGEN", 6); } +function renderMonthlyBatchResult( + result: RunMonthlyBatchResult, + title: string = "Monthly Batch Retirement" +): string { + const lines: string[] = [ + `## ${title}`, + "", + `| Field | Value |`, + `|-------|-------|`, + `| Status | ${result.status} |`, + `| Month | ${result.month} |`, + `| Credit Type | ${result.creditType || "all"} |`, + `| Gross Budget | ${formatUsd(result.budgetUsdCents)} |`, + `| Planned Quantity | ${result.plannedQuantity} |`, + `| Planned Cost | ${formatMicroAmount(result.plannedCostMicro, result.plannedCostDenom, denomExponent(result.plannedCostDenom))} |`, + ]; + + if (result.protocolFee) { + const pct = (result.protocolFee.protocolFeeBps / 100).toFixed(2); + lines.splice( + 7, + 0, + `| Protocol Fee | ${formatUsd(result.protocolFee.protocolFeeUsdCents)} (${pct}%) |`, + `| Credit Purchase Budget | ${formatUsd(result.protocolFee.creditBudgetUsdCents)} |` + ); + } + + if (result.creditMix) { + lines.push( + `| Credit Mix Policy | ${result.creditMix.policy} |`, + `| Credit Mix Strategy | ${result.creditMix.strategy} |` + ); + } + + if (result.regenAcquisition) { + lines.push( + `| REGEN Acquisition Status | ${result.regenAcquisition.status} (${result.regenAcquisition.provider}) |`, + `| REGEN Acquisition Spend | ${formatMicroAmount(BigInt(result.regenAcquisition.spendMicro), result.regenAcquisition.spendDenom, denomExponent(result.regenAcquisition.spendDenom))} |`, + `| Estimated REGEN | ${formatRegenMicro(result.regenAcquisition.estimatedRegenMicro)} |` + ); + + if (result.regenAcquisition.acquiredRegenMicro) { + lines.push( + `| Acquired REGEN | ${formatRegenMicro(result.regenAcquisition.acquiredRegenMicro)} |` + ); + } + if (result.regenAcquisition.txHash) { + lines.push( + `| REGEN Acquisition Tx | \`${result.regenAcquisition.txHash}\` |` + ); + } + } + + if (result.regenBurn) { + lines.push( + `| REGEN Burn Status | ${result.regenBurn.status} (${result.regenBurn.provider}) |`, + `| REGEN Burn Amount | ${formatMicroAmount(BigInt(result.regenBurn.amountMicro), "REGEN", 6)} |` + ); + if (result.regenBurn.burnAddress) { + lines.push(`| REGEN Burn Address | \`${result.regenBurn.burnAddress}\` |`); + } + if (result.regenBurn.txHash) { + lines.push(`| REGEN Burn Tx | \`${result.regenBurn.txHash}\` |`); + } + } + + if (result.txHash) { + lines.push(`| Transaction Hash | \`${result.txHash}\` |`); + } + if (typeof result.blockHeight === "number") { + lines.push(`| Block Height | ${result.blockHeight} |`); + } + if (result.retirementId) { + lines.push(`| Retirement ID | ${result.retirementId} |`); + } + + if (result.attributions && result.attributions.length > 0) { + lines.push( + "", + "### Fractional Attribution", + "", + "| User ID | Share | Attributed Budget | Attributed Quantity |", + "|---------|-------|-------------------|---------------------|", + ...result.attributions.slice(0, 25).map((item) => { + const share = `${(item.sharePpm / 10_000).toFixed(2)}%`; + return `| ${item.userId} | ${share} | ${formatUsd(item.attributedBudgetUsdCents)} | ${item.attributedQuantity} |`; + }) + ); + + if (result.attributions.length > 25) { + lines.push( + "", + `Showing 25 of ${result.attributions.length} attribution rows.` + ); + } + } + + if (result.creditMix) { + lines.push( + "", + "### Credit Mix Allocation", + "", + "| Credit Type | Budget | Spent | Quantity | Orders |", + "|-------------|--------|-------|----------|--------|", + ...result.creditMix.allocations.map( + (item) => + `| ${item.creditType} | ${formatMicroAmount(BigInt(item.budgetMicro), result.plannedCostDenom, denomExponent(result.plannedCostDenom))} | ${formatMicroAmount(BigInt(item.spentMicro), result.plannedCostDenom, denomExponent(result.plannedCostDenom))} | ${item.selectedQuantity} | ${item.orderCount} |` + ) + ); + } + + lines.push("", result.message); + if (result.regenAcquisition) { + lines.push(`REGEN acquisition: ${result.regenAcquisition.message}`); + } + if (result.regenBurn) { + lines.push(`REGEN burn: ${result.regenBurn.message}`); + } + + return lines.join("\n"); +} + +function renderSyncSummary( + syncScope: SyncScope, + result?: SubscriptionPoolSyncResult +): string { + if (syncScope === "none") { + return [ + "| Field | Value |", + "|-------|-------|", + "| Scope | none |", + "| Status | skipped |", + "| Details | Contribution sync was skipped for this run. |", + ].join("\n"); + } + + if (!result) { + return [ + "| Field | Value |", + "|-------|-------|", + `| Scope | ${syncScope} |`, + "| Status | no_data |", + ].join("\n"); + } + + const lines: string[] = [ + "| Field | Value |", + "|-------|-------|", + `| Scope | ${result.scope} |`, + `| Invoices Fetched | ${result.fetchedInvoiceCount} |`, + `| Invoices Processed | ${result.processedInvoiceCount} |`, + `| Synced | ${result.syncedCount} |`, + `| Duplicates | ${result.duplicateCount} |`, + `| Skipped (month filter) | ${result.skippedCount} |`, + ]; + + if (result.records.length > 0) { + lines.push( + "", + "| Invoice ID | Contribution ID | Amount | Duplicate |", + "|------------|------------------|--------|-----------|", + ...result.records.slice(0, 20).map( + (record) => + `| ${record.invoiceId} | ${record.contributionId} | ${formatUsd(record.amountUsdCents)} | ${record.duplicated ? "Yes" : "No"} |` + ) + ); + + if (result.records.length > 20) { + lines.push("", `Showing 20 of ${result.records.length} processed invoices.`); + } + } + + return lines.join("\n"); +} + export async function runMonthlyBatchRetirementTool( month: string, creditType?: "carbon" | "biodiversity", @@ -44,130 +243,93 @@ export async function runMonthlyBatchRetirementTool( paymentDenom: "USDC", }); - const lines: string[] = [ - `## Monthly Batch Retirement`, - ``, - `| Field | Value |`, - `|-------|-------|`, - `| Status | ${result.status} |`, - `| Month | ${result.month} |`, - `| Credit Type | ${result.creditType || "all"} |`, - `| Gross Budget | ${formatUsd(result.budgetUsdCents)} |`, - `| Planned Quantity | ${result.plannedQuantity} |`, - `| Planned Cost | ${formatMicroAmount(result.plannedCostMicro, result.plannedCostDenom, denomExponent(result.plannedCostDenom))} |`, - ]; - - if (result.protocolFee) { - const pct = (result.protocolFee.protocolFeeBps / 100).toFixed(2); - lines.splice( - 7, - 0, - `| Protocol Fee | ${formatUsd(result.protocolFee.protocolFeeUsdCents)} (${pct}%) |`, - `| Credit Purchase Budget | ${formatUsd(result.protocolFee.creditBudgetUsdCents)} |` - ); - } - - if (result.creditMix) { - lines.push( - `| Credit Mix Policy | ${result.creditMix.policy} |`, - `| Credit Mix Strategy | ${result.creditMix.strategy} |` - ); - } - - if (result.regenAcquisition) { - lines.push( - `| REGEN Acquisition Status | ${result.regenAcquisition.status} (${result.regenAcquisition.provider}) |`, - `| REGEN Acquisition Spend | ${formatMicroAmount(BigInt(result.regenAcquisition.spendMicro), result.regenAcquisition.spendDenom, denomExponent(result.regenAcquisition.spendDenom))} |`, - `| Estimated REGEN | ${formatRegenMicro(result.regenAcquisition.estimatedRegenMicro)} |` - ); - - if (result.regenAcquisition.acquiredRegenMicro) { - lines.push( - `| Acquired REGEN | ${formatRegenMicro(result.regenAcquisition.acquiredRegenMicro)} |` - ); - } - if (result.regenAcquisition.txHash) { - lines.push( - `| REGEN Acquisition Tx | \`${result.regenAcquisition.txHash}\` |` - ); - } - } - - if (result.regenBurn) { - lines.push( - `| REGEN Burn Status | ${result.regenBurn.status} (${result.regenBurn.provider}) |`, - `| REGEN Burn Amount | ${formatMicroAmount(BigInt(result.regenBurn.amountMicro), "REGEN", 6)} |` - ); - if (result.regenBurn.burnAddress) { - lines.push(`| REGEN Burn Address | \`${result.regenBurn.burnAddress}\` |`); - } - if (result.regenBurn.txHash) { - lines.push(`| REGEN Burn Tx | \`${result.regenBurn.txHash}\` |`); - } - } - - if (result.txHash) { - lines.push(`| Transaction Hash | \`${result.txHash}\` |`); - } - if (typeof result.blockHeight === "number") { - lines.push(`| Block Height | ${result.blockHeight} |`); - } - if (result.retirementId) { - lines.push(`| Retirement ID | ${result.retirementId} |`); - } + return { + content: [ + { + type: "text" as const, + text: renderMonthlyBatchResult(result), + }, + ], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown monthly batch error"; + return { + content: [ + { + type: "text" as const, + text: `Monthly batch retirement failed: ${message}`, + }, + ], + isError: true, + }; + } +} - if (result.attributions && result.attributions.length > 0) { - lines.push( - "", - "### Fractional Attribution", - "", - "| User ID | Share | Attributed Budget | Attributed Quantity |", - "|---------|-------|-------------------|---------------------|", - ...result.attributions.slice(0, 25).map((item) => { - const share = `${(item.sharePpm / 10_000).toFixed(2)}%`; - return `| ${item.userId} | ${share} | ${formatUsd(item.attributedBudgetUsdCents)} | ${item.attributedQuantity} |`; - }) - ); +export async function runMonthlyReconciliationTool( + input: RunMonthlyReconciliationInput +) { + try { + const syncScope = input.syncScope || "all_customers"; - if (result.attributions.length > 25) { - lines.push( - "", - `Showing 25 of ${result.attributions.length} attribution rows.` - ); - } + let syncResult: SubscriptionPoolSyncResult | undefined; + if (syncScope === "customer") { + syncResult = await poolSync.syncPaidInvoices({ + month: input.month, + email: input.email, + customerId: input.customerId, + userId: input.userId, + limit: input.invoiceLimit, + }); + } else if (syncScope === "all_customers") { + syncResult = await poolSync.syncPaidInvoices({ + month: input.month, + limit: input.invoiceLimit, + maxPages: input.invoiceMaxPages, + allCustomers: true, + }); } - if (result.creditMix) { - lines.push( - "", - "### Credit Mix Allocation", - "", - "| Credit Type | Budget | Spent | Quantity | Orders |", - "|-------------|--------|-------|----------|--------|", - ...result.creditMix.allocations.map( - (item) => - `| ${item.creditType} | ${formatMicroAmount(BigInt(item.budgetMicro), result.plannedCostDenom, denomExponent(result.plannedCostDenom))} | ${formatMicroAmount(BigInt(item.spentMicro), result.plannedCostDenom, denomExponent(result.plannedCostDenom))} | ${item.selectedQuantity} | ${item.orderCount} |` - ) - ); - } + const batchResult = await executor.runMonthlyBatch({ + month: input.month, + creditType: input.creditType, + maxBudgetUsd: input.maxBudgetUsd, + dryRun: input.dryRun, + force: input.force, + reason: input.reason, + jurisdiction: input.jurisdiction, + paymentDenom: "USDC", + }); - lines.push("", result.message); - if (result.regenAcquisition) { - lines.push(`REGEN acquisition: ${result.regenAcquisition.message}`); - } - if (result.regenBurn) { - lines.push(`REGEN burn: ${result.regenBurn.message}`); - } + const lines: string[] = [ + "## Monthly Reconciliation", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${input.month} |`, + `| Sync Scope | ${syncScope} |`, + `| Batch Status | ${batchResult.status} |`, + "", + "### Contribution Sync", + "", + renderSyncSummary(syncScope, syncResult), + "", + "### Batch Retirement", + "", + renderMonthlyBatchResult(batchResult, "Monthly Batch Retirement"), + ]; - return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + }; } catch (error) { const message = - error instanceof Error ? error.message : "Unknown monthly batch error"; + error instanceof Error ? error.message : "Unknown reconciliation error"; return { content: [ { type: "text" as const, - text: `Monthly batch retirement failed: ${message}`, + text: `Monthly reconciliation failed: ${message}`, }, ], isError: true, diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts new file mode 100644 index 0000000..574a29e --- /dev/null +++ b/tests/monthly-reconciliation-tool.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + runMonthlyBatch: vi.fn(), + syncPaidInvoices: vi.fn(), +})); + +vi.mock("../src/services/batch-retirement/executor.js", () => ({ + MonthlyBatchRetirementExecutor: class { + runMonthlyBatch(input: unknown) { + return mocks.runMonthlyBatch(input); + } + }, +})); + +vi.mock("../src/services/subscription/pool-sync.js", () => ({ + SubscriptionPoolSyncService: class { + syncPaidInvoices(input: unknown) { + return mocks.syncPaidInvoices(input); + } + }, +})); + +import { runMonthlyReconciliationTool } from "../src/tools/monthly-batch-retirement.js"; + +function responseText(result: { content: Array<{ type: "text"; text: string }> }): string { + return result.content[0]?.text ?? ""; +} + +describe("runMonthlyReconciliationTool", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.syncPaidInvoices.mockResolvedValue({ + scope: "all_customers", + month: "2026-03", + fetchedInvoiceCount: 2, + processedInvoiceCount: 2, + syncedCount: 2, + duplicateCount: 0, + skippedCount: 0, + records: [ + { + invoiceId: "in_1", + contributionId: "contrib_1", + duplicated: false, + amountUsdCents: 300, + paidAt: "2026-03-01T00:00:00.000Z", + }, + ], + }); + + mocks.runMonthlyBatch.mockResolvedValue({ + status: "dry_run", + month: "2026-03", + creditType: undefined, + budgetUsdCents: 300, + plannedQuantity: "1.000000", + plannedCostMicro: 3_000_000n, + plannedCostDenom: "USDC", + message: "Dry run complete. No on-chain transaction was broadcast.", + }); + }); + + it("runs all-customer sync before monthly batch by default", async () => { + const result = await runMonthlyReconciliationTool({ month: "2026-03" }); + const text = responseText(result); + + expect(mocks.syncPaidInvoices).toHaveBeenCalledWith({ + month: "2026-03", + limit: undefined, + maxPages: undefined, + allCustomers: true, + }); + expect(mocks.runMonthlyBatch).toHaveBeenCalledWith({ + month: "2026-03", + creditType: undefined, + maxBudgetUsd: undefined, + dryRun: undefined, + force: undefined, + reason: undefined, + jurisdiction: undefined, + paymentDenom: "USDC", + }); + + expect(text).toContain("## Monthly Reconciliation"); + expect(text).toContain("| Sync Scope | all_customers |"); + expect(text).toContain("| Batch Status | dry_run |"); + expect(text).toContain("| Scope | all_customers |"); + }); + + it("supports customer-scoped sync input", async () => { + await runMonthlyReconciliationTool({ + month: "2026-03", + syncScope: "customer", + email: "alice@example.com", + customerId: "cus_123", + userId: "user_123", + invoiceLimit: 25, + creditType: "carbon", + dryRun: true, + force: false, + }); + + expect(mocks.syncPaidInvoices).toHaveBeenCalledWith({ + month: "2026-03", + email: "alice@example.com", + customerId: "cus_123", + userId: "user_123", + limit: 25, + }); + + expect(mocks.runMonthlyBatch).toHaveBeenCalledWith( + expect.objectContaining({ + month: "2026-03", + creditType: "carbon", + }) + ); + }); + + it("skips sync when sync_scope=none", async () => { + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + syncScope: "none", + }); + const text = responseText(result); + + expect(mocks.syncPaidInvoices).not.toHaveBeenCalled(); + expect(mocks.runMonthlyBatch).toHaveBeenCalledTimes(1); + expect(text).toContain("| Scope | none |"); + expect(text).toContain("Contribution sync was skipped"); + }); + + it("returns an error and skips batch execution if sync fails", async () => { + mocks.syncPaidInvoices.mockRejectedValue(new Error("Stripe unavailable")); + + const result = await runMonthlyReconciliationTool({ month: "2026-03" }); + + expect(result.isError).toBe(true); + expect(responseText(result)).toContain( + "Monthly reconciliation failed: Stripe unavailable" + ); + expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + }); +}); From e59dd32f6c1df03d5aeccd6e455e5b695b1ed310 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:37:14 -0800 Subject: [PATCH 17/31] feat: add monthly batch execution history query tool --- README.md | 11 ++++ src/index.ts | 57 ++++++++++++++++-- src/services/batch-retirement/executor.ts | 33 ++++++++++ src/services/batch-retirement/types.ts | 9 +++ src/tools/monthly-batch-retirement.ts | 65 +++++++++++++++++++- tests/monthly-batch-executor.test.ts | 73 +++++++++++++++++++++++ tests/monthly-reconciliation-tool.test.ts | 61 ++++++++++++++++++- 7 files changed, 302 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 97bb040..6262897 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,17 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr **When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. +### `get_monthly_batch_execution_history` + +Returns stored monthly batch execution history from the local execution ledger (`REGEN_BATCH_EXECUTIONS_PATH`) with optional filters: +- `month` (`YYYY-MM`) +- `status` (`success` | `failed` | `dry_run`) +- `credit_type` (`carbon` | `biodiversity`) +- `dry_run` (`true` / `false`) +- `limit` (`1-200`, default `50`) + +**When it's used:** Operator auditing, post-run troubleshooting, and confirming prior dry-run/success/failure outcomes. + ### `publish_subscriber_certificate_page` Generates a user-facing HTML certificate page for a subscriber's monthly fractional attribution record and returns: diff --git a/src/index.ts b/src/index.ts index 7336a5b..30f1968 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { recordPoolContributionTool, } from "./tools/pool-accounting.js"; import { + getMonthlyBatchExecutionHistoryTool, runMonthlyBatchRetirementTool, runMonthlyReconciliationTool, } from "./tools/monthly-batch-retirement.js"; @@ -122,11 +123,12 @@ const server = new McpServer( "8. record_pool_contribution / get_pool_accounting_summary — track monthly subscription pool accounting", "9. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", "10. run_monthly_reconciliation — optional contribution sync + monthly batch in one operator workflow", - "11. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", - "12. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", - "13. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", - "14. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", - "15. link_identity_session / recover_identity_session — identity linking and recovery flows", + "11. get_monthly_batch_execution_history — query stored monthly batch run history with filters", + "12. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", + "13. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", + "14. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", + "15. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", + "16. link_identity_session / recover_identity_session — identity linking and recovery flows", "", ...(walletMode ? [ @@ -141,6 +143,7 @@ const server = new McpServer( "Pool accounting tools support per-user contribution tracking and monthly aggregation summaries.", "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", "The run_monthly_reconciliation tool orchestrates contribution sync and monthly batch execution in one call.", + "The get_monthly_batch_execution_history tool returns persisted batch run history for operator auditing and troubleshooting.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", "Certificate frontend tool publishes shareable subscriber certificate pages to a configurable URL/path.", "Dashboard frontend tool publishes shareable subscriber impact dashboard pages to a configurable URL/path.", @@ -650,6 +653,50 @@ server.tool( } ); +// Tool: Query stored monthly batch execution history +server.tool( + "get_monthly_batch_execution_history", + "Returns persisted monthly batch execution history records with optional filters for month, status, credit type, dry-run mode, and result count limit.", + { + month: z + .string() + .optional() + .describe("Optional month filter in YYYY-MM format"), + status: z + .enum(["success", "failed", "dry_run"]) + .optional() + .describe("Optional execution status filter"), + credit_type: z + .enum(["carbon", "biodiversity"]) + .optional() + .describe("Optional credit type filter"), + dry_run: z + .boolean() + .optional() + .describe("Optional dry-run mode filter"), + limit: z + .number() + .int() + .optional() + .describe("Max records to return (1-200, default 50)"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async ({ month, status, credit_type, dry_run, limit }) => { + return getMonthlyBatchExecutionHistoryTool( + month, + status, + credit_type, + dry_run, + limit + ); + } +); + // Tool: User-facing fractional impact dashboard server.tool( "get_subscriber_impact_dashboard", diff --git a/src/services/batch-retirement/executor.ts b/src/services/batch-retirement/executor.ts index a0a961d..c0647a0 100644 --- a/src/services/batch-retirement/executor.ts +++ b/src/services/batch-retirement/executor.ts @@ -18,6 +18,7 @@ import { import { JsonFileBatchExecutionStore } from "./store.js"; import type { BatchCreditMixPolicy, + BatchExecutionHistoryQuery, BatchExecutionRecord, BatchExecutionStore, BudgetOrderSelection, @@ -151,6 +152,38 @@ export class MonthlyBatchRetirementExecutor { await this.deps.executionStore.writeState(state); } + async getExecutionHistory( + query: BatchExecutionHistoryQuery = {} + ): Promise { + if (query.month && !MONTH_REGEX.test(query.month)) { + throw new Error("month must be in YYYY-MM format"); + } + + const state = await this.deps.executionStore.readState(); + const filtered = state.executions.filter((item) => { + if (query.month && item.month !== query.month) return false; + if (query.status && item.status !== query.status) return false; + if (query.creditType && item.creditType !== query.creditType) return false; + if (typeof query.dryRun === "boolean" && item.dryRun !== query.dryRun) { + return false; + } + return true; + }); + + const newestFirst = query.newestFirst !== false; + filtered.sort((a, b) => + newestFirst + ? b.executedAt.localeCompare(a.executedAt) + : a.executedAt.localeCompare(b.executedAt) + ); + + const limit = + typeof query.limit === "number" && Number.isInteger(query.limit) + ? Math.min(200, Math.max(1, query.limit)) + : 50; + return filtered.slice(0, limit); + } + async runMonthlyBatch(input: RunMonthlyBatchInput): Promise { if (!MONTH_REGEX.test(input.month)) { throw new Error("month must be in YYYY-MM format"); diff --git a/src/services/batch-retirement/types.ts b/src/services/batch-retirement/types.ts index 7e7e95a..8c1f56b 100644 --- a/src/services/batch-retirement/types.ts +++ b/src/services/batch-retirement/types.ts @@ -122,6 +122,15 @@ export interface BatchExecutionStore { writeState(state: BatchExecutionState): Promise; } +export interface BatchExecutionHistoryQuery { + month?: string; + status?: BatchExecutionStatus; + creditType?: "carbon" | "biodiversity"; + dryRun?: boolean; + limit?: number; + newestFirst?: boolean; +} + export interface RunMonthlyBatchInput { month: string; creditType?: "carbon" | "biodiversity"; diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 8d505b5..211aef5 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -1,5 +1,8 @@ import { MonthlyBatchRetirementExecutor } from "../services/batch-retirement/executor.js"; -import type { RunMonthlyBatchResult } from "../services/batch-retirement/types.js"; +import type { + BatchExecutionStatus, + RunMonthlyBatchResult, +} from "../services/batch-retirement/types.js"; import { SubscriptionPoolSyncService, type SubscriptionPoolSyncResult, @@ -266,6 +269,66 @@ export async function runMonthlyBatchRetirementTool( } } +export async function getMonthlyBatchExecutionHistoryTool( + month?: string, + status?: BatchExecutionStatus, + creditType?: "carbon" | "biodiversity", + dryRun?: boolean, + limit?: number +) { + try { + const records = await executor.getExecutionHistory({ + month, + status, + creditType, + dryRun, + limit, + newestFirst: true, + }); + + const lines: string[] = [ + "## Monthly Batch Execution History", + "", + "| Filter | Value |", + "|--------|-------|", + `| Month | ${month || "all"} |`, + `| Status | ${status || "all"} |`, + `| Credit Type | ${creditType || "all"} |`, + `| Dry Run | ${typeof dryRun === "boolean" ? String(dryRun) : "all"} |`, + `| Returned Records | ${records.length} |`, + ]; + + if (records.length === 0) { + lines.push("", "No batch execution records matched the provided filters."); + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } + + lines.push( + "", + "| Executed At | ID | Month | Status | Dry Run | Credit Type | Budget | Retired Quantity | Tx Hash |", + "|-------------|----|-------|--------|---------|-------------|--------|------------------|---------|", + ...records.map( + (record) => + `| ${record.executedAt} | ${record.id} | ${record.month} | ${record.status} | ${record.dryRun ? "Yes" : "No"} | ${record.creditType || "all"} | ${formatUsd(record.budgetUsdCents)} | ${record.retiredQuantity} | ${record.txHash ? `\`${record.txHash}\`` : "N/A"} |` + ) + ); + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown execution history error"; + return { + content: [ + { + type: "text" as const, + text: `Execution history query failed: ${message}`, + }, + ], + isError: true, + }; + } +} + export async function runMonthlyReconciliationTool( input: RunMonthlyReconciliationInput ) { diff --git a/tests/monthly-batch-executor.test.ts b/tests/monthly-batch-executor.test.ts index e60979a..d874082 100644 --- a/tests/monthly-batch-executor.test.ts +++ b/tests/monthly-batch-executor.test.ts @@ -293,4 +293,77 @@ describe("MonthlyBatchRetirementExecutor", () => { expect(result.regenBurn?.status).toBe("failed"); expect(result.message).toContain("REGEN burn failed"); }); + + it("returns execution history with filtering, ordering, and limit", async () => { + await store.writeState({ + version: 1, + executions: [ + { + id: "batch_1", + month: "2026-01", + status: "dry_run", + dryRun: true, + reason: "Dry run Jan", + budgetUsdCents: 100, + spentMicro: "900000", + spentDenom: "USDC", + retiredQuantity: "0.450000", + executedAt: "2026-01-31T12:00:00.000Z", + }, + { + id: "batch_2", + month: "2026-02", + creditType: "carbon", + status: "success", + dryRun: false, + reason: "Success Feb", + budgetUsdCents: 200, + spentMicro: "1800000", + spentDenom: "USDC", + retiredQuantity: "0.900000", + txHash: "TX_FEB", + executedAt: "2026-02-28T12:00:00.000Z", + }, + { + id: "batch_3", + month: "2026-03", + creditType: "biodiversity", + status: "failed", + dryRun: false, + reason: "Failed Mar", + budgetUsdCents: 300, + spentMicro: "0", + spentDenom: "USDC", + retiredQuantity: "0.000000", + error: "rpc unavailable", + executedAt: "2026-03-31T12:00:00.000Z", + }, + ], + }); + + const executor = createExecutor(); + + const filtered = await executor.getExecutionHistory({ + month: "2026-02", + status: "success", + creditType: "carbon", + dryRun: false, + limit: 10, + }); + expect(filtered).toHaveLength(1); + expect(filtered[0]?.id).toBe("batch_2"); + + const newestTwo = await executor.getExecutionHistory({ limit: 2 }); + expect(newestTwo.map((item) => item.id)).toEqual(["batch_3", "batch_2"]); + + const oldestFirst = await executor.getExecutionHistory({ + limit: 3, + newestFirst: false, + }); + expect(oldestFirst.map((item) => item.id)).toEqual([ + "batch_1", + "batch_2", + "batch_3", + ]); + }); }); diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index 574a29e..f007609 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ runMonthlyBatch: vi.fn(), + getExecutionHistory: vi.fn(), syncPaidInvoices: vi.fn(), })); @@ -10,6 +11,10 @@ vi.mock("../src/services/batch-retirement/executor.js", () => ({ runMonthlyBatch(input: unknown) { return mocks.runMonthlyBatch(input); } + + getExecutionHistory(input: unknown) { + return mocks.getExecutionHistory(input); + } }, })); @@ -21,7 +26,10 @@ vi.mock("../src/services/subscription/pool-sync.js", () => ({ }, })); -import { runMonthlyReconciliationTool } from "../src/tools/monthly-batch-retirement.js"; +import { + getMonthlyBatchExecutionHistoryTool, + runMonthlyReconciliationTool, +} from "../src/tools/monthly-batch-retirement.js"; function responseText(result: { content: Array<{ type: "text"; text: string }> }): string { return result.content[0]?.text ?? ""; @@ -60,6 +68,22 @@ describe("runMonthlyReconciliationTool", () => { plannedCostDenom: "USDC", message: "Dry run complete. No on-chain transaction was broadcast.", }); + mocks.getExecutionHistory.mockResolvedValue([ + { + id: "batch_1", + month: "2026-03", + creditType: "carbon", + dryRun: false, + status: "success", + reason: "Success run", + budgetUsdCents: 300, + spentMicro: "2500000", + spentDenom: "USDC", + retiredQuantity: "1.250000", + txHash: "TX123", + executedAt: "2026-03-31T12:00:00.000Z", + }, + ]); }); it("runs all-customer sync before monthly batch by default", async () => { @@ -142,4 +166,39 @@ describe("runMonthlyReconciliationTool", () => { ); expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); }); + + it("returns monthly batch execution history table", async () => { + const result = await getMonthlyBatchExecutionHistoryTool( + "2026-03", + "success", + "carbon", + false, + 25 + ); + const text = responseText(result); + + expect(mocks.getExecutionHistory).toHaveBeenCalledWith({ + month: "2026-03", + status: "success", + creditType: "carbon", + dryRun: false, + limit: 25, + newestFirst: true, + }); + expect(text).toContain("## Monthly Batch Execution History"); + expect(text).toContain("| Returned Records | 1 |"); + expect(text).toContain("| batch_1 |"); + expect(text).toContain("| `TX123` |"); + }); + + it("returns error response when monthly batch execution history query fails", async () => { + mocks.getExecutionHistory.mockRejectedValue( + new Error("month must be in YYYY-MM format") + ); + const result = await getMonthlyBatchExecutionHistoryTool("03-2026"); + expect(result.isError).toBe(true); + expect(responseText(result)).toContain( + "Execution history query failed: month must be in YYYY-MM format" + ); + }); }); From e3f748937aafc08aa2cda0d5d6d055fa6e57563f Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:41:58 -0800 Subject: [PATCH 18/31] feat: add monthly reconciliation status readiness tool --- README.md | 14 ++ src/index.ts | 37 ++++- src/tools/monthly-batch-retirement.ts | 87 +++++++++++ tests/monthly-reconciliation-status.test.ts | 161 ++++++++++++++++++++ 4 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 tests/monthly-reconciliation-status.test.ts diff --git a/README.md b/README.md index 6262897..7c845ec 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,20 @@ Returns stored monthly batch execution history from the local execution ledger ( **When it's used:** Operator auditing, post-run troubleshooting, and confirming prior dry-run/success/failure outcomes. +### `get_monthly_reconciliation_status` + +Returns an operator readiness snapshot for a month by combining: +- pool contribution totals, +- protocol fee and net budget preview, +- latest batch execution state (`none`/`dry_run`/`success`/`failed`), +- actionable recommendation for the next step. + +**Parameters:** +- `month` (`YYYY-MM`, required) +- `credit_type` (`carbon` | `biodiversity`, optional filter for latest execution lookup) + +**When it's used:** Pre-flight check before monthly execution, and quick triage when deciding whether to sync, dry-run, execute, or avoid reruns. + ### `publish_subscriber_certificate_page` Generates a user-facing HTML certificate page for a subscriber's monthly fractional attribution record and returns: diff --git a/src/index.ts b/src/index.ts index 30f1968..75c38f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { } from "./tools/pool-accounting.js"; import { getMonthlyBatchExecutionHistoryTool, + getMonthlyReconciliationStatusTool, runMonthlyBatchRetirementTool, runMonthlyReconciliationTool, } from "./tools/monthly-batch-retirement.js"; @@ -124,11 +125,12 @@ const server = new McpServer( "9. run_monthly_batch_retirement — execute the monthly pooled credit retirement batch", "10. run_monthly_reconciliation — optional contribution sync + monthly batch in one operator workflow", "11. get_monthly_batch_execution_history — query stored monthly batch run history with filters", - "12. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", - "13. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", - "14. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", - "15. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", - "16. link_identity_session / recover_identity_session — identity linking and recovery flows", + "12. get_monthly_reconciliation_status — operator readiness/status view for a target month", + "13. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", + "14. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", + "15. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", + "16. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", + "17. link_identity_session / recover_identity_session — identity linking and recovery flows", "", ...(walletMode ? [ @@ -144,6 +146,7 @@ const server = new McpServer( "Monthly batch retirement uses pool accounting totals to execute one on-chain retirement per month.", "The run_monthly_reconciliation tool orchestrates contribution sync and monthly batch execution in one call.", "The get_monthly_batch_execution_history tool returns persisted batch run history for operator auditing and troubleshooting.", + "The get_monthly_reconciliation_status tool summarizes contribution totals, latest execution state, and readiness guidance for a month.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", "Certificate frontend tool publishes shareable subscriber certificate pages to a configurable URL/path.", "Dashboard frontend tool publishes shareable subscriber impact dashboard pages to a configurable URL/path.", @@ -697,6 +700,30 @@ server.tool( } ); +// Tool: Query monthly reconciliation readiness and latest execution state +server.tool( + "get_monthly_reconciliation_status", + "Returns operator-focused monthly reconciliation status including contribution totals, latest execution state, protocol fee/net budget preview, and readiness guidance.", + { + month: z + .string() + .describe("Target month in YYYY-MM format"), + credit_type: z + .enum(["carbon", "biodiversity"]) + .optional() + .describe("Optional credit type filter for latest execution lookup"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async ({ month, credit_type }) => { + return getMonthlyReconciliationStatusTool(month, credit_type); + } +); + // Tool: User-facing fractional impact dashboard server.tool( "get_subscriber_impact_dashboard", diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 211aef5..d4fd9a3 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -3,13 +3,18 @@ import type { BatchExecutionStatus, RunMonthlyBatchResult, } from "../services/batch-retirement/types.js"; +import { PoolAccountingService } from "../services/pool-accounting/service.js"; import { SubscriptionPoolSyncService, type SubscriptionPoolSyncResult, } from "../services/subscription/pool-sync.js"; +import { loadConfig } from "../config.js"; +import { calculateProtocolFee } from "../services/batch-retirement/fee.js"; const executor = new MonthlyBatchRetirementExecutor(); +const poolAccounting = new PoolAccountingService(); const poolSync = new SubscriptionPoolSyncService(); +const MONTH_REGEX = /^\d{4}-\d{2}$/; type SyncScope = "none" | "customer" | "all_customers"; @@ -329,6 +334,88 @@ export async function getMonthlyBatchExecutionHistoryTool( } } +export async function getMonthlyReconciliationStatusTool( + month: string, + creditType?: "carbon" | "biodiversity" +) { + try { + if (!MONTH_REGEX.test(month)) { + throw new Error("month must be in YYYY-MM format"); + } + + const monthlySummary = await poolAccounting.getMonthlySummary(month); + const config = loadConfig(); + const protocolFee = calculateProtocolFee({ + grossBudgetUsdCents: monthlySummary.totalUsdCents, + protocolFeeBps: config.protocolFeeBps, + paymentDenom: "USDC", + }); + const latestExecution = ( + await executor.getExecutionHistory({ + month, + creditType, + limit: 1, + newestFirst: true, + }) + )[0]; + + const hasContributions = monthlySummary.totalUsdCents > 0; + const alreadySucceeded = latestExecution?.status === "success"; + const readyForExecution = hasContributions && !alreadySucceeded; + + let recommendation = "Run `run_monthly_reconciliation` with `sync_scope=all_customers`."; + if (!hasContributions) { + recommendation = + "No contributions found. Sync invoices first (`run_monthly_reconciliation` with sync enabled), then re-check."; + } else if (alreadySucceeded) { + recommendation = + "A successful execution already exists for this month. Use `force=true` only if rerun is intentional."; + } else if (latestExecution?.status === "failed") { + recommendation = + "Latest execution failed. Run `run_monthly_reconciliation` with `dry_run=true` first, then execute with `dry_run=false`."; + } else if (latestExecution?.status === "dry_run") { + recommendation = + "Dry-run record exists. Execute with `dry_run=false` when ready."; + } + + const lines: string[] = [ + "## Monthly Reconciliation Status", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${month} |`, + `| Credit Type Filter | ${creditType || "all"} |`, + `| Contribution Count | ${monthlySummary.contributionCount} |`, + `| Unique Contributors | ${monthlySummary.uniqueContributors} |`, + `| Gross Pool Budget | ${formatUsd(monthlySummary.totalUsdCents)} |`, + `| Protocol Fee | ${formatUsd(protocolFee.protocolFeeUsdCents)} (${(protocolFee.protocolFeeBps / 100).toFixed(2)}%) |`, + `| Net Credit Budget | ${formatUsd(protocolFee.creditBudgetUsdCents)} |`, + `| Latest Execution Status | ${latestExecution?.status || "none"} |`, + `| Latest Execution At | ${latestExecution?.executedAt || "N/A"} |`, + `| Latest Execution Dry Run | ${latestExecution ? (latestExecution.dryRun ? "Yes" : "No") : "N/A"} |`, + `| Latest Tx Hash | ${latestExecution?.txHash ? `\`${latestExecution.txHash}\`` : "N/A"} |`, + `| Latest Retirement ID | ${latestExecution?.retirementId || "N/A"} |`, + `| Ready For Execution | ${readyForExecution ? "Yes" : "No"} |`, + "", + `Recommendation: ${recommendation}`, + ]; + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown reconciliation status error"; + return { + content: [ + { + type: "text" as const, + text: `Monthly reconciliation status query failed: ${message}`, + }, + ], + isError: true, + }; + } +} + export async function runMonthlyReconciliationTool( input: RunMonthlyReconciliationInput ) { diff --git a/tests/monthly-reconciliation-status.test.ts b/tests/monthly-reconciliation-status.test.ts new file mode 100644 index 0000000..5526a3a --- /dev/null +++ b/tests/monthly-reconciliation-status.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getMonthlySummary: vi.fn(), + getExecutionHistory: vi.fn(), + loadConfig: vi.fn(), +})); + +vi.mock("../src/services/batch-retirement/executor.js", () => ({ + MonthlyBatchRetirementExecutor: class { + getExecutionHistory(input: unknown) { + return mocks.getExecutionHistory(input); + } + + runMonthlyBatch() { + throw new Error("not implemented for this test"); + } + }, +})); + +vi.mock("../src/services/pool-accounting/service.js", () => ({ + PoolAccountingService: class { + getMonthlySummary(month: string) { + return mocks.getMonthlySummary(month); + } + }, +})); + +vi.mock("../src/services/subscription/pool-sync.js", () => ({ + SubscriptionPoolSyncService: class { + syncPaidInvoices() { + throw new Error("not implemented for this test"); + } + }, +})); + +vi.mock("../src/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +import { getMonthlyReconciliationStatusTool } from "../src/tools/monthly-batch-retirement.js"; + +function responseText(result: { content: Array<{ type: "text"; text: string }> }): string { + return result.content[0]?.text ?? ""; +} + +describe("getMonthlyReconciliationStatusTool", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({ protocolFeeBps: 1000 }); + mocks.getMonthlySummary.mockResolvedValue({ + month: "2026-03", + contributionCount: 0, + uniqueContributors: 0, + totalUsdCents: 0, + totalUsd: 0, + contributors: [], + }); + mocks.getExecutionHistory.mockResolvedValue([]); + }); + + it("reports no-contribution status with blocked readiness", async () => { + const result = await getMonthlyReconciliationStatusTool("2026-03"); + const text = responseText(result); + + expect(mocks.getExecutionHistory).toHaveBeenCalledWith({ + month: "2026-03", + creditType: undefined, + limit: 1, + newestFirst: true, + }); + expect(text).toContain("## Monthly Reconciliation Status"); + expect(text).toContain("| Gross Pool Budget | $0.00 |"); + expect(text).toContain("| Protocol Fee | $0.00 (10.00%) |"); + expect(text).toContain("| Latest Execution Status | none |"); + expect(text).toContain("| Ready For Execution | No |"); + expect(text).toContain("Recommendation: No contributions found"); + }); + + it("reports ready state with recovery guidance after failed execution", async () => { + mocks.getMonthlySummary.mockResolvedValueOnce({ + month: "2026-03", + contributionCount: 2, + uniqueContributors: 2, + totalUsdCents: 500, + totalUsd: 5, + contributors: [], + }); + mocks.getExecutionHistory.mockResolvedValueOnce([ + { + id: "batch_fail", + month: "2026-03", + creditType: "carbon", + dryRun: false, + status: "failed", + reason: "RPC error", + budgetUsdCents: 500, + spentMicro: "0", + spentDenom: "USDC", + retiredQuantity: "0.000000", + error: "rpc unavailable", + executedAt: "2026-03-31T12:00:00.000Z", + }, + ]); + + const result = await getMonthlyReconciliationStatusTool("2026-03", "carbon"); + const text = responseText(result); + + expect(text).toContain("| Credit Type Filter | carbon |"); + expect(text).toContain("| Gross Pool Budget | $5.00 |"); + expect(text).toContain("| Net Credit Budget | $4.50 |"); + expect(text).toContain("| Latest Execution Status | failed |"); + expect(text).toContain("| Ready For Execution | Yes |"); + expect(text).toContain("dry_run=true"); + }); + + it("reports already-executed state when latest run succeeded", async () => { + mocks.getMonthlySummary.mockResolvedValueOnce({ + month: "2026-03", + contributionCount: 3, + uniqueContributors: 1, + totalUsdCents: 300, + totalUsd: 3, + contributors: [], + }); + mocks.getExecutionHistory.mockResolvedValueOnce([ + { + id: "batch_success", + month: "2026-03", + creditType: "carbon", + dryRun: false, + status: "success", + reason: "Done", + budgetUsdCents: 300, + spentMicro: "2500000", + spentDenom: "USDC", + retiredQuantity: "1.250000", + txHash: "TX123", + retirementId: "WyRet123", + executedAt: "2026-03-31T12:00:00.000Z", + }, + ]); + + const result = await getMonthlyReconciliationStatusTool("2026-03", "carbon"); + const text = responseText(result); + + expect(text).toContain("| Latest Execution Status | success |"); + expect(text).toContain("| Latest Tx Hash | `TX123` |"); + expect(text).toContain("| Latest Retirement ID | WyRet123 |"); + expect(text).toContain("| Ready For Execution | No |"); + expect(text).toContain("A successful execution already exists"); + }); + + it("returns error for invalid month format", async () => { + const result = await getMonthlyReconciliationStatusTool("03-2026"); + expect(result.isError).toBe(true); + expect(responseText(result)).toContain( + "Monthly reconciliation status query failed: month must be in YYYY-MM format" + ); + }); +}); From fe5b612dd4ca386117b8cc659e03a51c9229a640 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:53:05 -0800 Subject: [PATCH 19/31] feat: harden reconciliation readiness and sync truncation reporting --- README.md | 3 + src/services/subscription/pool-sync.ts | 37 +++++-- src/services/subscription/stripe.ts | 33 +++++- src/tools/monthly-batch-retirement.ts | 48 +++++++-- src/tools/subscriptions.ts | 19 ++++ tests/monthly-reconciliation-status.test.ts | 114 +++++++++++++++++--- tests/subscription-pool-sync.test.ts | 63 ++++++++++- tests/subscription-service.test.ts | 61 ++++++++++- 8 files changed, 333 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 7c845ec..678f9df 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,8 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr - `email` / `customer_id` / `user_id` for customer-scoped sync, - `invoice_limit` and `invoice_max_pages` for Stripe pagination control. +All-customer sync output now reports whether Stripe pagination was truncated by the `invoice_max_pages` cap so operators can rerun with a higher limit when needed. + **When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. ### `get_monthly_batch_execution_history` @@ -279,6 +281,7 @@ Returns an operator readiness snapshot for a month by combining: - pool contribution totals, - protocol fee and net budget preview, - latest batch execution state (`none`/`dry_run`/`success`/`failed`), +- latest successful execution lookup (not just latest attempt), - actionable recommendation for the next step. **Parameters:** diff --git a/src/services/subscription/pool-sync.ts b/src/services/subscription/pool-sync.ts index c73bd7e..22d2b3f 100644 --- a/src/services/subscription/pool-sync.ts +++ b/src/services/subscription/pool-sync.ts @@ -1,4 +1,5 @@ import { PoolAccountingService } from "../pool-accounting/service.js"; +import type { PaidInvoice } from "./stripe.js"; import { StripeSubscriptionService } from "./stripe.js"; import { getTierIdForStripePrice } from "./tiers.js"; import type { SubscriptionIdentityInput } from "./types.js"; @@ -37,6 +38,10 @@ export interface SubscriptionPoolSyncResult { customerId?: string; email?: string; month?: string; + truncated?: boolean; + hasMore?: boolean; + pageCount?: number; + maxPages?: number; fetchedInvoiceCount: number; processedInvoiceCount: number; syncedCount: number; @@ -72,14 +77,30 @@ export class SubscriptionPoolSyncService { throw new Error("month must be in YYYY-MM format"); } - const fetched = allCustomers - ? await this.subscriptions.listPaidInvoicesAcrossCustomers({ + let fetched: PaidInvoice[] = []; + let truncated: boolean | undefined; + let hasMore: boolean | undefined; + let pageCount: number | undefined; + let maxPages: number | undefined; + + if (allCustomers) { + const acrossCustomers = await this.subscriptions.listPaidInvoicesAcrossCustomers( + { limit: input.limit, maxPages: input.maxPages, - }) - : await this.subscriptions.listPaidInvoices(identity, { - limit: input.limit, - }); + } + ); + fetched = acrossCustomers.invoices; + truncated = acrossCustomers.truncated; + hasMore = acrossCustomers.hasMore; + pageCount = acrossCustomers.pageCount; + maxPages = acrossCustomers.maxPages; + } else { + fetched = await this.subscriptions.listPaidInvoices(identity, { + limit: input.limit, + }); + } + const invoices = month ? fetched.filter((invoice) => invoice.paidAt.slice(0, 7) === month) : fetched; @@ -130,6 +151,10 @@ export class SubscriptionPoolSyncService { invoices[0]?.customerId || fetched[0]?.customerId || identity.customerId, email: identity.email || invoices[0]?.customerEmail || fetched[0]?.customerEmail, month, + truncated, + hasMore, + pageCount, + maxPages, fetchedInvoiceCount: fetched.length, processedInvoiceCount: invoices.length, syncedCount, diff --git a/src/services/subscription/stripe.ts b/src/services/subscription/stripe.ts index a01fed4..9b8b594 100644 --- a/src/services/subscription/stripe.ts +++ b/src/services/subscription/stripe.ts @@ -83,6 +83,14 @@ export interface PaidInvoice { paidAt: string; } +export interface PaidInvoiceAcrossCustomersResult { + invoices: PaidInvoice[]; + truncated: boolean; + hasMore: boolean; + pageCount: number; + maxPages: number; +} + function trimOrUndefined(value?: string): string | undefined { if (!value) return undefined; const trimmed = value.trim(); @@ -509,7 +517,7 @@ export class StripeSubscriptionService { async listPaidInvoicesAcrossCustomers(options?: { limit?: number; maxPages?: number; - }): Promise { + }): Promise { const limit = this.clampInvoiceFetchLimit(options?.limit); const maxPages = typeof options?.maxPages === "number" && Number.isInteger(options.maxPages) @@ -518,8 +526,12 @@ export class StripeSubscriptionService { const results: PaidInvoice[] = []; let startingAfter: string | undefined; + let pageCount = 0; + let hasMore = false; + let reachedEnd = false; for (let page = 0; page < maxPages; page += 1) { + pageCount += 1; const invoices = await this.stripeRequest>( "GET", "/invoices", @@ -537,14 +549,25 @@ export class StripeSubscriptionService { ); const lastId = invoices.data[invoices.data.length - 1]?.id; - if (!invoices.has_more || !lastId) { + hasMore = Boolean(invoices.has_more); + if (!hasMore) { + reachedEnd = true; + break; + } + if (!lastId) { break; } startingAfter = lastId; } - return results - .filter((item): item is PaidInvoice => Boolean(item)) - .sort((a, b) => a.paidAt.localeCompare(b.paidAt)); + return { + invoices: results + .filter((item): item is PaidInvoice => Boolean(item)) + .sort((a, b) => a.paidAt.localeCompare(b.paidAt)), + truncated: !reachedEnd, + hasMore, + pageCount, + maxPages, + }; } } diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index d4fd9a3..15d2f3f 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -211,6 +211,14 @@ function renderSyncSummary( `| Skipped (month filter) | ${result.skippedCount} |`, ]; + if (result.scope === "all_customers") { + lines.push( + `| Pages Fetched | ${typeof result.pageCount === "number" ? result.pageCount : "N/A"} |`, + `| Max Pages | ${typeof result.maxPages === "number" ? result.maxPages : "N/A"} |`, + `| Fetch Truncated | ${result.truncated ? "Yes" : "No"} |` + ); + } + if (result.records.length > 0) { lines.push( "", @@ -227,6 +235,13 @@ function renderSyncSummary( } } + if (result.scope === "all_customers" && result.truncated) { + lines.push( + "", + "Warning: invoice fetch stopped before Stripe pagination was exhausted. Increase `invoice_max_pages` and rerun to avoid partial reconciliation." + ); + } + return lines.join("\n"); } @@ -350,17 +365,28 @@ export async function getMonthlyReconciliationStatusTool( protocolFeeBps: config.protocolFeeBps, paymentDenom: "USDC", }); - const latestExecution = ( - await executor.getExecutionHistory({ - month, - creditType, - limit: 1, - newestFirst: true, - }) - )[0]; + const [latestExecution, latestSuccessExecution] = await Promise.all([ + executor + .getExecutionHistory({ + month, + creditType, + limit: 1, + newestFirst: true, + }) + .then((records) => records[0]), + executor + .getExecutionHistory({ + month, + creditType, + status: "success", + limit: 1, + newestFirst: true, + }) + .then((records) => records[0]), + ]); const hasContributions = monthlySummary.totalUsdCents > 0; - const alreadySucceeded = latestExecution?.status === "success"; + const alreadySucceeded = Boolean(latestSuccessExecution); const readyForExecution = hasContributions && !alreadySucceeded; let recommendation = "Run `run_monthly_reconciliation` with `sync_scope=all_customers`."; @@ -369,7 +395,7 @@ export async function getMonthlyReconciliationStatusTool( "No contributions found. Sync invoices first (`run_monthly_reconciliation` with sync enabled), then re-check."; } else if (alreadySucceeded) { recommendation = - "A successful execution already exists for this month. Use `force=true` only if rerun is intentional."; + `A successful execution already exists for this month${latestSuccessExecution?.executedAt ? ` (latest success: ${latestSuccessExecution.executedAt})` : ""}. Use \`force=true\` only if rerun is intentional.`; } else if (latestExecution?.status === "failed") { recommendation = "Latest execution failed. Run `run_monthly_reconciliation` with `dry_run=true` first, then execute with `dry_run=false`."; @@ -392,6 +418,8 @@ export async function getMonthlyReconciliationStatusTool( `| Net Credit Budget | ${formatUsd(protocolFee.creditBudgetUsdCents)} |`, `| Latest Execution Status | ${latestExecution?.status || "none"} |`, `| Latest Execution At | ${latestExecution?.executedAt || "N/A"} |`, + `| Any Successful Execution | ${alreadySucceeded ? "Yes" : "No"} |`, + `| Latest Successful Execution At | ${latestSuccessExecution?.executedAt || "N/A"} |`, `| Latest Execution Dry Run | ${latestExecution ? (latestExecution.dryRun ? "Yes" : "No") : "N/A"} |`, `| Latest Tx Hash | ${latestExecution?.txHash ? `\`${latestExecution.txHash}\`` : "N/A"} |`, `| Latest Retirement ID | ${latestExecution?.retirementId || "N/A"} |`, diff --git a/src/tools/subscriptions.ts b/src/tools/subscriptions.ts index 9a56fa7..2d019b0 100644 --- a/src/tools/subscriptions.ts +++ b/src/tools/subscriptions.ts @@ -159,6 +159,10 @@ function renderPoolSyncResult( customerId?: string; email?: string; month?: string; + truncated?: boolean; + hasMore?: boolean; + pageCount?: number; + maxPages?: number; fetchedInvoiceCount: number; processedInvoiceCount: number; syncedCount: number; @@ -189,6 +193,14 @@ function renderPoolSyncResult( `| Skipped (month filter) | ${result.skippedCount} |`, ]; + if (result.scope === "all_customers") { + lines.push( + `| Pages Fetched | ${typeof result.pageCount === "number" ? result.pageCount : "N/A"} |`, + `| Max Pages | ${typeof result.maxPages === "number" ? result.maxPages : "N/A"} |`, + `| Fetch Truncated | ${result.truncated ? "Yes" : "No"} |` + ); + } + if (result.records.length > 0) { lines.push( "", @@ -205,6 +217,13 @@ function renderPoolSyncResult( lines.push("", "No paid invoices matched the provided filter."); } + if (result.scope === "all_customers" && result.truncated) { + lines.push( + "", + "Warning: invoice fetch stopped before Stripe pagination was exhausted. Increase `max_pages` and rerun to complete reconciliation." + ); + } + return lines.join("\n"); } diff --git a/tests/monthly-reconciliation-status.test.ts b/tests/monthly-reconciliation-status.test.ts index 5526a3a..4fe8d0e 100644 --- a/tests/monthly-reconciliation-status.test.ts +++ b/tests/monthly-reconciliation-status.test.ts @@ -69,10 +69,18 @@ describe("getMonthlyReconciliationStatusTool", () => { limit: 1, newestFirst: true, }); + expect(mocks.getExecutionHistory).toHaveBeenNthCalledWith(2, { + month: "2026-03", + creditType: undefined, + status: "success", + limit: 1, + newestFirst: true, + }); expect(text).toContain("## Monthly Reconciliation Status"); expect(text).toContain("| Gross Pool Budget | $0.00 |"); expect(text).toContain("| Protocol Fee | $0.00 (10.00%) |"); expect(text).toContain("| Latest Execution Status | none |"); + expect(text).toContain("| Any Successful Execution | No |"); expect(text).toContain("| Ready For Execution | No |"); expect(text).toContain("Recommendation: No contributions found"); }); @@ -123,34 +131,106 @@ describe("getMonthlyReconciliationStatusTool", () => { totalUsd: 3, contributors: [], }); - mocks.getExecutionHistory.mockResolvedValueOnce([ - { - id: "batch_success", - month: "2026-03", - creditType: "carbon", - dryRun: false, - status: "success", - reason: "Done", - budgetUsdCents: 300, - spentMicro: "2500000", - spentDenom: "USDC", - retiredQuantity: "1.250000", - txHash: "TX123", - retirementId: "WyRet123", - executedAt: "2026-03-31T12:00:00.000Z", - }, - ]); + mocks.getExecutionHistory + .mockResolvedValueOnce([ + { + id: "batch_success", + month: "2026-03", + creditType: "carbon", + dryRun: false, + status: "success", + reason: "Done", + budgetUsdCents: 300, + spentMicro: "2500000", + spentDenom: "USDC", + retiredQuantity: "1.250000", + txHash: "TX123", + retirementId: "WyRet123", + executedAt: "2026-03-31T12:00:00.000Z", + }, + ]) + .mockResolvedValueOnce([ + { + id: "batch_success", + month: "2026-03", + creditType: "carbon", + dryRun: false, + status: "success", + reason: "Done", + budgetUsdCents: 300, + spentMicro: "2500000", + spentDenom: "USDC", + retiredQuantity: "1.250000", + txHash: "TX123", + retirementId: "WyRet123", + executedAt: "2026-03-31T12:00:00.000Z", + }, + ]); const result = await getMonthlyReconciliationStatusTool("2026-03", "carbon"); const text = responseText(result); expect(text).toContain("| Latest Execution Status | success |"); + expect(text).toContain("| Any Successful Execution | Yes |"); expect(text).toContain("| Latest Tx Hash | `TX123` |"); expect(text).toContain("| Latest Retirement ID | WyRet123 |"); expect(text).toContain("| Ready For Execution | No |"); expect(text).toContain("A successful execution already exists"); }); + it("blocks readiness when a prior success exists but latest run is dry-run", async () => { + mocks.getMonthlySummary.mockResolvedValueOnce({ + month: "2026-03", + contributionCount: 4, + uniqueContributors: 3, + totalUsdCents: 1200, + totalUsd: 12, + contributors: [], + }); + mocks.getExecutionHistory + .mockResolvedValueOnce([ + { + id: "batch_dryrun_new", + month: "2026-03", + creditType: "carbon", + dryRun: true, + status: "dry_run", + reason: "verification", + budgetUsdCents: 1200, + spentMicro: "0", + spentDenom: "USDC", + retiredQuantity: "0.000000", + executedAt: "2026-03-31T13:00:00.000Z", + }, + ]) + .mockResolvedValueOnce([ + { + id: "batch_success_old", + month: "2026-03", + creditType: "carbon", + dryRun: false, + status: "success", + reason: "done", + budgetUsdCents: 1200, + spentMicro: "10000000", + spentDenom: "USDC", + retiredQuantity: "5.000000", + txHash: "TX_OLD", + retirementId: "RET_OLD", + executedAt: "2026-03-30T12:00:00.000Z", + }, + ]); + + const result = await getMonthlyReconciliationStatusTool("2026-03", "carbon"); + const text = responseText(result); + + expect(text).toContain("| Latest Execution Status | dry_run |"); + expect(text).toContain("| Any Successful Execution | Yes |"); + expect(text).toContain("| Latest Successful Execution At | 2026-03-30T12:00:00.000Z |"); + expect(text).toContain("| Ready For Execution | No |"); + expect(text).toContain("A successful execution already exists"); + }); + it("returns error for invalid month format", async () => { const result = await getMonthlyReconciliationStatusTool("03-2026"); expect(result.isError).toBe(true); diff --git a/tests/subscription-pool-sync.test.ts b/tests/subscription-pool-sync.test.ts index e6c5d82..3dfce53 100644 --- a/tests/subscription-pool-sync.test.ts +++ b/tests/subscription-pool-sync.test.ts @@ -22,7 +22,13 @@ class InMemoryPoolAccountingStore implements PoolAccountingStore { class StubSubscriptionService { constructor( private readonly customerInvoices: PaidInvoice[], - private readonly allInvoices: PaidInvoice[] = [] + private readonly allInvoices: PaidInvoice[] = [], + private readonly allInvoiceMeta?: { + truncated?: boolean; + hasMore?: boolean; + pageCount?: number; + maxPages?: number; + } ) {} async listPaidInvoices( @@ -34,8 +40,20 @@ class StubSubscriptionService { async listPaidInvoicesAcrossCustomers( _options?: unknown - ): Promise { - return this.allInvoices; + ): Promise<{ + invoices: PaidInvoice[]; + truncated: boolean; + hasMore: boolean; + pageCount: number; + maxPages: number; + }> { + return { + invoices: this.allInvoices, + truncated: Boolean(this.allInvoiceMeta?.truncated), + hasMore: Boolean(this.allInvoiceMeta?.hasMore), + pageCount: this.allInvoiceMeta?.pageCount ?? 1, + maxPages: this.allInvoiceMeta?.maxPages ?? 10, + }; } } @@ -159,9 +177,48 @@ describe("SubscriptionPoolSyncService", () => { expect(result.processedInvoiceCount).toBe(2); expect(result.syncedCount).toBe(2); expect(result.duplicateCount).toBe(0); + expect(result.truncated).toBe(false); + expect(result.pageCount).toBe(1); + expect(result.maxPages).toBe(10); const march = await poolAccounting.getMonthlySummary("2026-03"); expect(march.contributionCount).toBe(2); expect(march.totalUsdCents).toBe(400); }); + + it("reports truncation metadata for all-customer sync", async () => { + const invoices: PaidInvoice[] = [ + { + invoiceId: "in_truncated", + customerId: "cus_a", + customerEmail: "a@example.com", + subscriptionId: "sub_a", + priceId: "price_starter", + amountPaidCents: 100, + paidAt: "2026-03-01T00:00:00.000Z", + }, + ]; + + const sync = new SubscriptionPoolSyncService( + new StubSubscriptionService([], invoices, { + truncated: true, + hasMore: true, + pageCount: 3, + maxPages: 3, + }), + poolAccounting + ); + + const result = await sync.syncPaidInvoices({ + allCustomers: true, + month: "2026-03", + maxPages: 3, + }); + + expect(result.scope).toBe("all_customers"); + expect(result.truncated).toBe(true); + expect(result.hasMore).toBe(true); + expect(result.pageCount).toBe(3); + expect(result.maxPages).toBe(3); + }); }); diff --git a/tests/subscription-service.test.ts b/tests/subscription-service.test.ts index cc862e3..9db03e2 100644 --- a/tests/subscription-service.test.ts +++ b/tests/subscription-service.test.ts @@ -274,18 +274,22 @@ describe("StripeSubscriptionService", () => { vi.stubGlobal("fetch", fetchMock); const service = new StripeSubscriptionService(); - const invoices = await service.listPaidInvoicesAcrossCustomers({ + const result = await service.listPaidInvoicesAcrossCustomers({ limit: 2, maxPages: 5, }); - expect(invoices).toHaveLength(3); - expect(invoices[0]).toMatchObject({ + expect(result.invoices).toHaveLength(3); + expect(result.truncated).toBe(false); + expect(result.hasMore).toBe(false); + expect(result.pageCount).toBe(2); + expect(result.maxPages).toBe(5); + expect(result.invoices[0]).toMatchObject({ invoiceId: "in_1", customerId: "cus_321", amountPaidCents: 300, }); - expect(invoices[2]).toMatchObject({ + expect(result.invoices[2]).toMatchObject({ invoiceId: "in_3", customerId: "cus_999", amountPaidCents: 500, @@ -298,4 +302,53 @@ describe("StripeSubscriptionService", () => { "/invoices?status=paid&limit=2&starting_after=in_2" ); }); + + it("marks all-customer invoice listing as truncated when max pages is reached", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse(200, { + has_more: true, + data: [ + { + id: "in_2", + customer: "cus_123", + amount_paid: 100, + currency: "usd", + status_transitions: { paid_at: 1_707_000_100 }, + lines: { data: [{ price: { id: "price_starter" } }] }, + }, + ], + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + has_more: true, + data: [ + { + id: "in_1", + customer: "cus_321", + amount_paid: 200, + currency: "usd", + status_transitions: { paid_at: 1_707_000_000 }, + lines: { data: [{ price: { id: "price_growth" } }] }, + }, + ], + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new StripeSubscriptionService(); + const result = await service.listPaidInvoicesAcrossCustomers({ + limit: 1, + maxPages: 2, + }); + + expect(result.invoices).toHaveLength(2); + expect(result.truncated).toBe(true); + expect(result.hasMore).toBe(true); + expect(result.pageCount).toBe(2); + expect(result.maxPages).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); From 86c1401f6705ba7ca42d0b8346a83c2e46ce38a6 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:57:14 -0800 Subject: [PATCH 20/31] feat: block batch execution on truncated all-customer sync --- README.md | 3 +- src/index.ts | 9 ++++ src/tools/monthly-batch-retirement.ts | 29 ++++++++++++ tests/monthly-reconciliation-tool.test.ts | 54 +++++++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 678f9df..b7ee9e8 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,8 @@ Runs an operator workflow in one call: Supports all `run_monthly_batch_retirement` execution parameters plus sync controls: - `sync_scope`: `none` | `customer` | `all_customers` (default), - `email` / `customer_id` / `user_id` for customer-scoped sync, -- `invoice_limit` and `invoice_max_pages` for Stripe pagination control. +- `invoice_limit` and `invoice_max_pages` for Stripe pagination control, +- `allow_partial_sync` (default `false`) to explicitly allow execution after truncated all-customer sync. All-customer sync output now reports whether Stripe pagination was truncated by the `invoice_max_pages` cap so operators can rerun with a higher limit when needed. diff --git a/src/index.ts b/src/index.ts index 75c38f9..f435fda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -581,6 +581,13 @@ server.tool( .optional() .default(false) .describe("If true, allows rerunning a month even if a prior success exists"), + allow_partial_sync: z + .boolean() + .optional() + .default(false) + .describe( + "If true, allows batch execution to continue when all-customer sync is truncated by invoice_max_pages" + ), reason: z .string() .optional() @@ -629,6 +636,7 @@ server.tool( max_budget_usd, dry_run, force, + allow_partial_sync, reason, jurisdiction, sync_scope, @@ -644,6 +652,7 @@ server.tool( maxBudgetUsd: max_budget_usd, dryRun: dry_run, force, + allowPartialSync: allow_partial_sync, reason, jurisdiction, syncScope: sync_scope, diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 15d2f3f..7ba8ffb 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -24,6 +24,7 @@ export interface RunMonthlyReconciliationInput { maxBudgetUsd?: number; dryRun?: boolean; force?: boolean; + allowPartialSync?: boolean; reason?: string; jurisdiction?: string; syncScope?: SyncScope; @@ -468,6 +469,34 @@ export async function runMonthlyReconciliationTool( }); } + if ( + syncScope === "all_customers" && + syncResult?.truncated && + !input.allowPartialSync + ) { + const lines: string[] = [ + "## Monthly Reconciliation", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${input.month} |`, + `| Sync Scope | ${syncScope} |`, + "| Batch Status | blocked_partial_sync |", + "", + "### Contribution Sync", + "", + renderSyncSummary(syncScope, syncResult), + "", + "Batch execution was skipped because all-customer invoice sync was truncated by `invoice_max_pages` and may be incomplete.", + "Increase `invoice_max_pages` and rerun, or set `allow_partial_sync=true` to override (not recommended).", + ]; + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + isError: true, + }; + } + const batchResult = await executor.runMonthlyBatch({ month: input.month, creditType: input.creditType, diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index f007609..282cb4f 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -167,6 +167,60 @@ describe("runMonthlyReconciliationTool", () => { expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); }); + it("blocks batch execution when all-customer sync is truncated", async () => { + mocks.syncPaidInvoices.mockResolvedValueOnce({ + scope: "all_customers", + month: "2026-03", + truncated: true, + hasMore: true, + pageCount: 3, + maxPages: 3, + fetchedInvoiceCount: 50, + processedInvoiceCount: 50, + syncedCount: 50, + duplicateCount: 0, + skippedCount: 0, + records: [], + }); + + const result = await runMonthlyReconciliationTool({ month: "2026-03" }); + const text = responseText(result); + + expect(result.isError).toBe(true); + expect(text).toContain("| Batch Status | blocked_partial_sync |"); + expect(text).toContain("| Fetch Truncated | Yes |"); + expect(text).toContain("allow_partial_sync=true"); + expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + }); + + it("allows continuing with truncated all-customer sync when override is set", async () => { + mocks.syncPaidInvoices.mockResolvedValueOnce({ + scope: "all_customers", + month: "2026-03", + truncated: true, + hasMore: true, + pageCount: 3, + maxPages: 3, + fetchedInvoiceCount: 50, + processedInvoiceCount: 50, + syncedCount: 50, + duplicateCount: 0, + skippedCount: 0, + records: [], + }); + + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + allowPartialSync: true, + }); + const text = responseText(result); + + expect(result.isError).toBeUndefined(); + expect(text).toContain("| Batch Status | dry_run |"); + expect(text).toContain("| Fetch Truncated | Yes |"); + expect(mocks.runMonthlyBatch).toHaveBeenCalledTimes(1); + }); + it("returns monthly batch execution history table", async () => { const result = await getMonthlyBatchExecutionHistoryTool( "2026-03", From 0c908caae01278f330d3580884520425d2cbfb02 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:01:23 -0800 Subject: [PATCH 21/31] feat: require dry-run preflight before live reconciliation --- README.md | 3 +- src/index.ts | 9 +++ src/tools/monthly-batch-retirement.ts | 36 +++++++++ tests/monthly-reconciliation-tool.test.ts | 91 ++++++++++++++++++++++- 4 files changed, 137 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b7ee9e8..036f80f 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,8 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr - `sync_scope`: `none` | `customer` | `all_customers` (default), - `email` / `customer_id` / `user_id` for customer-scoped sync, - `invoice_limit` and `invoice_max_pages` for Stripe pagination control, -- `allow_partial_sync` (default `false`) to explicitly allow execution after truncated all-customer sync. +- `allow_partial_sync` (default `false`) to explicitly allow execution after truncated all-customer sync, +- `allow_execute_without_dry_run` (default `false`) to allow live execution without a latest prior dry-run for the same month/credit type. All-customer sync output now reports whether Stripe pagination was truncated by the `invoice_max_pages` cap so operators can rerun with a higher limit when needed. diff --git a/src/index.ts b/src/index.ts index f435fda..ce6caab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -588,6 +588,13 @@ server.tool( .describe( "If true, allows batch execution to continue when all-customer sync is truncated by invoice_max_pages" ), + allow_execute_without_dry_run: z + .boolean() + .optional() + .default(false) + .describe( + "If true, allows dry_run=false execution even when no latest dry-run record exists for the target month and credit type" + ), reason: z .string() .optional() @@ -637,6 +644,7 @@ server.tool( dry_run, force, allow_partial_sync, + allow_execute_without_dry_run, reason, jurisdiction, sync_scope, @@ -653,6 +661,7 @@ server.tool( dryRun: dry_run, force, allowPartialSync: allow_partial_sync, + allowExecuteWithoutDryRun: allow_execute_without_dry_run, reason, jurisdiction, syncScope: sync_scope, diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 7ba8ffb..c2e258d 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -25,6 +25,7 @@ export interface RunMonthlyReconciliationInput { dryRun?: boolean; force?: boolean; allowPartialSync?: boolean; + allowExecuteWithoutDryRun?: boolean; reason?: string; jurisdiction?: string; syncScope?: SyncScope; @@ -497,6 +498,41 @@ export async function runMonthlyReconciliationTool( }; } + if (input.dryRun === false && !input.allowExecuteWithoutDryRun) { + const latestExecution = ( + await executor.getExecutionHistory({ + month: input.month, + creditType: input.creditType, + limit: 1, + newestFirst: true, + }) + )[0]; + + if (latestExecution?.status !== "dry_run") { + const lines: string[] = [ + "## Monthly Reconciliation", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${input.month} |`, + `| Sync Scope | ${syncScope} |`, + "| Batch Status | blocked_preflight |", + "", + "### Contribution Sync", + "", + renderSyncSummary(syncScope, syncResult), + "", + `Live execution was blocked because the latest execution state is \`${latestExecution?.status || "none"}\`, not \`dry_run\`.`, + "Run with `dry_run=true` first, then re-run with `dry_run=false`, or set `allow_execute_without_dry_run=true` to override.", + ]; + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + isError: true, + }; + } + } + const batchResult = await executor.runMonthlyBatch({ month: input.month, creditType: input.creditType, diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index 282cb4f..521edfa 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -37,7 +37,7 @@ function responseText(result: { content: Array<{ type: "text"; text: string }> } describe("runMonthlyReconciliationTool", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); mocks.syncPaidInvoices.mockResolvedValue({ scope: "all_customers", @@ -221,6 +221,95 @@ describe("runMonthlyReconciliationTool", () => { expect(mocks.runMonthlyBatch).toHaveBeenCalledTimes(1); }); + it("blocks live execution without a latest dry-run record by default", async () => { + mocks.getExecutionHistory.mockResolvedValueOnce([ + { + id: "batch_success", + month: "2026-03", + creditType: undefined, + dryRun: false, + status: "success", + reason: "success", + budgetUsdCents: 300, + spentMicro: "2500000", + spentDenom: "USDC", + retiredQuantity: "1.250000", + executedAt: "2026-03-31T12:00:00.000Z", + }, + ]); + + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + dryRun: false, + }); + const text = responseText(result); + + expect(mocks.getExecutionHistory).toHaveBeenCalledWith({ + month: "2026-03", + creditType: undefined, + limit: 1, + newestFirst: true, + }); + expect(result.isError).toBe(true); + expect(text).toContain("| Batch Status | blocked_preflight |"); + expect(text).toContain("latest execution state is `success`"); + expect(text).toContain("allow_execute_without_dry_run=true"); + expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + }); + + it("allows live execution when latest record is dry-run", async () => { + mocks.getExecutionHistory.mockResolvedValueOnce([ + { + id: "batch_dry", + month: "2026-03", + creditType: undefined, + dryRun: true, + status: "dry_run", + reason: "plan", + budgetUsdCents: 300, + spentMicro: "0", + spentDenom: "USDC", + retiredQuantity: "0.000000", + executedAt: "2026-03-31T13:00:00.000Z", + }, + ]); + + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + dryRun: false, + }); + const text = responseText(result); + + expect(result.isError).toBeUndefined(); + expect(text).toContain("| Batch Status | dry_run |"); + expect(mocks.runMonthlyBatch).toHaveBeenCalledWith( + expect.objectContaining({ + month: "2026-03", + dryRun: false, + }) + ); + }); + + it("allows live execution without latest dry-run when override is enabled", async () => { + mocks.getExecutionHistory.mockResolvedValueOnce([]); + + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + dryRun: false, + allowExecuteWithoutDryRun: true, + }); + const text = responseText(result); + + expect(result.isError).toBeUndefined(); + expect(text).toContain("| Batch Status | dry_run |"); + expect(mocks.runMonthlyBatch).toHaveBeenCalledWith( + expect.objectContaining({ + month: "2026-03", + dryRun: false, + }) + ); + }); + it("returns monthly batch execution history table", async () => { const result = await getMonthlyBatchExecutionHistoryTool( "2026-03", From fe1d729adfec4a8789b0bcf9bc15b29907abc5fe Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:05:51 -0800 Subject: [PATCH 22/31] feat: block stale dry-run live reconciliation execution --- README.md | 1 + src/services/pool-accounting/service.ts | 8 ++++ src/services/pool-accounting/types.ts | 1 + src/tools/monthly-batch-retirement.ts | 46 ++++++++++++++---- tests/monthly-reconciliation-tool.test.ts | 57 +++++++++++++++++++++++ 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 036f80f..4e576f4 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,7 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr - `allow_execute_without_dry_run` (default `false`) to allow live execution without a latest prior dry-run for the same month/credit type. All-customer sync output now reports whether Stripe pagination was truncated by the `invoice_max_pages` cap so operators can rerun with a higher limit when needed. +Live execution preflight also requires the latest record for that month/credit type to be a fresh `dry_run` (not older than the latest contribution), unless explicitly overridden. **When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. diff --git a/src/services/pool-accounting/service.ts b/src/services/pool-accounting/service.ts index 9b639cd..0dc05bf 100644 --- a/src/services/pool-accounting/service.ts +++ b/src/services/pool-accounting/service.ts @@ -149,8 +149,15 @@ function summarizeMonth( >(); let totalUsdCents = 0; + let lastContributionAt: string | undefined; for (const record of filtered) { totalUsdCents += record.amountUsdCents; + if ( + !lastContributionAt || + record.contributedAt.localeCompare(lastContributionAt) > 0 + ) { + lastContributionAt = record.contributedAt; + } const existing = contributorMap.get(record.userId) || { userId: record.userId, email: record.email, @@ -180,6 +187,7 @@ function summarizeMonth( uniqueContributors: contributors.length, totalUsdCents, totalUsd: toUsd(totalUsdCents), + lastContributionAt, contributors, }; } diff --git a/src/services/pool-accounting/types.ts b/src/services/pool-accounting/types.ts index bd0b25a..79e51c9 100644 --- a/src/services/pool-accounting/types.ts +++ b/src/services/pool-accounting/types.ts @@ -67,6 +67,7 @@ export interface MonthlyPoolSummary { uniqueContributors: number; totalUsdCents: number; totalUsd: number; + lastContributionAt?: string; contributors: MonthlyContributorAggregate[]; } diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index c2e258d..e0f2d4a 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -499,14 +499,17 @@ export async function runMonthlyReconciliationTool( } if (input.dryRun === false && !input.allowExecuteWithoutDryRun) { - const latestExecution = ( - await executor.getExecutionHistory({ - month: input.month, - creditType: input.creditType, - limit: 1, - newestFirst: true, - }) - )[0]; + const [latestExecution, monthSummary] = await Promise.all([ + executor + .getExecutionHistory({ + month: input.month, + creditType: input.creditType, + limit: 1, + newestFirst: true, + }) + .then((records) => records[0]), + poolAccounting.getMonthlySummary(input.month), + ]); if (latestExecution?.status !== "dry_run") { const lines: string[] = [ @@ -531,6 +534,33 @@ export async function runMonthlyReconciliationTool( isError: true, }; } + + if ( + monthSummary.lastContributionAt && + latestExecution.executedAt.localeCompare(monthSummary.lastContributionAt) < 0 + ) { + const lines: string[] = [ + "## Monthly Reconciliation", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${input.month} |`, + `| Sync Scope | ${syncScope} |`, + "| Batch Status | blocked_preflight_stale_dry_run |", + "", + "### Contribution Sync", + "", + renderSyncSummary(syncScope, syncResult), + "", + `Live execution was blocked because the latest \`dry_run\` (${latestExecution.executedAt}) is older than the latest contribution (${monthSummary.lastContributionAt}).`, + "Run a fresh `dry_run=true` and then re-run live execution, or set `allow_execute_without_dry_run=true` to override.", + ]; + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + isError: true, + }; + } } const batchResult = await executor.runMonthlyBatch({ diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index 521edfa..fdd6b76 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ runMonthlyBatch: vi.fn(), getExecutionHistory: vi.fn(), syncPaidInvoices: vi.fn(), + getMonthlySummary: vi.fn(), })); vi.mock("../src/services/batch-retirement/executor.js", () => ({ @@ -26,6 +27,14 @@ vi.mock("../src/services/subscription/pool-sync.js", () => ({ }, })); +vi.mock("../src/services/pool-accounting/service.js", () => ({ + PoolAccountingService: class { + getMonthlySummary(month: string) { + return mocks.getMonthlySummary(month); + } + }, +})); + import { getMonthlyBatchExecutionHistoryTool, runMonthlyReconciliationTool, @@ -84,6 +93,15 @@ describe("runMonthlyReconciliationTool", () => { executedAt: "2026-03-31T12:00:00.000Z", }, ]); + mocks.getMonthlySummary.mockResolvedValue({ + month: "2026-03", + contributionCount: 2, + uniqueContributors: 2, + totalUsdCents: 600, + totalUsd: 6, + lastContributionAt: "2026-03-01T00:00:00.000Z", + contributors: [], + }); }); it("runs all-customer sync before monthly batch by default", async () => { @@ -290,6 +308,45 @@ describe("runMonthlyReconciliationTool", () => { ); }); + it("blocks live execution when latest dry-run is stale versus contributions", async () => { + mocks.getExecutionHistory.mockResolvedValueOnce([ + { + id: "batch_dry_old", + month: "2026-03", + creditType: undefined, + dryRun: true, + status: "dry_run", + reason: "plan", + budgetUsdCents: 300, + spentMicro: "0", + spentDenom: "USDC", + retiredQuantity: "0.000000", + executedAt: "2026-03-10T00:00:00.000Z", + }, + ]); + mocks.getMonthlySummary.mockResolvedValueOnce({ + month: "2026-03", + contributionCount: 3, + uniqueContributors: 2, + totalUsdCents: 900, + totalUsd: 9, + lastContributionAt: "2026-03-20T00:00:00.000Z", + contributors: [], + }); + + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + dryRun: false, + }); + const text = responseText(result); + + expect(result.isError).toBe(true); + expect(text).toContain("| Batch Status | blocked_preflight_stale_dry_run |"); + expect(text).toContain("latest `dry_run` (2026-03-10T00:00:00.000Z)"); + expect(text).toContain("latest contribution (2026-03-20T00:00:00.000Z)"); + expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + }); + it("allows live execution without latest dry-run when override is enabled", async () => { mocks.getExecutionHistory.mockResolvedValueOnce([]); From 645c09e5e4ea79103436fffd9b7a998c1cea6b0f Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:35:18 -0800 Subject: [PATCH 23/31] feat: add preflight-only mode for monthly reconciliation --- README.md | 1 + src/index.ts | 9 ++++ src/tools/monthly-batch-retirement.ts | 24 +++++++++++ tests/monthly-reconciliation-tool.test.ts | 52 +++++++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/README.md b/README.md index 4e576f4..927cb1f 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr - `sync_scope`: `none` | `customer` | `all_customers` (default), - `email` / `customer_id` / `user_id` for customer-scoped sync, - `invoice_limit` and `invoice_max_pages` for Stripe pagination control, +- `preflight_only` (default `false`) to run sync + safety checks without executing the batch, - `allow_partial_sync` (default `false`) to explicitly allow execution after truncated all-customer sync, - `allow_execute_without_dry_run` (default `false`) to allow live execution without a latest prior dry-run for the same month/credit type. diff --git a/src/index.ts b/src/index.ts index ce6caab..1d012e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -576,6 +576,13 @@ server.tool( .optional() .default(true) .describe("If true, plans the batch without broadcasting a transaction"), + preflight_only: z + .boolean() + .optional() + .default(false) + .describe( + "If true, runs sync + preflight checks only and skips monthly batch execution" + ), force: z .boolean() .optional() @@ -642,6 +649,7 @@ server.tool( credit_type, max_budget_usd, dry_run, + preflight_only, force, allow_partial_sync, allow_execute_without_dry_run, @@ -659,6 +667,7 @@ server.tool( creditType: credit_type, maxBudgetUsd: max_budget_usd, dryRun: dry_run, + preflightOnly: preflight_only, force, allowPartialSync: allow_partial_sync, allowExecuteWithoutDryRun: allow_execute_without_dry_run, diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index e0f2d4a..9492ba7 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -23,6 +23,7 @@ export interface RunMonthlyReconciliationInput { creditType?: "carbon" | "biodiversity"; maxBudgetUsd?: number; dryRun?: boolean; + preflightOnly?: boolean; force?: boolean; allowPartialSync?: boolean; allowExecuteWithoutDryRun?: boolean; @@ -563,6 +564,29 @@ export async function runMonthlyReconciliationTool( } } + if (input.preflightOnly) { + const lines: string[] = [ + "## Monthly Reconciliation", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${input.month} |`, + `| Sync Scope | ${syncScope} |`, + `| Intended Execution Mode | ${input.dryRun === false ? "live" : "dry_run"} |`, + "| Batch Status | preflight_ok |", + "", + "### Contribution Sync", + "", + renderSyncSummary(syncScope, syncResult), + "", + "Preflight checks passed. No batch execution was performed because `preflight_only=true`.", + ]; + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + }; + } + const batchResult = await executor.runMonthlyBatch({ month: input.month, creditType: input.creditType, diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index fdd6b76..f0e153c 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -367,6 +367,58 @@ describe("runMonthlyReconciliationTool", () => { ); }); + it("supports preflight-only mode and skips batch execution", async () => { + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + preflightOnly: true, + }); + const text = responseText(result); + + expect(result.isError).toBeUndefined(); + expect(text).toContain("| Batch Status | preflight_ok |"); + expect(text).toContain("preflight_only=true"); + expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + }); + + it("supports preflight-only mode for intended live execution when checks pass", async () => { + mocks.getExecutionHistory.mockResolvedValueOnce([ + { + id: "batch_dry_current", + month: "2026-03", + creditType: undefined, + dryRun: true, + status: "dry_run", + reason: "plan", + budgetUsdCents: 300, + spentMicro: "0", + spentDenom: "USDC", + retiredQuantity: "0.000000", + executedAt: "2026-03-31T13:00:00.000Z", + }, + ]); + mocks.getMonthlySummary.mockResolvedValueOnce({ + month: "2026-03", + contributionCount: 2, + uniqueContributors: 2, + totalUsdCents: 600, + totalUsd: 6, + lastContributionAt: "2026-03-31T12:00:00.000Z", + contributors: [], + }); + + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + dryRun: false, + preflightOnly: true, + }); + const text = responseText(result); + + expect(result.isError).toBeUndefined(); + expect(text).toContain("| Intended Execution Mode | live |"); + expect(text).toContain("| Batch Status | preflight_ok |"); + expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + }); + it("returns monthly batch execution history table", async () => { const result = await getMonthlyBatchExecutionHistoryTool( "2026-03", From 9f6617ee227f3c6cbd237741a70054534bcadf8e Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:39:29 -0800 Subject: [PATCH 24/31] feat: add single-flight lock for monthly reconciliation --- README.md | 1 + src/tools/monthly-batch-retirement.ts | 45 +++++++++++++++++++++- tests/monthly-reconciliation-tool.test.ts | 47 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 927cb1f..a1aabc4 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr All-customer sync output now reports whether Stripe pagination was truncated by the `invoice_max_pages` cap so operators can rerun with a higher limit when needed. Live execution preflight also requires the latest record for that month/credit type to be a fresh `dry_run` (not older than the latest contribution), unless explicitly overridden. +Reconciliation runs are also single-flight per month/credit type; concurrent overlapping requests are rejected until the active run completes. **When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index 9492ba7..b7e2faa 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -15,6 +15,7 @@ const executor = new MonthlyBatchRetirementExecutor(); const poolAccounting = new PoolAccountingService(); const poolSync = new SubscriptionPoolSyncService(); const MONTH_REGEX = /^\d{4}-\d{2}$/; +const activeReconciliationLocks = new Set(); type SyncScope = "none" | "customer" | "all_customers"; @@ -37,6 +38,25 @@ export interface RunMonthlyReconciliationInput { invoiceMaxPages?: number; } +function reconciliationLockKey( + month: string, + creditType?: "carbon" | "biodiversity" +): string { + return `${month}:${creditType || "all"}`; +} + +function acquireReconciliationLock(lockKey: string): boolean { + if (activeReconciliationLocks.has(lockKey)) { + return false; + } + activeReconciliationLocks.add(lockKey); + return true; +} + +function releaseReconciliationLock(lockKey: string): void { + activeReconciliationLocks.delete(lockKey); +} + function formatUsd(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } @@ -450,9 +470,28 @@ export async function getMonthlyReconciliationStatusTool( export async function runMonthlyReconciliationTool( input: RunMonthlyReconciliationInput ) { - try { - const syncScope = input.syncScope || "all_customers"; + const syncScope = input.syncScope || "all_customers"; + const lockKey = reconciliationLockKey(input.month, input.creditType); + if (!acquireReconciliationLock(lockKey)) { + const lines: string[] = [ + "## Monthly Reconciliation", + "", + "| Field | Value |", + "|-------|-------|", + `| Month | ${input.month} |`, + `| Sync Scope | ${syncScope} |`, + `| Credit Type | ${input.creditType || "all"} |`, + "| Batch Status | blocked_in_progress |", + "", + "A reconciliation run for this month/credit type is already in progress. Wait for it to finish, then retry.", + ]; + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + isError: true, + }; + } + try { let syncResult: SubscriptionPoolSyncResult | undefined; if (syncScope === "customer") { syncResult = await poolSync.syncPaidInvoices({ @@ -631,5 +670,7 @@ export async function runMonthlyReconciliationTool( ], isError: true, }; + } finally { + releaseReconciliationLock(lockKey); } } diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index f0e153c..b298144 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -419,6 +419,53 @@ describe("runMonthlyReconciliationTool", () => { expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); }); + it("blocks concurrent reconciliation runs for the same month and credit type", async () => { + let resolveFirstSync: + | ((value: { + scope: "all_customers"; + month: string; + fetchedInvoiceCount: number; + processedInvoiceCount: number; + syncedCount: number; + duplicateCount: number; + skippedCount: number; + records: []; + }) => void) + | undefined; + + mocks.syncPaidInvoices.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirstSync = resolve; + }) + ); + + const firstRun = runMonthlyReconciliationTool({ month: "2026-03" }); + await Promise.resolve(); + + const blocked = await runMonthlyReconciliationTool({ month: "2026-03" }); + const blockedText = responseText(blocked); + + expect(blocked.isError).toBe(true); + expect(blockedText).toContain("| Batch Status | blocked_in_progress |"); + expect(blockedText).toContain("already in progress"); + expect(mocks.syncPaidInvoices).toHaveBeenCalledTimes(1); + + resolveFirstSync?.({ + scope: "all_customers", + month: "2026-03", + fetchedInvoiceCount: 0, + processedInvoiceCount: 0, + syncedCount: 0, + duplicateCount: 0, + skippedCount: 0, + records: [], + }); + + const firstResult = await firstRun; + expect(firstResult.isError).toBeUndefined(); + expect(mocks.runMonthlyBatch).toHaveBeenCalledTimes(1); + }); + it("returns monthly batch execution history table", async () => { const result = await getMonthlyBatchExecutionHistoryTool( "2026-03", From b7f48adb3c4e7a50903177af13f9e88de601d6db Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:42:33 -0800 Subject: [PATCH 25/31] feat: add reconciliation sync and batch timeout controls --- README.md | 1 + src/index.ts | 14 +++ src/tools/monthly-batch-retirement.ts | 102 +++++++++++++++++----- tests/monthly-reconciliation-tool.test.ts | 56 ++++++++++++ 4 files changed, 150 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a1aabc4..03fe663 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr - `sync_scope`: `none` | `customer` | `all_customers` (default), - `email` / `customer_id` / `user_id` for customer-scoped sync, - `invoice_limit` and `invoice_max_pages` for Stripe pagination control, +- `sync_timeout_ms` / `batch_timeout_ms` to bound sync and batch phase runtime, - `preflight_only` (default `false`) to run sync + safety checks without executing the batch, - `allow_partial_sync` (default `false`) to explicitly allow execution after truncated all-customer sync, - `allow_execute_without_dry_run` (default `false`) to allow live execution without a latest prior dry-run for the same month/credit type. diff --git a/src/index.ts b/src/index.ts index 1d012e3..54a58fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -637,6 +637,16 @@ server.tool( .int() .optional() .describe("Max pages for account-wide sync (1-50)"), + sync_timeout_ms: z + .number() + .int() + .optional() + .describe("Optional timeout for contribution sync phase (1-300000 ms)"), + batch_timeout_ms: z + .number() + .int() + .optional() + .describe("Optional timeout for monthly batch execution phase (1-300000 ms)"), }, { readOnlyHint: false, @@ -661,6 +671,8 @@ server.tool( user_id, invoice_limit, invoice_max_pages, + sync_timeout_ms, + batch_timeout_ms, }) => { return runMonthlyReconciliationTool({ month, @@ -679,6 +691,8 @@ server.tool( userId: user_id, invoiceLimit: invoice_limit, invoiceMaxPages: invoice_max_pages, + syncTimeoutMs: sync_timeout_ms, + batchTimeoutMs: batch_timeout_ms, }); } ); diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index b7e2faa..d2d39b6 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -28,6 +28,8 @@ export interface RunMonthlyReconciliationInput { force?: boolean; allowPartialSync?: boolean; allowExecuteWithoutDryRun?: boolean; + syncTimeoutMs?: number; + batchTimeoutMs?: number; reason?: string; jurisdiction?: string; syncScope?: SyncScope; @@ -57,6 +59,39 @@ function releaseReconciliationLock(lockKey: string): void { activeReconciliationLocks.delete(lockKey); } +function resolveTimeoutMs(value: number | undefined, label: string): number | undefined { + if (typeof value === "undefined") return undefined; + if (!Number.isInteger(value) || value < 1 || value > 300_000) { + throw new Error(`${label} must be an integer between 1 and 300000`); + } + return value; +} + +async function withTimeout( + promise: Promise, + timeoutMs: number | undefined, + label: string +): Promise { + if (!timeoutMs) { + return promise; + } + + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + function formatUsd(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } @@ -492,22 +527,39 @@ export async function runMonthlyReconciliationTool( } try { + const syncTimeoutMs = resolveTimeoutMs( + input.syncTimeoutMs, + "sync_timeout_ms" + ); + const batchTimeoutMs = resolveTimeoutMs( + input.batchTimeoutMs, + "batch_timeout_ms" + ); + let syncResult: SubscriptionPoolSyncResult | undefined; if (syncScope === "customer") { - syncResult = await poolSync.syncPaidInvoices({ - month: input.month, - email: input.email, - customerId: input.customerId, - userId: input.userId, - limit: input.invoiceLimit, - }); + syncResult = await withTimeout( + poolSync.syncPaidInvoices({ + month: input.month, + email: input.email, + customerId: input.customerId, + userId: input.userId, + limit: input.invoiceLimit, + }), + syncTimeoutMs, + "Contribution sync" + ); } else if (syncScope === "all_customers") { - syncResult = await poolSync.syncPaidInvoices({ - month: input.month, - limit: input.invoiceLimit, - maxPages: input.invoiceMaxPages, - allCustomers: true, - }); + syncResult = await withTimeout( + poolSync.syncPaidInvoices({ + month: input.month, + limit: input.invoiceLimit, + maxPages: input.invoiceMaxPages, + allCustomers: true, + }), + syncTimeoutMs, + "Contribution sync" + ); } if ( @@ -626,16 +678,20 @@ export async function runMonthlyReconciliationTool( }; } - const batchResult = await executor.runMonthlyBatch({ - month: input.month, - creditType: input.creditType, - maxBudgetUsd: input.maxBudgetUsd, - dryRun: input.dryRun, - force: input.force, - reason: input.reason, - jurisdiction: input.jurisdiction, - paymentDenom: "USDC", - }); + const batchResult = await withTimeout( + executor.runMonthlyBatch({ + month: input.month, + creditType: input.creditType, + maxBudgetUsd: input.maxBudgetUsd, + dryRun: input.dryRun, + force: input.force, + reason: input.reason, + jurisdiction: input.jurisdiction, + paymentDenom: "USDC", + }), + batchTimeoutMs, + "Monthly batch execution" + ); const lines: string[] = [ "## Monthly Reconciliation", diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index b298144..9ccbcfa 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -185,6 +185,62 @@ describe("runMonthlyReconciliationTool", () => { expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); }); + it("returns timeout error when contribution sync exceeds sync_timeout_ms", async () => { + vi.useFakeTimers(); + try { + mocks.syncPaidInvoices.mockReturnValueOnce(new Promise(() => {})); + + const resultPromise = runMonthlyReconciliationTool({ + month: "2026-03", + syncTimeoutMs: 5, + }); + await vi.advanceTimersByTimeAsync(5); + const result = await resultPromise; + + expect(result.isError).toBe(true); + expect(responseText(result)).toContain( + "Monthly reconciliation failed: Contribution sync timed out after 5ms" + ); + expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("returns timeout error when batch phase exceeds batch_timeout_ms", async () => { + vi.useFakeTimers(); + try { + mocks.runMonthlyBatch.mockReturnValueOnce(new Promise(() => {})); + + const resultPromise = runMonthlyReconciliationTool({ + month: "2026-03", + batchTimeoutMs: 5, + }); + await vi.advanceTimersByTimeAsync(5); + const result = await resultPromise; + + expect(result.isError).toBe(true); + expect(responseText(result)).toContain( + "Monthly reconciliation failed: Monthly batch execution timed out after 5ms" + ); + } finally { + vi.useRealTimers(); + } + }); + + it("validates timeout parameter bounds", async () => { + const result = await runMonthlyReconciliationTool({ + month: "2026-03", + syncTimeoutMs: 0, + }); + + expect(result.isError).toBe(true); + expect(responseText(result)).toContain( + "Monthly reconciliation failed: sync_timeout_ms must be an integer between 1 and 300000" + ); + expect(mocks.syncPaidInvoices).not.toHaveBeenCalled(); + }); + it("blocks batch execution when all-customer sync is truncated", async () => { mocks.syncPaidInvoices.mockResolvedValueOnce({ scope: "all_customers", From ba29c92ad9ac7c628d0bf9e926abdbb1edc734f3 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:50:14 -0800 Subject: [PATCH 26/31] feat: persist and query monthly reconciliation run history --- README.md | 10 + src/index.ts | 52 ++++- .../reconciliation-run-history/service.ts | 133 +++++++++++ .../reconciliation-run-history/store.ts | 55 +++++ .../reconciliation-run-history/types.ts | 79 +++++++ src/tools/monthly-batch-retirement.ts | 210 +++++++++++++++++- ...ly-reconciliation-run-history-tool.test.ts | 105 +++++++++ tests/monthly-reconciliation-tool.test.ts | 34 +++ ...reconciliation-run-history-service.test.ts | 115 ++++++++++ 9 files changed, 787 insertions(+), 6 deletions(-) create mode 100644 src/services/reconciliation-run-history/service.ts create mode 100644 src/services/reconciliation-run-history/store.ts create mode 100644 src/services/reconciliation-run-history/types.ts create mode 100644 tests/monthly-reconciliation-run-history-tool.test.ts create mode 100644 tests/reconciliation-run-history-service.test.ts diff --git a/README.md b/README.md index 03fe663..75e444d 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,16 @@ Returns an operator readiness snapshot for a month by combining: **When it's used:** Pre-flight check before monthly execution, and quick triage when deciding whether to sync, dry-run, execute, or avoid reruns. +### `get_monthly_reconciliation_run_history` + +Returns persisted reconciliation orchestration history from the local run ledger (`REGEN_RECONCILIATION_RUNS_PATH`) with optional filters: +- `month` (`YYYY-MM`) +- `status` (`in_progress` | `completed` | `blocked` | `failed`) +- `credit_type` (`carbon` | `biodiversity`) +- `limit` (`1-200`, default `50`) + +**When it's used:** Operator auditing/troubleshooting across end-to-end reconciliation attempts, including blocked preflight runs and timeout/error failures. + ### `publish_subscriber_certificate_page` Generates a user-facing HTML certificate page for a subscriber's monthly fractional attribution record and returns: diff --git a/src/index.ts b/src/index.ts index 54a58fb..703ccc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { } from "./tools/pool-accounting.js"; import { getMonthlyBatchExecutionHistoryTool, + getMonthlyReconciliationRunHistoryTool, getMonthlyReconciliationStatusTool, runMonthlyBatchRetirementTool, runMonthlyReconciliationTool, @@ -126,11 +127,12 @@ const server = new McpServer( "10. run_monthly_reconciliation — optional contribution sync + monthly batch in one operator workflow", "11. get_monthly_batch_execution_history — query stored monthly batch run history with filters", "12. get_monthly_reconciliation_status — operator readiness/status view for a target month", - "13. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", - "14. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", - "15. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", - "16. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", - "17. link_identity_session / recover_identity_session — identity linking and recovery flows", + "13. get_monthly_reconciliation_run_history — query stored reconciliation orchestration run history with filters", + "14. get_subscriber_impact_dashboard / get_subscriber_attribution_certificate — user-facing fractional impact views", + "15. publish_subscriber_certificate_page — generate a user-facing certificate HTML page and URL", + "16. publish_subscriber_dashboard_page — generate a user-facing dashboard HTML page and URL", + "17. start_identity_auth_session / verify_identity_auth_session / get_identity_auth_session — hardened identity auth session lifecycle", + "18. link_identity_session / recover_identity_session — identity linking and recovery flows", "", ...(walletMode ? [ @@ -147,6 +149,7 @@ const server = new McpServer( "The run_monthly_reconciliation tool orchestrates contribution sync and monthly batch execution in one call.", "The get_monthly_batch_execution_history tool returns persisted batch run history for operator auditing and troubleshooting.", "The get_monthly_reconciliation_status tool summarizes contribution totals, latest execution state, and readiness guidance for a month.", + "The get_monthly_reconciliation_run_history tool returns persisted orchestration history across success/failure/blocked preflight outcomes.", "Subscriber dashboard tools expose fractional attribution and impact history per user.", "Certificate frontend tool publishes shareable subscriber certificate pages to a configurable URL/path.", "Dashboard frontend tool publishes shareable subscriber impact dashboard pages to a configurable URL/path.", @@ -765,6 +768,45 @@ server.tool( } ); +// Tool: Query persisted monthly reconciliation orchestration run history +server.tool( + "get_monthly_reconciliation_run_history", + "Returns persisted monthly reconciliation orchestration history with optional filters for month, status, credit type, and record limit.", + { + month: z + .string() + .optional() + .describe("Optional month filter in YYYY-MM format"), + status: z + .enum(["in_progress", "completed", "blocked", "failed"]) + .optional() + .describe("Optional reconciliation run status filter"), + credit_type: z + .enum(["carbon", "biodiversity"]) + .optional() + .describe("Optional credit type filter"), + limit: z + .number() + .int() + .optional() + .describe("Max records to return (1-200, default 50)"), + }, + { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async ({ month, status, credit_type, limit }) => { + return getMonthlyReconciliationRunHistoryTool( + month, + status, + credit_type, + limit + ); + } +); + // Tool: User-facing fractional impact dashboard server.tool( "get_subscriber_impact_dashboard", diff --git a/src/services/reconciliation-run-history/service.ts b/src/services/reconciliation-run-history/service.ts new file mode 100644 index 0000000..4595089 --- /dev/null +++ b/src/services/reconciliation-run-history/service.ts @@ -0,0 +1,133 @@ +import { randomUUID } from "node:crypto"; +import { JsonFileReconciliationRunStore } from "./store.js"; +import type { + FinishReconciliationRunInput, + ReconciliationRunHistoryQuery, + ReconciliationRunRecord, + ReconciliationRunStore, + RecordBlockedReconciliationRunInput, + StartReconciliationRunInput, +} from "./types.js"; + +const MONTH_REGEX = /^\d{4}-\d{2}$/; + +function sortByStartedAtAsc(records: ReconciliationRunRecord[]): void { + records.sort((a, b) => a.startedAt.localeCompare(b.startedAt)); +} + +function normalizeLimit(value?: number): number { + return typeof value === "number" && Number.isInteger(value) + ? Math.min(200, Math.max(1, value)) + : 50; +} + +export class ReconciliationRunHistoryService { + constructor( + private readonly store: ReconciliationRunStore = new JsonFileReconciliationRunStore() + ) {} + + async startRun( + input: StartReconciliationRunInput + ): Promise { + if (!MONTH_REGEX.test(input.month)) { + throw new Error("month must be in YYYY-MM format"); + } + + const state = await this.store.readState(); + const record: ReconciliationRunRecord = { + id: `reconcile_${randomUUID()}`, + month: input.month, + creditType: input.creditType, + syncScope: input.syncScope, + executionMode: input.executionMode, + preflightOnly: input.preflightOnly, + force: input.force, + status: "in_progress", + batchStatus: "in_progress", + startedAt: new Date().toISOString(), + }; + + state.runs.push(record); + sortByStartedAtAsc(state.runs); + await this.store.writeState(state); + return record; + } + + async finishRun( + runId: string, + input: FinishReconciliationRunInput + ): Promise { + const state = await this.store.readState(); + const record = state.runs.find((item) => item.id === runId); + if (!record) { + throw new Error(`Unknown reconciliation run id: ${runId}`); + } + + record.status = input.status; + record.batchStatus = input.batchStatus; + record.sync = input.sync; + record.message = input.message; + record.error = input.error; + record.finishedAt = new Date().toISOString(); + + sortByStartedAtAsc(state.runs); + await this.store.writeState(state); + return record; + } + + async recordBlockedRun( + input: RecordBlockedReconciliationRunInput + ): Promise { + if (!MONTH_REGEX.test(input.month)) { + throw new Error("month must be in YYYY-MM format"); + } + + const state = await this.store.readState(); + const now = new Date().toISOString(); + const record: ReconciliationRunRecord = { + id: `reconcile_${randomUUID()}`, + month: input.month, + creditType: input.creditType, + syncScope: input.syncScope, + executionMode: input.executionMode, + preflightOnly: input.preflightOnly, + force: input.force, + status: "blocked", + batchStatus: input.batchStatus, + sync: input.sync, + message: input.message, + startedAt: now, + finishedAt: now, + }; + + state.runs.push(record); + sortByStartedAtAsc(state.runs); + await this.store.writeState(state); + return record; + } + + async getHistory( + query: ReconciliationRunHistoryQuery = {} + ): Promise { + if (query.month && !MONTH_REGEX.test(query.month)) { + throw new Error("month must be in YYYY-MM format"); + } + + const state = await this.store.readState(); + const filtered = state.runs.filter((item) => { + if (query.month && item.month !== query.month) return false; + if (query.status && item.status !== query.status) return false; + if (query.creditType && item.creditType !== query.creditType) return false; + return true; + }); + + const newestFirst = query.newestFirst !== false; + filtered.sort((a, b) => + newestFirst + ? b.startedAt.localeCompare(a.startedAt) + : a.startedAt.localeCompare(b.startedAt) + ); + + return filtered.slice(0, normalizeLimit(query.limit)); + } +} diff --git a/src/services/reconciliation-run-history/store.ts b/src/services/reconciliation-run-history/store.ts new file mode 100644 index 0000000..ab215b4 --- /dev/null +++ b/src/services/reconciliation-run-history/store.ts @@ -0,0 +1,55 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { ReconciliationRunState, ReconciliationRunStore } from "./types.js"; + +const DEFAULT_RELATIVE_RECONCILIATION_RUNS_PATH = + "data/monthly-reconciliation-runs.json"; + +function getDefaultState(): ReconciliationRunState { + return { version: 1, runs: [] }; +} + +function isValidState(value: unknown): value is ReconciliationRunState { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return candidate.version === 1 && Array.isArray(candidate.runs); +} + +export function getDefaultReconciliationRunsPath(): string { + const configured = process.env.REGEN_RECONCILIATION_RUNS_PATH?.trim(); + if (configured) { + return path.resolve(configured); + } + return path.resolve(process.cwd(), DEFAULT_RELATIVE_RECONCILIATION_RUNS_PATH); +} + +export class JsonFileReconciliationRunStore implements ReconciliationRunStore { + constructor( + private readonly filePath: string = getDefaultReconciliationRunsPath() + ) {} + + async readState(): Promise { + try { + const raw = await readFile(this.filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isValidState(parsed)) { + throw new Error("Invalid monthly reconciliation runs file format"); + } + return parsed; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return getDefaultState(); + } + throw err; + } + } + + async writeState(state: ReconciliationRunState): Promise { + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true }); + + const tempPath = `${this.filePath}.tmp`; + await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8"); + await rename(tempPath, this.filePath); + } +} diff --git a/src/services/reconciliation-run-history/types.ts b/src/services/reconciliation-run-history/types.ts new file mode 100644 index 0000000..26817d8 --- /dev/null +++ b/src/services/reconciliation-run-history/types.ts @@ -0,0 +1,79 @@ +export type ReconciliationRunStatus = + | "in_progress" + | "completed" + | "blocked" + | "failed"; + +export type ReconciliationExecutionMode = "dry_run" | "live"; + +export interface ReconciliationRunSyncSummary { + scope: "none" | "customer" | "all_customers"; + fetchedInvoiceCount: number; + processedInvoiceCount: number; + syncedCount: number; + duplicateCount: number; + skippedCount: number; + truncated?: boolean; + hasMore?: boolean; + pageCount?: number; + maxPages?: number; +} + +export interface ReconciliationRunRecord { + id: string; + month: string; + creditType?: "carbon" | "biodiversity"; + syncScope: "none" | "customer" | "all_customers"; + executionMode: ReconciliationExecutionMode; + preflightOnly: boolean; + force: boolean; + status: ReconciliationRunStatus; + batchStatus: string; + sync?: ReconciliationRunSyncSummary; + message?: string; + error?: string; + startedAt: string; + finishedAt?: string; +} + +export interface ReconciliationRunState { + version: 1; + runs: ReconciliationRunRecord[]; +} + +export interface ReconciliationRunStore { + readState(): Promise; + writeState(state: ReconciliationRunState): Promise; +} + +export interface StartReconciliationRunInput { + month: string; + creditType?: "carbon" | "biodiversity"; + syncScope: "none" | "customer" | "all_customers"; + executionMode: ReconciliationExecutionMode; + preflightOnly: boolean; + force: boolean; +} + +export interface FinishReconciliationRunInput { + status: Exclude; + batchStatus: string; + sync?: ReconciliationRunSyncSummary; + message?: string; + error?: string; +} + +export interface RecordBlockedReconciliationRunInput + extends StartReconciliationRunInput { + batchStatus: string; + sync?: ReconciliationRunSyncSummary; + message?: string; +} + +export interface ReconciliationRunHistoryQuery { + month?: string; + status?: ReconciliationRunStatus; + creditType?: "carbon" | "biodiversity"; + limit?: number; + newestFirst?: boolean; +} diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index d2d39b6..cbd6e3f 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -10,10 +10,16 @@ import { } from "../services/subscription/pool-sync.js"; import { loadConfig } from "../config.js"; import { calculateProtocolFee } from "../services/batch-retirement/fee.js"; +import { ReconciliationRunHistoryService } from "../services/reconciliation-run-history/service.js"; +import type { + ReconciliationRunStatus, + ReconciliationRunSyncSummary, +} from "../services/reconciliation-run-history/types.js"; const executor = new MonthlyBatchRetirementExecutor(); const poolAccounting = new PoolAccountingService(); const poolSync = new SubscriptionPoolSyncService(); +const reconciliationHistory = new ReconciliationRunHistoryService(); const MONTH_REGEX = /^\d{4}-\d{2}$/; const activeReconciliationLocks = new Set(); @@ -92,6 +98,35 @@ async function withTimeout( } } +function toReconciliationSyncSummary( + syncScope: SyncScope, + result?: SubscriptionPoolSyncResult +): ReconciliationRunSyncSummary { + if (!result) { + return { + scope: syncScope, + fetchedInvoiceCount: 0, + processedInvoiceCount: 0, + syncedCount: 0, + duplicateCount: 0, + skippedCount: 0, + }; + } + + return { + scope: result.scope, + fetchedInvoiceCount: result.fetchedInvoiceCount, + processedInvoiceCount: result.processedInvoiceCount, + syncedCount: result.syncedCount, + duplicateCount: result.duplicateCount, + skippedCount: result.skippedCount, + truncated: result.truncated, + hasMore: result.hasMore, + pageCount: result.pageCount, + maxPages: result.maxPages, + }; +} + function formatUsd(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } @@ -502,12 +537,97 @@ export async function getMonthlyReconciliationStatusTool( } } +export async function getMonthlyReconciliationRunHistoryTool( + month?: string, + status?: ReconciliationRunStatus, + creditType?: "carbon" | "biodiversity", + limit?: number +) { + try { + const records = await reconciliationHistory.getHistory({ + month, + status, + creditType, + limit, + newestFirst: true, + }); + + const lines: string[] = [ + "## Monthly Reconciliation Run History", + "", + "| Filter | Value |", + "|--------|-------|", + `| Month | ${month || "all"} |`, + `| Status | ${status || "all"} |`, + `| Credit Type | ${creditType || "all"} |`, + `| Returned Records | ${records.length} |`, + ]; + + if (records.length === 0) { + lines.push("", "No reconciliation runs matched the provided filters."); + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } + + lines.push( + "", + "| Started At | Finished At | ID | Month | Status | Batch Status | Mode | Preflight | Sync Scope | Credit Type | Sync Summary |", + "|------------|-------------|----|-------|--------|--------------|------|-----------|------------|-------------|--------------|", + ...records.map((record) => { + const syncSummary = record.sync + ? `${record.sync.syncedCount}/${record.sync.processedInvoiceCount} synced, ${record.sync.fetchedInvoiceCount} fetched${record.sync.truncated ? ", truncated" : ""}` + : "N/A"; + + return `| ${record.startedAt} | ${record.finishedAt || "N/A"} | ${record.id} | ${record.month} | ${record.status} | ${record.batchStatus} | ${record.executionMode} | ${record.preflightOnly ? "Yes" : "No"} | ${record.syncScope} | ${record.creditType || "all"} | ${syncSummary} |`; + }) + ); + + return { content: [{ type: "text" as const, text: lines.join("\n") }] }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Unknown reconciliation run history error"; + return { + content: [ + { + type: "text" as const, + text: `Reconciliation run history query failed: ${message}`, + }, + ], + isError: true, + }; + } +} + export async function runMonthlyReconciliationTool( input: RunMonthlyReconciliationInput ) { const syncScope = input.syncScope || "all_customers"; + const executionMode = input.dryRun === false ? "live" : "dry_run"; + const runStartInput = { + month: input.month, + creditType: input.creditType, + syncScope, + executionMode, + preflightOnly: Boolean(input.preflightOnly), + force: Boolean(input.force), + } as const; const lockKey = reconciliationLockKey(input.month, input.creditType); if (!acquireReconciliationLock(lockKey)) { + let blockedRunId: string | undefined; + try { + blockedRunId = ( + await reconciliationHistory.recordBlockedRun({ + ...runStartInput, + batchStatus: "blocked_in_progress", + message: + "A reconciliation run for this month/credit type is already in progress.", + }) + ).id; + } catch { + // Reconciliation execution should not fail if audit logging is unavailable. + } + const lines: string[] = [ "## Monthly Reconciliation", "", @@ -517,6 +637,7 @@ export async function runMonthlyReconciliationTool( `| Sync Scope | ${syncScope} |`, `| Credit Type | ${input.creditType || "all"} |`, "| Batch Status | blocked_in_progress |", + `| Reconciliation Run ID | ${blockedRunId || "N/A"} |`, "", "A reconciliation run for this month/credit type is already in progress. Wait for it to finish, then retry.", ]; @@ -526,7 +647,16 @@ export async function runMonthlyReconciliationTool( }; } + let runId: string | undefined; + let syncResult: SubscriptionPoolSyncResult | undefined; + try { + try { + runId = (await reconciliationHistory.startRun(runStartInput)).id; + } catch { + // Reconciliation execution should not fail if audit logging is unavailable. + } + const syncTimeoutMs = resolveTimeoutMs( input.syncTimeoutMs, "sync_timeout_ms" @@ -536,7 +666,6 @@ export async function runMonthlyReconciliationTool( "batch_timeout_ms" ); - let syncResult: SubscriptionPoolSyncResult | undefined; if (syncScope === "customer") { syncResult = await withTimeout( poolSync.syncPaidInvoices({ @@ -567,6 +696,18 @@ export async function runMonthlyReconciliationTool( syncResult?.truncated && !input.allowPartialSync ) { + if (runId) { + await reconciliationHistory + .finishRun(runId, { + status: "blocked", + batchStatus: "blocked_partial_sync", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: + "All-customer invoice sync was truncated by invoice_max_pages.", + }) + .catch(() => undefined); + } + const lines: string[] = [ "## Monthly Reconciliation", "", @@ -575,6 +716,7 @@ export async function runMonthlyReconciliationTool( `| Month | ${input.month} |`, `| Sync Scope | ${syncScope} |`, "| Batch Status | blocked_partial_sync |", + `| Reconciliation Run ID | ${runId || "N/A"} |`, "", "### Contribution Sync", "", @@ -604,6 +746,18 @@ export async function runMonthlyReconciliationTool( ]); if (latestExecution?.status !== "dry_run") { + if (runId) { + await reconciliationHistory + .finishRun(runId, { + status: "blocked", + batchStatus: "blocked_preflight", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: + "Latest execution is not dry_run, so live execution preflight was blocked.", + }) + .catch(() => undefined); + } + const lines: string[] = [ "## Monthly Reconciliation", "", @@ -612,6 +766,7 @@ export async function runMonthlyReconciliationTool( `| Month | ${input.month} |`, `| Sync Scope | ${syncScope} |`, "| Batch Status | blocked_preflight |", + `| Reconciliation Run ID | ${runId || "N/A"} |`, "", "### Contribution Sync", "", @@ -631,6 +786,18 @@ export async function runMonthlyReconciliationTool( monthSummary.lastContributionAt && latestExecution.executedAt.localeCompare(monthSummary.lastContributionAt) < 0 ) { + if (runId) { + await reconciliationHistory + .finishRun(runId, { + status: "blocked", + batchStatus: "blocked_preflight_stale_dry_run", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: + "Latest dry_run record is older than latest contribution.", + }) + .catch(() => undefined); + } + const lines: string[] = [ "## Monthly Reconciliation", "", @@ -639,6 +806,7 @@ export async function runMonthlyReconciliationTool( `| Month | ${input.month} |`, `| Sync Scope | ${syncScope} |`, "| Batch Status | blocked_preflight_stale_dry_run |", + `| Reconciliation Run ID | ${runId || "N/A"} |`, "", "### Contribution Sync", "", @@ -656,6 +824,17 @@ export async function runMonthlyReconciliationTool( } if (input.preflightOnly) { + if (runId) { + await reconciliationHistory + .finishRun(runId, { + status: "completed", + batchStatus: "preflight_ok", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: "Preflight-only mode completed successfully.", + }) + .catch(() => undefined); + } + const lines: string[] = [ "## Monthly Reconciliation", "", @@ -665,6 +844,7 @@ export async function runMonthlyReconciliationTool( `| Sync Scope | ${syncScope} |`, `| Intended Execution Mode | ${input.dryRun === false ? "live" : "dry_run"} |`, "| Batch Status | preflight_ok |", + `| Reconciliation Run ID | ${runId || "N/A"} |`, "", "### Contribution Sync", "", @@ -693,6 +873,20 @@ export async function runMonthlyReconciliationTool( "Monthly batch execution" ); + const finalStatus: Exclude = + batchResult.status === "failed" ? "failed" : "completed"; + if (runId) { + await reconciliationHistory + .finishRun(runId, { + status: finalStatus, + batchStatus: batchResult.status, + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: batchResult.message, + error: batchResult.status === "failed" ? batchResult.message : undefined, + }) + .catch(() => undefined); + } + const lines: string[] = [ "## Monthly Reconciliation", "", @@ -701,6 +895,7 @@ export async function runMonthlyReconciliationTool( `| Month | ${input.month} |`, `| Sync Scope | ${syncScope} |`, `| Batch Status | ${batchResult.status} |`, + `| Reconciliation Run ID | ${runId || "N/A"} |`, "", "### Contribution Sync", "", @@ -717,6 +912,19 @@ export async function runMonthlyReconciliationTool( } catch (error) { const message = error instanceof Error ? error.message : "Unknown reconciliation error"; + + if (runId) { + await reconciliationHistory + .finishRun(runId, { + status: "failed", + batchStatus: "error", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message, + error: message, + }) + .catch(() => undefined); + } + return { content: [ { diff --git a/tests/monthly-reconciliation-run-history-tool.test.ts b/tests/monthly-reconciliation-run-history-tool.test.ts new file mode 100644 index 0000000..3999635 --- /dev/null +++ b/tests/monthly-reconciliation-run-history-tool.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getHistory: vi.fn(), +})); + +vi.mock("../src/services/reconciliation-run-history/service.js", () => ({ + ReconciliationRunHistoryService: class { + getHistory(input: unknown) { + return mocks.getHistory(input); + } + + startRun() { + throw new Error("not implemented for this test"); + } + + finishRun() { + throw new Error("not implemented for this test"); + } + + recordBlockedRun() { + throw new Error("not implemented for this test"); + } + }, +})); + +import { getMonthlyReconciliationRunHistoryTool } from "../src/tools/monthly-batch-retirement.js"; + +function responseText(result: { content: Array<{ type: "text"; text: string }> }): string { + return result.content[0]?.text ?? ""; +} + +describe("getMonthlyReconciliationRunHistoryTool", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getHistory.mockResolvedValue([ + { + id: "reconcile_1", + month: "2026-03", + creditType: "carbon", + syncScope: "all_customers", + executionMode: "dry_run", + preflightOnly: false, + force: false, + status: "completed", + batchStatus: "dry_run", + startedAt: "2026-03-01T00:00:00.000Z", + finishedAt: "2026-03-01T00:00:02.000Z", + sync: { + scope: "all_customers", + fetchedInvoiceCount: 10, + processedInvoiceCount: 10, + syncedCount: 9, + duplicateCount: 1, + skippedCount: 0, + truncated: false, + }, + }, + ]); + }); + + it("returns reconciliation run history table", async () => { + const result = await getMonthlyReconciliationRunHistoryTool( + "2026-03", + "completed", + "carbon", + 25 + ); + const text = responseText(result); + + expect(mocks.getHistory).toHaveBeenCalledWith({ + month: "2026-03", + status: "completed", + creditType: "carbon", + limit: 25, + newestFirst: true, + }); + expect(text).toContain("## Monthly Reconciliation Run History"); + expect(text).toContain("| Returned Records | 1 |"); + expect(text).toContain("| reconcile_1 |"); + expect(text).toContain("| completed |"); + expect(text).toContain("9/10 synced"); + }); + + it("returns empty-state message when no records match", async () => { + mocks.getHistory.mockResolvedValueOnce([]); + const result = await getMonthlyReconciliationRunHistoryTool("2026-03"); + const text = responseText(result); + + expect(text).toContain("| Returned Records | 0 |"); + expect(text).toContain("No reconciliation runs matched the provided filters."); + }); + + it("returns error response when query fails", async () => { + mocks.getHistory.mockRejectedValueOnce( + new Error("month must be in YYYY-MM format") + ); + const result = await getMonthlyReconciliationRunHistoryTool("03-2026"); + + expect(result.isError).toBe(true); + expect(responseText(result)).toContain( + "Reconciliation run history query failed: month must be in YYYY-MM format" + ); + }); +}); diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index 9ccbcfa..5e95818 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -5,6 +5,10 @@ const mocks = vi.hoisted(() => ({ getExecutionHistory: vi.fn(), syncPaidInvoices: vi.fn(), getMonthlySummary: vi.fn(), + startRun: vi.fn(), + finishRun: vi.fn(), + recordBlockedRun: vi.fn(), + getHistory: vi.fn(), })); vi.mock("../src/services/batch-retirement/executor.js", () => ({ @@ -35,6 +39,26 @@ vi.mock("../src/services/pool-accounting/service.js", () => ({ }, })); +vi.mock("../src/services/reconciliation-run-history/service.js", () => ({ + ReconciliationRunHistoryService: class { + startRun(input: unknown) { + return mocks.startRun(input); + } + + finishRun(runId: string, input: unknown) { + return mocks.finishRun(runId, input); + } + + recordBlockedRun(input: unknown) { + return mocks.recordBlockedRun(input); + } + + getHistory(input: unknown) { + return mocks.getHistory(input); + } + }, +})); + import { getMonthlyBatchExecutionHistoryTool, runMonthlyReconciliationTool, @@ -102,6 +126,16 @@ describe("runMonthlyReconciliationTool", () => { lastContributionAt: "2026-03-01T00:00:00.000Z", contributors: [], }); + mocks.startRun.mockResolvedValue({ + id: "reconcile_run_1", + }); + mocks.finishRun.mockResolvedValue({ + id: "reconcile_run_1", + }); + mocks.recordBlockedRun.mockResolvedValue({ + id: "reconcile_blocked_1", + }); + mocks.getHistory.mockResolvedValue([]); }); it("runs all-customer sync before monthly batch by default", async () => { diff --git a/tests/reconciliation-run-history-service.test.ts b/tests/reconciliation-run-history-service.test.ts new file mode 100644 index 0000000..56d1bfe --- /dev/null +++ b/tests/reconciliation-run-history-service.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { ReconciliationRunHistoryService } from "../src/services/reconciliation-run-history/service.js"; +import type { + ReconciliationRunState, + ReconciliationRunStore, +} from "../src/services/reconciliation-run-history/types.js"; + +class InMemoryReconciliationRunStore implements ReconciliationRunStore { + private state: ReconciliationRunState = { version: 1, runs: [] }; + + async readState(): Promise { + return JSON.parse(JSON.stringify(this.state)) as ReconciliationRunState; + } + + async writeState(state: ReconciliationRunState): Promise { + this.state = JSON.parse(JSON.stringify(state)) as ReconciliationRunState; + } +} + +describe("ReconciliationRunHistoryService", () => { + it("starts and finishes runs with persisted status fields", async () => { + const service = new ReconciliationRunHistoryService( + new InMemoryReconciliationRunStore() + ); + + const started = await service.startRun({ + month: "2026-03", + creditType: "carbon", + syncScope: "all_customers", + executionMode: "dry_run", + preflightOnly: false, + force: false, + }); + + expect(started.id).toMatch(/^reconcile_/); + expect(started.status).toBe("in_progress"); + expect(started.batchStatus).toBe("in_progress"); + + const finished = await service.finishRun(started.id, { + status: "completed", + batchStatus: "dry_run", + sync: { + scope: "all_customers", + fetchedInvoiceCount: 10, + processedInvoiceCount: 10, + syncedCount: 9, + duplicateCount: 1, + skippedCount: 0, + truncated: false, + }, + message: "Dry run complete", + }); + + expect(finished.status).toBe("completed"); + expect(finished.batchStatus).toBe("dry_run"); + expect(finished.finishedAt).toBeDefined(); + expect(finished.sync?.syncedCount).toBe(9); + + const history = await service.getHistory({ month: "2026-03" }); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + id: started.id, + status: "completed", + batchStatus: "dry_run", + month: "2026-03", + creditType: "carbon", + }); + }); + + it("records blocked runs and supports status filtering", async () => { + const service = new ReconciliationRunHistoryService( + new InMemoryReconciliationRunStore() + ); + + const blocked = await service.recordBlockedRun({ + month: "2026-03", + syncScope: "all_customers", + executionMode: "live", + preflightOnly: false, + force: false, + batchStatus: "blocked_preflight", + message: "latest run is not dry_run", + }); + + expect(blocked.status).toBe("blocked"); + expect(blocked.finishedAt).toBeDefined(); + + const blockedOnly = await service.getHistory({ status: "blocked" }); + expect(blockedOnly).toHaveLength(1); + expect(blockedOnly[0]).toMatchObject({ + id: blocked.id, + batchStatus: "blocked_preflight", + }); + }); + + it("validates month format for writes and queries", async () => { + const service = new ReconciliationRunHistoryService( + new InMemoryReconciliationRunStore() + ); + + await expect( + service.startRun({ + month: "03-2026", + syncScope: "none", + executionMode: "dry_run", + preflightOnly: false, + force: false, + }) + ).rejects.toThrow("month must be in YYYY-MM format"); + + await expect(service.getHistory({ month: "03-2026" })).rejects.toThrow( + "month must be in YYYY-MM format" + ); + }); +}); From 5414de4f3c97954b059966c0bf12154329614373 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:01:01 -0800 Subject: [PATCH 27/31] fix: retain reconciliation lock until timed-out phase settles --- README.md | 1 + src/tools/monthly-batch-retirement.ts | 26 ++++- tests/monthly-reconciliation-tool.test.ts | 111 +++++++++++++++++++++- 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 75e444d..9fe5db9 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,7 @@ Supports all `run_monthly_batch_retirement` execution parameters plus sync contr All-customer sync output now reports whether Stripe pagination was truncated by the `invoice_max_pages` cap so operators can rerun with a higher limit when needed. Live execution preflight also requires the latest record for that month/credit type to be a fresh `dry_run` (not older than the latest contribution), unless explicitly overridden. Reconciliation runs are also single-flight per month/credit type; concurrent overlapping requests are rejected until the active run completes. +When a sync/batch timeout is hit, the run lock remains active until the timed-out phase fully settles, preventing overlapping retries from racing. **When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index cbd6e3f..dbab239 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -76,7 +76,8 @@ function resolveTimeoutMs(value: number | undefined, label: string): number | un async function withTimeout( promise: Promise, timeoutMs: number | undefined, - label: string + label: string, + onTimeout?: (pendingPromise: Promise) => void ): Promise { if (!timeoutMs) { return promise; @@ -85,6 +86,7 @@ async function withTimeout( let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { + onTimeout?.(promise); reject(new Error(`${label} timed out after ${timeoutMs}ms`)); }, timeoutMs); }); @@ -649,6 +651,11 @@ export async function runMonthlyReconciliationTool( let runId: string | undefined; let syncResult: SubscriptionPoolSyncResult | undefined; + const timedOutPendingOperations: Promise[] = []; + + const trackTimedOutOperation = (pendingPromise: Promise) => { + timedOutPendingOperations.push(pendingPromise.catch(() => undefined)); + }; try { try { @@ -676,7 +683,8 @@ export async function runMonthlyReconciliationTool( limit: input.invoiceLimit, }), syncTimeoutMs, - "Contribution sync" + "Contribution sync", + trackTimedOutOperation ); } else if (syncScope === "all_customers") { syncResult = await withTimeout( @@ -687,7 +695,8 @@ export async function runMonthlyReconciliationTool( allCustomers: true, }), syncTimeoutMs, - "Contribution sync" + "Contribution sync", + trackTimedOutOperation ); } @@ -870,7 +879,8 @@ export async function runMonthlyReconciliationTool( paymentDenom: "USDC", }), batchTimeoutMs, - "Monthly batch execution" + "Monthly batch execution", + trackTimedOutOperation ); const finalStatus: Exclude = @@ -935,6 +945,12 @@ export async function runMonthlyReconciliationTool( isError: true, }; } finally { - releaseReconciliationLock(lockKey); + if (timedOutPendingOperations.length > 0) { + void Promise.allSettled(timedOutPendingOperations).finally(() => { + releaseReconciliationLock(lockKey); + }); + } else { + releaseReconciliationLock(lockKey); + } } } diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index 5e95818..ede33e1 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -222,7 +222,23 @@ describe("runMonthlyReconciliationTool", () => { it("returns timeout error when contribution sync exceeds sync_timeout_ms", async () => { vi.useFakeTimers(); try { - mocks.syncPaidInvoices.mockReturnValueOnce(new Promise(() => {})); + let resolveSync: + | ((value: { + scope: "all_customers"; + month: string; + fetchedInvoiceCount: number; + processedInvoiceCount: number; + syncedCount: number; + duplicateCount: number; + skippedCount: number; + records: []; + }) => void) + | undefined; + mocks.syncPaidInvoices.mockReturnValueOnce( + new Promise((resolve) => { + resolveSync = resolve; + }) + ); const resultPromise = runMonthlyReconciliationTool({ month: "2026-03", @@ -236,6 +252,79 @@ describe("runMonthlyReconciliationTool", () => { "Monthly reconciliation failed: Contribution sync timed out after 5ms" ); expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); + + resolveSync?.({ + scope: "all_customers", + month: "2026-03", + fetchedInvoiceCount: 0, + processedInvoiceCount: 0, + syncedCount: 0, + duplicateCount: 0, + skippedCount: 0, + records: [], + }); + await Promise.resolve(); + await Promise.resolve(); + } finally { + vi.useRealTimers(); + } + }); + + it("retains reconciliation lock until timed-out sync promise settles", async () => { + vi.useFakeTimers(); + try { + let resolveFirstSync: + | ((value: { + scope: "all_customers"; + month: string; + fetchedInvoiceCount: number; + processedInvoiceCount: number; + syncedCount: number; + duplicateCount: number; + skippedCount: number; + records: []; + }) => void) + | undefined; + + mocks.syncPaidInvoices.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirstSync = resolve; + }) + ); + + const timedOutRunPromise = runMonthlyReconciliationTool({ + month: "2026-03", + syncTimeoutMs: 5, + }); + await vi.advanceTimersByTimeAsync(5); + const timedOutRun = await timedOutRunPromise; + + expect(timedOutRun.isError).toBe(true); + expect(responseText(timedOutRun)).toContain( + "Contribution sync timed out after 5ms" + ); + + const blocked = await runMonthlyReconciliationTool({ month: "2026-03" }); + expect(blocked.isError).toBe(true); + expect(responseText(blocked)).toContain("| Batch Status | blocked_in_progress |"); + + resolveFirstSync?.({ + scope: "all_customers", + month: "2026-03", + fetchedInvoiceCount: 0, + processedInvoiceCount: 0, + syncedCount: 0, + duplicateCount: 0, + skippedCount: 0, + records: [], + }); + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + } + + const retry = await runMonthlyReconciliationTool({ month: "2026-03" }); + expect(responseText(retry)).not.toContain("| Batch Status | blocked_in_progress |"); + expect(retry.isError).toBeUndefined(); } finally { vi.useRealTimers(); } @@ -244,7 +333,12 @@ describe("runMonthlyReconciliationTool", () => { it("returns timeout error when batch phase exceeds batch_timeout_ms", async () => { vi.useFakeTimers(); try { - mocks.runMonthlyBatch.mockReturnValueOnce(new Promise(() => {})); + let resolveBatch: ((value: unknown) => void) | undefined; + mocks.runMonthlyBatch.mockReturnValueOnce( + new Promise((resolve) => { + resolveBatch = resolve; + }) + ); const resultPromise = runMonthlyReconciliationTool({ month: "2026-03", @@ -257,6 +351,19 @@ describe("runMonthlyReconciliationTool", () => { expect(responseText(result)).toContain( "Monthly reconciliation failed: Monthly batch execution timed out after 5ms" ); + + resolveBatch?.({ + status: "dry_run", + month: "2026-03", + creditType: undefined, + budgetUsdCents: 300, + plannedQuantity: "1.000000", + plannedCostMicro: 3_000_000n, + plannedCostDenom: "USDC", + message: "late completion after timeout", + }); + await Promise.resolve(); + await Promise.resolve(); } finally { vi.useRealTimers(); } From 12b0882c8b249e27125a2b3c54f9f47dced285ec Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:59:45 -0800 Subject: [PATCH 28/31] fix: surface reconciliation history write failures as warnings --- README.md | 1 + src/tools/monthly-batch-retirement.ts | 186 ++++++++++++++-------- tests/monthly-reconciliation-tool.test.ts | 75 +++++++++ 3 files changed, 192 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 9fe5db9..0ffd350 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ All-customer sync output now reports whether Stripe pagination was truncated by Live execution preflight also requires the latest record for that month/credit type to be a fresh `dry_run` (not older than the latest contribution), unless explicitly overridden. Reconciliation runs are also single-flight per month/credit type; concurrent overlapping requests are rejected until the active run completes. When a sync/batch timeout is hit, the run lock remains active until the timed-out phase fully settles, preventing overlapping retries from racing. +If reconciliation run-history persistence fails, execution continues but returns explicit warnings in tool output. **When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index dbab239..b6eb796 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -100,6 +100,23 @@ async function withTimeout( } } +function toErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function appendWarningsSection(lines: string[], warnings: string[]): void { + if (warnings.length === 0) { + return; + } + + lines.push( + "", + "### Warnings", + "", + ...warnings.map((warning) => `- ${warning}`) + ); +} + function toReconciliationSyncSummary( syncScope: SyncScope, result?: SubscriptionPoolSyncResult @@ -617,6 +634,7 @@ export async function runMonthlyReconciliationTool( const lockKey = reconciliationLockKey(input.month, input.creditType); if (!acquireReconciliationLock(lockKey)) { let blockedRunId: string | undefined; + const blockedWarnings: string[] = []; try { blockedRunId = ( await reconciliationHistory.recordBlockedRun({ @@ -626,8 +644,10 @@ export async function runMonthlyReconciliationTool( "A reconciliation run for this month/credit type is already in progress.", }) ).id; - } catch { - // Reconciliation execution should not fail if audit logging is unavailable. + } catch (error) { + blockedWarnings.push( + `Reconciliation run history write failed: ${toErrorMessage(error, "Unknown history write error")}` + ); } const lines: string[] = [ @@ -643,6 +663,7 @@ export async function runMonthlyReconciliationTool( "", "A reconciliation run for this month/credit type is already in progress. Wait for it to finish, then retry.", ]; + appendWarningsSection(lines, blockedWarnings); return { content: [{ type: "text" as const, text: lines.join("\n") }], isError: true, @@ -651,17 +672,42 @@ export async function runMonthlyReconciliationTool( let runId: string | undefined; let syncResult: SubscriptionPoolSyncResult | undefined; + const runWarnings: string[] = []; const timedOutPendingOperations: Promise[] = []; const trackTimedOutOperation = (pendingPromise: Promise) => { timedOutPendingOperations.push(pendingPromise.catch(() => undefined)); }; + const finishRun = async ( + finishInput: { + status: Exclude; + batchStatus: string; + sync?: ReconciliationRunSyncSummary; + message?: string; + error?: string; + }, + context: string + ) => { + if (!runId) { + return; + } + + try { + await reconciliationHistory.finishRun(runId, finishInput); + } catch (error) { + runWarnings.push( + `${context}: ${toErrorMessage(error, "Unknown history write error")}` + ); + } + }; try { try { runId = (await reconciliationHistory.startRun(runStartInput)).id; - } catch { - // Reconciliation execution should not fail if audit logging is unavailable. + } catch (error) { + runWarnings.push( + `Reconciliation run history start failed: ${toErrorMessage(error, "Unknown history start error")}` + ); } const syncTimeoutMs = resolveTimeoutMs( @@ -705,17 +751,15 @@ export async function runMonthlyReconciliationTool( syncResult?.truncated && !input.allowPartialSync ) { - if (runId) { - await reconciliationHistory - .finishRun(runId, { - status: "blocked", - batchStatus: "blocked_partial_sync", - sync: toReconciliationSyncSummary(syncScope, syncResult), - message: - "All-customer invoice sync was truncated by invoice_max_pages.", - }) - .catch(() => undefined); - } + await finishRun( + { + status: "blocked", + batchStatus: "blocked_partial_sync", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: "All-customer invoice sync was truncated by invoice_max_pages.", + }, + "Reconciliation run history finalize failed" + ); const lines: string[] = [ "## Monthly Reconciliation", @@ -734,6 +778,7 @@ export async function runMonthlyReconciliationTool( "Batch execution was skipped because all-customer invoice sync was truncated by `invoice_max_pages` and may be incomplete.", "Increase `invoice_max_pages` and rerun, or set `allow_partial_sync=true` to override (not recommended).", ]; + appendWarningsSection(lines, runWarnings); return { content: [{ type: "text" as const, text: lines.join("\n") }], @@ -755,17 +800,16 @@ export async function runMonthlyReconciliationTool( ]); if (latestExecution?.status !== "dry_run") { - if (runId) { - await reconciliationHistory - .finishRun(runId, { - status: "blocked", - batchStatus: "blocked_preflight", - sync: toReconciliationSyncSummary(syncScope, syncResult), - message: - "Latest execution is not dry_run, so live execution preflight was blocked.", - }) - .catch(() => undefined); - } + await finishRun( + { + status: "blocked", + batchStatus: "blocked_preflight", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: + "Latest execution is not dry_run, so live execution preflight was blocked.", + }, + "Reconciliation run history finalize failed" + ); const lines: string[] = [ "## Monthly Reconciliation", @@ -784,6 +828,7 @@ export async function runMonthlyReconciliationTool( `Live execution was blocked because the latest execution state is \`${latestExecution?.status || "none"}\`, not \`dry_run\`.`, "Run with `dry_run=true` first, then re-run with `dry_run=false`, or set `allow_execute_without_dry_run=true` to override.", ]; + appendWarningsSection(lines, runWarnings); return { content: [{ type: "text" as const, text: lines.join("\n") }], @@ -795,17 +840,15 @@ export async function runMonthlyReconciliationTool( monthSummary.lastContributionAt && latestExecution.executedAt.localeCompare(monthSummary.lastContributionAt) < 0 ) { - if (runId) { - await reconciliationHistory - .finishRun(runId, { - status: "blocked", - batchStatus: "blocked_preflight_stale_dry_run", - sync: toReconciliationSyncSummary(syncScope, syncResult), - message: - "Latest dry_run record is older than latest contribution.", - }) - .catch(() => undefined); - } + await finishRun( + { + status: "blocked", + batchStatus: "blocked_preflight_stale_dry_run", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: "Latest dry_run record is older than latest contribution.", + }, + "Reconciliation run history finalize failed" + ); const lines: string[] = [ "## Monthly Reconciliation", @@ -824,6 +867,7 @@ export async function runMonthlyReconciliationTool( `Live execution was blocked because the latest \`dry_run\` (${latestExecution.executedAt}) is older than the latest contribution (${monthSummary.lastContributionAt}).`, "Run a fresh `dry_run=true` and then re-run live execution, or set `allow_execute_without_dry_run=true` to override.", ]; + appendWarningsSection(lines, runWarnings); return { content: [{ type: "text" as const, text: lines.join("\n") }], @@ -833,16 +877,15 @@ export async function runMonthlyReconciliationTool( } if (input.preflightOnly) { - if (runId) { - await reconciliationHistory - .finishRun(runId, { - status: "completed", - batchStatus: "preflight_ok", - sync: toReconciliationSyncSummary(syncScope, syncResult), - message: "Preflight-only mode completed successfully.", - }) - .catch(() => undefined); - } + await finishRun( + { + status: "completed", + batchStatus: "preflight_ok", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: "Preflight-only mode completed successfully.", + }, + "Reconciliation run history finalize failed" + ); const lines: string[] = [ "## Monthly Reconciliation", @@ -861,6 +904,7 @@ export async function runMonthlyReconciliationTool( "", "Preflight checks passed. No batch execution was performed because `preflight_only=true`.", ]; + appendWarningsSection(lines, runWarnings); return { content: [{ type: "text" as const, text: lines.join("\n") }], @@ -885,17 +929,16 @@ export async function runMonthlyReconciliationTool( const finalStatus: Exclude = batchResult.status === "failed" ? "failed" : "completed"; - if (runId) { - await reconciliationHistory - .finishRun(runId, { - status: finalStatus, - batchStatus: batchResult.status, - sync: toReconciliationSyncSummary(syncScope, syncResult), - message: batchResult.message, - error: batchResult.status === "failed" ? batchResult.message : undefined, - }) - .catch(() => undefined); - } + await finishRun( + { + status: finalStatus, + batchStatus: batchResult.status, + sync: toReconciliationSyncSummary(syncScope, syncResult), + message: batchResult.message, + error: batchResult.status === "failed" ? batchResult.message : undefined, + }, + "Reconciliation run history finalize failed" + ); const lines: string[] = [ "## Monthly Reconciliation", @@ -915,6 +958,7 @@ export async function runMonthlyReconciliationTool( "", renderMonthlyBatchResult(batchResult, "Monthly Batch Retirement"), ]; + appendWarningsSection(lines, runWarnings); return { content: [{ type: "text" as const, text: lines.join("\n") }], @@ -923,23 +967,25 @@ export async function runMonthlyReconciliationTool( const message = error instanceof Error ? error.message : "Unknown reconciliation error"; - if (runId) { - await reconciliationHistory - .finishRun(runId, { - status: "failed", - batchStatus: "error", - sync: toReconciliationSyncSummary(syncScope, syncResult), - message, - error: message, - }) - .catch(() => undefined); - } + await finishRun( + { + status: "failed", + batchStatus: "error", + sync: toReconciliationSyncSummary(syncScope, syncResult), + message, + error: message, + }, + "Reconciliation run history finalize failed" + ); + + const lines: string[] = [`Monthly reconciliation failed: ${message}`]; + appendWarningsSection(lines, runWarnings); return { content: [ { type: "text" as const, - text: `Monthly reconciliation failed: ${message}`, + text: lines.join("\n"), }, ], isError: true, diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index ede33e1..4ac8d88 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -219,6 +219,81 @@ describe("runMonthlyReconciliationTool", () => { expect(mocks.runMonthlyBatch).not.toHaveBeenCalled(); }); + it("surfaces warning when reconciliation history start fails", async () => { + mocks.startRun.mockRejectedValueOnce(new Error("history store offline")); + + const result = await runMonthlyReconciliationTool({ month: "2026-03" }); + const text = responseText(result); + + expect(result.isError).toBeUndefined(); + expect(text).toContain("| Batch Status | dry_run |"); + expect(text).toContain("### Warnings"); + expect(text).toContain( + "Reconciliation run history start failed: history store offline" + ); + }); + + it("surfaces warning when reconciliation history finalize fails", async () => { + mocks.finishRun.mockRejectedValueOnce(new Error("history finalize failed")); + + const result = await runMonthlyReconciliationTool({ month: "2026-03" }); + const text = responseText(result); + + expect(result.isError).toBeUndefined(); + expect(text).toContain("| Batch Status | dry_run |"); + expect(text).toContain("### Warnings"); + expect(text).toContain( + "Reconciliation run history finalize failed: history finalize failed" + ); + }); + + it("surfaces warning when blocked-run history write fails", async () => { + let resolveFirstSync: + | ((value: { + scope: "all_customers"; + month: string; + fetchedInvoiceCount: number; + processedInvoiceCount: number; + syncedCount: number; + duplicateCount: number; + skippedCount: number; + records: []; + }) => void) + | undefined; + + mocks.syncPaidInvoices.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirstSync = resolve; + }) + ); + mocks.recordBlockedRun.mockRejectedValueOnce(new Error("blocked audit failed")); + + const firstRun = runMonthlyReconciliationTool({ month: "2026-03" }); + await Promise.resolve(); + + const blocked = await runMonthlyReconciliationTool({ month: "2026-03" }); + const blockedText = responseText(blocked); + + expect(blocked.isError).toBe(true); + expect(blockedText).toContain("| Batch Status | blocked_in_progress |"); + expect(blockedText).toContain("### Warnings"); + expect(blockedText).toContain( + "Reconciliation run history write failed: blocked audit failed" + ); + + resolveFirstSync?.({ + scope: "all_customers", + month: "2026-03", + fetchedInvoiceCount: 0, + processedInvoiceCount: 0, + syncedCount: 0, + duplicateCount: 0, + skippedCount: 0, + records: [], + }); + await firstRun; + }); + it("returns timeout error when contribution sync exceeds sync_timeout_ms", async () => { vi.useFakeTimers(); try { From 2314b5b38898dc88a22e75ae7dbdd97aadcab057 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:14:29 -0800 Subject: [PATCH 29/31] feat: harden reconciliation lock and history store concurrency --- .gitignore | 1 + README.md | 11 + .../reconciliation-run-history/service.ts | 125 ++++++----- .../reconciliation-run-history/store.ts | 145 ++++++++++++- .../reconciliation-run-history/types.ts | 3 + .../reconciliation-run-lock/service.ts | 200 ++++++++++++++++++ src/tools/monthly-batch-retirement.ts | 34 ++- tests/monthly-reconciliation-tool.test.ts | 30 +++ .../reconciliation-run-history-store.test.ts | 65 ++++++ tests/reconciliation-run-lock-service.test.ts | 85 ++++++++ 10 files changed, 623 insertions(+), 76 deletions(-) create mode 100644 src/services/reconciliation-run-lock/service.ts create mode 100644 tests/reconciliation-run-history-store.test.ts create mode 100644 tests/reconciliation-run-lock-service.test.ts diff --git a/.gitignore b/.gitignore index 2b08a6f..1fdf796 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .DS_Store .vscode/ .idea/ +data/ diff --git a/README.md b/README.md index 0ffd350..b750bd1 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ All-customer sync output now reports whether Stripe pagination was truncated by Live execution preflight also requires the latest record for that month/credit type to be a fresh `dry_run` (not older than the latest contribution), unless explicitly overridden. Reconciliation runs are also single-flight per month/credit type; concurrent overlapping requests are rejected until the active run completes. When a sync/batch timeout is hit, the run lock remains active until the timed-out phase fully settles, preventing overlapping retries from racing. +Single-flight locking is now file-backed (`REGEN_RECONCILIATION_LOCKS_DIR`) so overlap protection survives process restarts and works across multiple workers sharing the same filesystem. If reconciliation run-history persistence fails, execution continues but returns explicit warnings in tool output. **When it's used:** Monthly close/reconciliation where operators want deterministic “sync then run batch” execution with one tool invocation. @@ -491,6 +492,16 @@ export STRIPE_PRICE_ID_IMPACT=price_... export REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json # optional custom history path for monthly batch executions export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json +# optional custom history path for monthly reconciliation orchestration runs +export REGEN_RECONCILIATION_RUNS_PATH=./data/monthly-reconciliation-runs.json +# optional lock directory for reconciliation single-flight protection +export REGEN_RECONCILIATION_LOCKS_DIR=./data/monthly-reconciliation-locks +# optional stale-lock TTL in milliseconds (default 1800000) +export REGEN_RECONCILIATION_LOCK_TTL_MS=1800000 +# optional reconciliation run-history write-lock tuning +export REGEN_RECONCILIATION_RUNS_LOCK_WAIT_MS=10000 +export REGEN_RECONCILIATION_RUNS_LOCK_RETRY_MS=25 +export REGEN_RECONCILIATION_RUNS_LOCK_STALE_MS=60000 # optional subscriber certificate frontend settings export REGEN_CERTIFICATE_BASE_URL=https://regen.network/certificate export REGEN_CERTIFICATE_OUTPUT_DIR=./data/certificates diff --git a/src/services/reconciliation-run-history/service.ts b/src/services/reconciliation-run-history/service.ts index 4595089..89fd76c 100644 --- a/src/services/reconciliation-run-history/service.ts +++ b/src/services/reconciliation-run-history/service.ts @@ -26,6 +26,19 @@ export class ReconciliationRunHistoryService { private readonly store: ReconciliationRunStore = new JsonFileReconciliationRunStore() ) {} + private async mutateState( + updater: (state: { version: 1; runs: ReconciliationRunRecord[] }) => T | Promise + ): Promise { + if (this.store.withExclusiveState) { + return this.store.withExclusiveState(updater); + } + + const state = await this.store.readState(); + const result = await updater(state); + await this.store.writeState(state); + return result; + } + async startRun( input: StartReconciliationRunInput ): Promise { @@ -33,46 +46,46 @@ export class ReconciliationRunHistoryService { throw new Error("month must be in YYYY-MM format"); } - const state = await this.store.readState(); - const record: ReconciliationRunRecord = { - id: `reconcile_${randomUUID()}`, - month: input.month, - creditType: input.creditType, - syncScope: input.syncScope, - executionMode: input.executionMode, - preflightOnly: input.preflightOnly, - force: input.force, - status: "in_progress", - batchStatus: "in_progress", - startedAt: new Date().toISOString(), - }; - - state.runs.push(record); - sortByStartedAtAsc(state.runs); - await this.store.writeState(state); - return record; + return this.mutateState((state) => { + const record: ReconciliationRunRecord = { + id: `reconcile_${randomUUID()}`, + month: input.month, + creditType: input.creditType, + syncScope: input.syncScope, + executionMode: input.executionMode, + preflightOnly: input.preflightOnly, + force: input.force, + status: "in_progress", + batchStatus: "in_progress", + startedAt: new Date().toISOString(), + }; + + state.runs.push(record); + sortByStartedAtAsc(state.runs); + return record; + }); } async finishRun( runId: string, input: FinishReconciliationRunInput ): Promise { - const state = await this.store.readState(); - const record = state.runs.find((item) => item.id === runId); - if (!record) { - throw new Error(`Unknown reconciliation run id: ${runId}`); - } - - record.status = input.status; - record.batchStatus = input.batchStatus; - record.sync = input.sync; - record.message = input.message; - record.error = input.error; - record.finishedAt = new Date().toISOString(); - - sortByStartedAtAsc(state.runs); - await this.store.writeState(state); - return record; + return this.mutateState((state) => { + const record = state.runs.find((item) => item.id === runId); + if (!record) { + throw new Error(`Unknown reconciliation run id: ${runId}`); + } + + record.status = input.status; + record.batchStatus = input.batchStatus; + record.sync = input.sync; + record.message = input.message; + record.error = input.error; + record.finishedAt = new Date().toISOString(); + + sortByStartedAtAsc(state.runs); + return record; + }); } async recordBlockedRun( @@ -82,28 +95,28 @@ export class ReconciliationRunHistoryService { throw new Error("month must be in YYYY-MM format"); } - const state = await this.store.readState(); - const now = new Date().toISOString(); - const record: ReconciliationRunRecord = { - id: `reconcile_${randomUUID()}`, - month: input.month, - creditType: input.creditType, - syncScope: input.syncScope, - executionMode: input.executionMode, - preflightOnly: input.preflightOnly, - force: input.force, - status: "blocked", - batchStatus: input.batchStatus, - sync: input.sync, - message: input.message, - startedAt: now, - finishedAt: now, - }; - - state.runs.push(record); - sortByStartedAtAsc(state.runs); - await this.store.writeState(state); - return record; + return this.mutateState((state) => { + const now = new Date().toISOString(); + const record: ReconciliationRunRecord = { + id: `reconcile_${randomUUID()}`, + month: input.month, + creditType: input.creditType, + syncScope: input.syncScope, + executionMode: input.executionMode, + preflightOnly: input.preflightOnly, + force: input.force, + status: "blocked", + batchStatus: input.batchStatus, + sync: input.sync, + message: input.message, + startedAt: now, + finishedAt: now, + }; + + state.runs.push(record); + sortByStartedAtAsc(state.runs); + return record; + }); } async getHistory( diff --git a/src/services/reconciliation-run-history/store.ts b/src/services/reconciliation-run-history/store.ts index ab215b4..6914724 100644 --- a/src/services/reconciliation-run-history/store.ts +++ b/src/services/reconciliation-run-history/store.ts @@ -1,9 +1,20 @@ -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { + mkdir, + open, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import type { ReconciliationRunState, ReconciliationRunStore } from "./types.js"; const DEFAULT_RELATIVE_RECONCILIATION_RUNS_PATH = "data/monthly-reconciliation-runs.json"; +const DEFAULT_LOCK_WAIT_MS = 10_000; +const DEFAULT_LOCK_RETRY_MS = 25; +const DEFAULT_LOCK_STALE_MS = 60_000; function getDefaultState(): ReconciliationRunState { return { version: 1, runs: [] }; @@ -23,11 +34,126 @@ export function getDefaultReconciliationRunsPath(): string { return path.resolve(process.cwd(), DEFAULT_RELATIVE_RECONCILIATION_RUNS_PATH); } +function resolvePositiveInteger(envName: string, fallback: number): number { + const raw = process.env[envName]?.trim(); + if (!raw) { + return fallback; + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + export class JsonFileReconciliationRunStore implements ReconciliationRunStore { constructor( - private readonly filePath: string = getDefaultReconciliationRunsPath() + private readonly filePath: string = getDefaultReconciliationRunsPath(), + private readonly lockWaitMs: number = resolvePositiveInteger( + "REGEN_RECONCILIATION_RUNS_LOCK_WAIT_MS", + DEFAULT_LOCK_WAIT_MS + ), + private readonly lockRetryMs: number = resolvePositiveInteger( + "REGEN_RECONCILIATION_RUNS_LOCK_RETRY_MS", + DEFAULT_LOCK_RETRY_MS + ), + private readonly lockStaleMs: number = resolvePositiveInteger( + "REGEN_RECONCILIATION_RUNS_LOCK_STALE_MS", + DEFAULT_LOCK_STALE_MS + ) ) {} + private lockFilePath(): string { + return `${this.filePath}.lock`; + } + + private async tryClearStaleLock(): Promise { + const lockPath = this.lockFilePath(); + + try { + const lockStat = await stat(lockPath); + if (Date.now() - lockStat.mtimeMs < this.lockStaleMs) { + return false; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + return false; + } + + try { + await unlink(lockPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + return false; + } + } + + private async acquireLock(): Promise { + const lockPath = this.lockFilePath(); + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true }); + const startedAt = Date.now(); + + while (true) { + try { + const handle = await open(lockPath, "wx"); + try { + await handle.writeFile( + JSON.stringify( + { pid: process.pid, acquiredAt: new Date().toISOString() }, + null, + 2 + ), + "utf8" + ); + } finally { + await handle.close(); + } + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + throw error; + } + } + + if (await this.tryClearStaleLock()) { + continue; + } + + if (Date.now() - startedAt >= this.lockWaitMs) { + throw new Error( + `Timed out acquiring reconciliation run history lock after ${this.lockWaitMs}ms` + ); + } + + await wait(this.lockRetryMs); + } + } + + private async releaseLock(): Promise { + try { + await unlink(this.lockFilePath()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + async readState(): Promise { try { const raw = await readFile(this.filePath, "utf8"); @@ -52,4 +178,19 @@ export class JsonFileReconciliationRunStore implements ReconciliationRunStore { await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8"); await rename(tempPath, this.filePath); } + + async withExclusiveState( + updater: (state: ReconciliationRunState) => T | Promise + ): Promise { + await this.acquireLock(); + + try { + const state = await this.readState(); + const result = await updater(state); + await this.writeState(state); + return result; + } finally { + await this.releaseLock(); + } + } } diff --git a/src/services/reconciliation-run-history/types.ts b/src/services/reconciliation-run-history/types.ts index 26817d8..4cb1418 100644 --- a/src/services/reconciliation-run-history/types.ts +++ b/src/services/reconciliation-run-history/types.ts @@ -44,6 +44,9 @@ export interface ReconciliationRunState { export interface ReconciliationRunStore { readState(): Promise; writeState(state: ReconciliationRunState): Promise; + withExclusiveState?( + updater: (state: ReconciliationRunState) => T | Promise + ): Promise; } export interface StartReconciliationRunInput { diff --git a/src/services/reconciliation-run-lock/service.ts b/src/services/reconciliation-run-lock/service.ts new file mode 100644 index 0000000..d4e0967 --- /dev/null +++ b/src/services/reconciliation-run-lock/service.ts @@ -0,0 +1,200 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, open, readFile, unlink } from "node:fs/promises"; +import path from "node:path"; + +const DEFAULT_RELATIVE_RECONCILIATION_LOCK_DIR = + "data/monthly-reconciliation-locks"; +const DEFAULT_RECONCILIATION_LOCK_TTL_MS = 30 * 60 * 1000; + +interface ReconciliationLockMetadata { + lockKey: string; + token: string; + acquiredAt: string; + expiresAt: string; + pid: number; +} + +export interface ReconciliationRunLock { + key: string; + token: string; + release(): Promise; +} + +export interface ReconciliationRunLockServiceOptions { + lockDirectory?: string; + lockTtlMs?: number; +} + +export function getDefaultReconciliationLockDirectory(): string { + const configured = process.env.REGEN_RECONCILIATION_LOCKS_DIR?.trim(); + if (configured) { + return path.resolve(configured); + } + + return path.resolve(process.cwd(), DEFAULT_RELATIVE_RECONCILIATION_LOCK_DIR); +} + +export function getDefaultReconciliationLockTtlMs(): number { + const configured = process.env.REGEN_RECONCILIATION_LOCK_TTL_MS?.trim(); + if (!configured) { + return DEFAULT_RECONCILIATION_LOCK_TTL_MS; + } + + const parsed = Number.parseInt(configured, 10); + if (!Number.isInteger(parsed) || parsed < 1000) { + return DEFAULT_RECONCILIATION_LOCK_TTL_MS; + } + + return parsed; +} + +function lockFileNameForKey(lockKey: string): string { + const digest = createHash("sha256").update(lockKey).digest("hex"); + return `${digest}.lock`; +} + +export function getReconciliationLockFilePath( + lockDirectory: string, + lockKey: string +): string { + return path.resolve(lockDirectory, lockFileNameForKey(lockKey)); +} + +function isMetadata(value: unknown): value is ReconciliationLockMetadata { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.lockKey === "string" && + typeof candidate.token === "string" && + typeof candidate.acquiredAt === "string" && + typeof candidate.expiresAt === "string" && + typeof candidate.pid === "number" + ); +} + +export class ReconciliationRunLockService { + private readonly lockDirectory: string; + private readonly lockTtlMs: number; + + constructor(options: ReconciliationRunLockServiceOptions = {}) { + this.lockDirectory = + options.lockDirectory || getDefaultReconciliationLockDirectory(); + this.lockTtlMs = options.lockTtlMs || getDefaultReconciliationLockTtlMs(); + } + + private async readMetadata( + lockPath: string + ): Promise { + try { + const raw = await readFile(lockPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isMetadata(parsed)) { + return undefined; + } + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + return undefined; + } + } + + private isExpired(metadata: ReconciliationLockMetadata, nowMs: number): boolean { + const expiresAtMs = Date.parse(metadata.expiresAt); + if (Number.isFinite(expiresAtMs)) { + return expiresAtMs <= nowMs; + } + + const acquiredAtMs = Date.parse(metadata.acquiredAt); + if (!Number.isFinite(acquiredAtMs)) { + return true; + } + + return acquiredAtMs + this.lockTtlMs <= nowMs; + } + + private async clearLockFile(lockPath: string): Promise { + try { + await unlink(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + + private async tryWriteLock( + lockPath: string, + metadata: ReconciliationLockMetadata + ): Promise { + try { + const handle = await open(lockPath, "wx"); + try { + await handle.writeFile(JSON.stringify(metadata, null, 2), "utf8"); + } finally { + await handle.close(); + } + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return false; + } + throw error; + } + } + + private async release(lockPath: string, token: string): Promise { + const metadata = await this.readMetadata(lockPath); + if (!metadata || metadata.token !== token) { + return; + } + + await this.clearLockFile(lockPath); + } + + async acquire(lockKey: string): Promise { + await mkdir(this.lockDirectory, { recursive: true }); + const lockPath = getReconciliationLockFilePath(this.lockDirectory, lockKey); + + for (let attempts = 0; attempts < 2; attempts += 1) { + const token = randomUUID(); + const now = Date.now(); + const metadata: ReconciliationLockMetadata = { + lockKey, + token, + pid: process.pid, + acquiredAt: new Date(now).toISOString(), + expiresAt: new Date(now + this.lockTtlMs).toISOString(), + }; + + const acquired = await this.tryWriteLock(lockPath, metadata); + if (acquired) { + let released = false; + return { + key: lockKey, + token, + release: async () => { + if (released) { + return; + } + released = true; + await this.release(lockPath, token); + }, + }; + } + + const existing = await this.readMetadata(lockPath); + if (existing && !this.isExpired(existing, Date.now())) { + return null; + } + + await this.clearLockFile(lockPath); + } + + return null; + } +} diff --git a/src/tools/monthly-batch-retirement.ts b/src/tools/monthly-batch-retirement.ts index b6eb796..1dc3460 100644 --- a/src/tools/monthly-batch-retirement.ts +++ b/src/tools/monthly-batch-retirement.ts @@ -11,6 +11,7 @@ import { import { loadConfig } from "../config.js"; import { calculateProtocolFee } from "../services/batch-retirement/fee.js"; import { ReconciliationRunHistoryService } from "../services/reconciliation-run-history/service.js"; +import { ReconciliationRunLockService } from "../services/reconciliation-run-lock/service.js"; import type { ReconciliationRunStatus, ReconciliationRunSyncSummary, @@ -20,8 +21,8 @@ const executor = new MonthlyBatchRetirementExecutor(); const poolAccounting = new PoolAccountingService(); const poolSync = new SubscriptionPoolSyncService(); const reconciliationHistory = new ReconciliationRunHistoryService(); +const reconciliationRunLockService = new ReconciliationRunLockService(); const MONTH_REGEX = /^\d{4}-\d{2}$/; -const activeReconciliationLocks = new Set(); type SyncScope = "none" | "customer" | "all_customers"; @@ -53,18 +54,6 @@ function reconciliationLockKey( return `${month}:${creditType || "all"}`; } -function acquireReconciliationLock(lockKey: string): boolean { - if (activeReconciliationLocks.has(lockKey)) { - return false; - } - activeReconciliationLocks.add(lockKey); - return true; -} - -function releaseReconciliationLock(lockKey: string): void { - activeReconciliationLocks.delete(lockKey); -} - function resolveTimeoutMs(value: number | undefined, label: string): number | undefined { if (typeof value === "undefined") return undefined; if (!Number.isInteger(value) || value < 1 || value > 300_000) { @@ -632,7 +621,8 @@ export async function runMonthlyReconciliationTool( force: Boolean(input.force), } as const; const lockKey = reconciliationLockKey(input.month, input.creditType); - if (!acquireReconciliationLock(lockKey)) { + const acquiredLock = await reconciliationRunLockService.acquire(lockKey); + if (!acquiredLock) { let blockedRunId: string | undefined; const blockedWarnings: string[] = []; try { @@ -991,12 +981,20 @@ export async function runMonthlyReconciliationTool( isError: true, }; } finally { + const releaseLock = async () => { + try { + await acquiredLock.release(); + } catch { + // lock release best-effort; stale TTL recovery still prevents deadlock + } + }; + if (timedOutPendingOperations.length > 0) { - void Promise.allSettled(timedOutPendingOperations).finally(() => { - releaseReconciliationLock(lockKey); - }); + void Promise.allSettled(timedOutPendingOperations).then(() => + releaseLock() + ); } else { - releaseReconciliationLock(lockKey); + await releaseLock(); } } } diff --git a/tests/monthly-reconciliation-tool.test.ts b/tests/monthly-reconciliation-tool.test.ts index 4ac8d88..cff9e4e 100644 --- a/tests/monthly-reconciliation-tool.test.ts +++ b/tests/monthly-reconciliation-tool.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ getExecutionHistory: vi.fn(), syncPaidInvoices: vi.fn(), getMonthlySummary: vi.fn(), + acquireLock: vi.fn(), startRun: vi.fn(), finishRun: vi.fn(), recordBlockedRun: vi.fn(), @@ -39,6 +40,14 @@ vi.mock("../src/services/pool-accounting/service.js", () => ({ }, })); +vi.mock("../src/services/reconciliation-run-lock/service.js", () => ({ + ReconciliationRunLockService: class { + acquire(lockKey: string) { + return mocks.acquireLock(lockKey); + } + }, +})); + vi.mock("../src/services/reconciliation-run-history/service.js", () => ({ ReconciliationRunHistoryService: class { startRun(input: unknown) { @@ -71,6 +80,27 @@ function responseText(result: { content: Array<{ type: "text"; text: string }> } describe("runMonthlyReconciliationTool", () => { beforeEach(() => { vi.resetAllMocks(); + const activeLocks = new Set(); + + mocks.acquireLock.mockImplementation(async (lockKey: string) => { + if (activeLocks.has(lockKey)) { + return null; + } + + activeLocks.add(lockKey); + let released = false; + return { + key: lockKey, + token: `token-${lockKey}`, + release: async () => { + if (released) { + return; + } + released = true; + activeLocks.delete(lockKey); + }, + }; + }); mocks.syncPaidInvoices.mockResolvedValue({ scope: "all_customers", diff --git a/tests/reconciliation-run-history-store.test.ts b/tests/reconciliation-run-history-store.test.ts new file mode 100644 index 0000000..8f5c106 --- /dev/null +++ b/tests/reconciliation-run-history-store.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { JsonFileReconciliationRunStore } from "../src/services/reconciliation-run-history/store.js"; +import type { ReconciliationRunRecord } from "../src/services/reconciliation-run-history/types.js"; + +const tempDirectories: string[] = []; + +async function createStore() { + const dir = await mkdtemp(path.join(os.tmpdir(), "reconciliation-runs-store-")); + tempDirectories.push(dir); + return new JsonFileReconciliationRunStore( + path.join(dir, "monthly-reconciliation-runs.json"), + 5_000, + 5, + 30_000 + ); +} + +function buildRecord(index: number): ReconciliationRunRecord { + const startedAt = `2026-03-01T00:00:${String(index).padStart(2, "0")}.000Z`; + return { + id: `reconcile_${index}`, + month: "2026-03", + syncScope: "all_customers", + executionMode: "dry_run", + preflightOnly: false, + force: false, + status: "completed", + batchStatus: "dry_run", + startedAt, + finishedAt: startedAt, + }; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((dir) => + rm(dir, { recursive: true, force: true }) + ) + ); +}); + +describe("JsonFileReconciliationRunStore", () => { + it("serializes concurrent withExclusiveState writes without dropping records", async () => { + const store = await createStore(); + const records = Array.from({ length: 20 }, (_, index) => + buildRecord(index + 1) + ); + + await Promise.all( + records.map((record) => + store.withExclusiveState!(async (state) => { + await new Promise((resolve) => setTimeout(resolve, 2)); + state.runs.push(record); + }) + ) + ); + + const state = await store.readState(); + expect(state.runs).toHaveLength(20); + expect(new Set(state.runs.map((item) => item.id)).size).toBe(20); + }); +}); diff --git a/tests/reconciliation-run-lock-service.test.ts b/tests/reconciliation-run-lock-service.test.ts new file mode 100644 index 0000000..c152b32 --- /dev/null +++ b/tests/reconciliation-run-lock-service.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getReconciliationLockFilePath, + ReconciliationRunLockService, +} from "../src/services/reconciliation-run-lock/service.js"; + +const tempDirectories: string[] = []; + +async function createTempDirectory(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "reconciliation-lock-")); + tempDirectories.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((dir) => + rm(dir, { recursive: true, force: true }) + ) + ); +}); + +describe("ReconciliationRunLockService", () => { + it("acquires, blocks concurrent acquire, and releases cleanly", async () => { + const dir = await createTempDirectory(); + const service = new ReconciliationRunLockService({ + lockDirectory: dir, + lockTtlMs: 60_000, + }); + + const first = await service.acquire("2026-03:all"); + const second = await service.acquire("2026-03:all"); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + + await first?.release(); + + const third = await service.acquire("2026-03:all"); + expect(third).not.toBeNull(); + await third?.release(); + }); + + it("reclaims stale lock files and prevents stale handles from releasing new locks", async () => { + const dir = await createTempDirectory(); + const service = new ReconciliationRunLockService({ + lockDirectory: dir, + lockTtlMs: 1_000, + }); + const lockKey = "2026-03:carbon"; + + const first = await service.acquire(lockKey); + expect(first).not.toBeNull(); + + const lockPath = getReconciliationLockFilePath(dir, lockKey); + await writeFile( + lockPath, + JSON.stringify( + { + lockKey, + token: "expired-token", + pid: 123, + acquiredAt: "2000-01-01T00:00:00.000Z", + expiresAt: "2000-01-01T00:00:01.000Z", + }, + null, + 2 + ), + "utf8" + ); + + const second = await service.acquire(lockKey); + expect(second).not.toBeNull(); + + await first?.release(); + + const blocked = await service.acquire(lockKey); + expect(blocked).toBeNull(); + + await second?.release(); + }); +}); From 7cc23834bf764f8be8ed69d01ae16463c87677f8 Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:32:35 -0800 Subject: [PATCH 30/31] feat: harden pool and batch stores with exclusive state writes --- .env.example | 18 +++ README.md | 8 ++ src/services/batch-retirement/executor.ts | 22 +++- src/services/batch-retirement/store.ts | 145 ++++++++++++++++++++- src/services/batch-retirement/types.ts | 3 + src/services/pool-accounting/service.ts | 114 +++++++++-------- src/services/pool-accounting/store.ts | 147 +++++++++++++++++++++- src/services/pool-accounting/types.ts | 3 + tests/batch-execution-store.test.ts | 64 ++++++++++ tests/monthly-batch-executor.test.ts | 9 ++ tests/pool-accounting-store.test.ts | 61 +++++++++ tests/pool-accounting.test.ts | 27 ++++ 12 files changed, 563 insertions(+), 58 deletions(-) create mode 100644 tests/batch-execution-store.test.ts create mode 100644 tests/pool-accounting-store.test.ts diff --git a/.env.example b/.env.example index 5681c18..49836bc 100644 --- a/.env.example +++ b/.env.example @@ -90,9 +90,27 @@ REGEN_DEFAULT_JURISDICTION=US # Optional: pool accounting ledger file path (default: ./data/pool-accounting-ledger.json) # REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json +# Optional: pool accounting store lock tuning (defaults shown) +# REGEN_POOL_ACCOUNTING_LOCK_WAIT_MS=10000 +# REGEN_POOL_ACCOUNTING_LOCK_RETRY_MS=25 +# REGEN_POOL_ACCOUNTING_LOCK_STALE_MS=60000 # Optional: monthly batch execution history file path (default: ./data/monthly-batch-executions.json) # REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json +# Optional: monthly batch execution store lock tuning (defaults shown) +# REGEN_BATCH_EXECUTIONS_LOCK_WAIT_MS=10000 +# REGEN_BATCH_EXECUTIONS_LOCK_RETRY_MS=25 +# REGEN_BATCH_EXECUTIONS_LOCK_STALE_MS=60000 + +# Optional: monthly reconciliation orchestration history file path (default: ./data/monthly-reconciliation-runs.json) +# REGEN_RECONCILIATION_RUNS_PATH=./data/monthly-reconciliation-runs.json +# Optional: reconciliation single-flight lock directory + stale-lock TTL +# REGEN_RECONCILIATION_LOCKS_DIR=./data/monthly-reconciliation-locks +# REGEN_RECONCILIATION_LOCK_TTL_MS=1800000 +# Optional: monthly reconciliation history store lock tuning (defaults shown) +# REGEN_RECONCILIATION_RUNS_LOCK_WAIT_MS=10000 +# REGEN_RECONCILIATION_RUNS_LOCK_RETRY_MS=25 +# REGEN_RECONCILIATION_RUNS_LOCK_STALE_MS=60000 # Optional: subscriber certificate frontend settings # Base URL prefix returned by publish_subscriber_certificate_page diff --git a/README.md b/README.md index b750bd1..0cb7325 100644 --- a/README.md +++ b/README.md @@ -490,8 +490,16 @@ export STRIPE_PRICE_ID_GROWTH=price_... export STRIPE_PRICE_ID_IMPACT=price_... # optional custom ledger path for pool accounting service export REGEN_POOL_ACCOUNTING_PATH=./data/pool-accounting-ledger.json +# optional pool accounting store lock tuning +export REGEN_POOL_ACCOUNTING_LOCK_WAIT_MS=10000 +export REGEN_POOL_ACCOUNTING_LOCK_RETRY_MS=25 +export REGEN_POOL_ACCOUNTING_LOCK_STALE_MS=60000 # optional custom history path for monthly batch executions export REGEN_BATCH_EXECUTIONS_PATH=./data/monthly-batch-executions.json +# optional batch execution store lock tuning +export REGEN_BATCH_EXECUTIONS_LOCK_WAIT_MS=10000 +export REGEN_BATCH_EXECUTIONS_LOCK_RETRY_MS=25 +export REGEN_BATCH_EXECUTIONS_LOCK_STALE_MS=60000 # optional custom history path for monthly reconciliation orchestration runs export REGEN_RECONCILIATION_RUNS_PATH=./data/monthly-reconciliation-runs.json # optional lock directory for reconciliation single-flight protection diff --git a/src/services/batch-retirement/executor.ts b/src/services/batch-retirement/executor.ts index c0647a0..c6b56bc 100644 --- a/src/services/batch-retirement/executor.ts +++ b/src/services/batch-retirement/executor.ts @@ -20,6 +20,7 @@ import type { BatchCreditMixPolicy, BatchExecutionHistoryQuery, BatchExecutionRecord, + BatchExecutionState, BatchExecutionStore, BudgetOrderSelection, RunMonthlyBatchInput, @@ -132,6 +133,19 @@ export class MonthlyBatchRetirementExecutor { }; } + private async mutateExecutionState( + updater: (state: BatchExecutionState) => T | Promise + ): Promise { + if (this.deps.executionStore.withExclusiveState) { + return this.deps.executionStore.withExclusiveState(updater); + } + + const state = await this.deps.executionStore.readState(); + const result = await updater(state); + await this.deps.executionStore.writeState(state); + return result; + } + private async hasSuccessfulExecution( month: string, creditType?: "carbon" | "biodiversity" @@ -146,10 +160,10 @@ export class MonthlyBatchRetirementExecutor { } private async appendExecution(record: BatchExecutionRecord): Promise { - const state = await this.deps.executionStore.readState(); - state.executions.push(record); - state.executions.sort((a, b) => a.executedAt.localeCompare(b.executedAt)); - await this.deps.executionStore.writeState(state); + await this.mutateExecutionState((state) => { + state.executions.push(record); + state.executions.sort((a, b) => a.executedAt.localeCompare(b.executedAt)); + }); } async getExecutionHistory( diff --git a/src/services/batch-retirement/store.ts b/src/services/batch-retirement/store.ts index a08759f..5992ce3 100644 --- a/src/services/batch-retirement/store.ts +++ b/src/services/batch-retirement/store.ts @@ -1,4 +1,12 @@ -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { + mkdir, + open, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import type { BatchExecutionState, @@ -6,6 +14,9 @@ import type { } from "./types.js"; const DEFAULT_RELATIVE_EXECUTIONS_PATH = "data/monthly-batch-executions.json"; +const DEFAULT_LOCK_WAIT_MS = 10_000; +const DEFAULT_LOCK_RETRY_MS = 25; +const DEFAULT_LOCK_STALE_MS = 60_000; function getDefaultState(): BatchExecutionState { return { version: 1, executions: [] }; @@ -25,11 +36,126 @@ export function getDefaultBatchExecutionsPath(): string { return path.resolve(process.cwd(), DEFAULT_RELATIVE_EXECUTIONS_PATH); } +function resolvePositiveInteger(envName: string, fallback: number): number { + const raw = process.env[envName]?.trim(); + if (!raw) { + return fallback; + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + export class JsonFileBatchExecutionStore implements BatchExecutionStore { constructor( - private readonly filePath: string = getDefaultBatchExecutionsPath() + private readonly filePath: string = getDefaultBatchExecutionsPath(), + private readonly lockWaitMs: number = resolvePositiveInteger( + "REGEN_BATCH_EXECUTIONS_LOCK_WAIT_MS", + DEFAULT_LOCK_WAIT_MS + ), + private readonly lockRetryMs: number = resolvePositiveInteger( + "REGEN_BATCH_EXECUTIONS_LOCK_RETRY_MS", + DEFAULT_LOCK_RETRY_MS + ), + private readonly lockStaleMs: number = resolvePositiveInteger( + "REGEN_BATCH_EXECUTIONS_LOCK_STALE_MS", + DEFAULT_LOCK_STALE_MS + ) ) {} + private lockFilePath(): string { + return `${this.filePath}.lock`; + } + + private async tryClearStaleLock(): Promise { + const lockPath = this.lockFilePath(); + + try { + const lockStat = await stat(lockPath); + if (Date.now() - lockStat.mtimeMs < this.lockStaleMs) { + return false; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + return false; + } + + try { + await unlink(lockPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + return false; + } + } + + private async acquireLock(): Promise { + const lockPath = this.lockFilePath(); + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true }); + const startedAt = Date.now(); + + while (true) { + try { + const handle = await open(lockPath, "wx"); + try { + await handle.writeFile( + JSON.stringify( + { pid: process.pid, acquiredAt: new Date().toISOString() }, + null, + 2 + ), + "utf8" + ); + } finally { + await handle.close(); + } + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + throw error; + } + } + + if (await this.tryClearStaleLock()) { + continue; + } + + if (Date.now() - startedAt >= this.lockWaitMs) { + throw new Error( + `Timed out acquiring batch execution store lock after ${this.lockWaitMs}ms` + ); + } + + await wait(this.lockRetryMs); + } + } + + private async releaseLock(): Promise { + try { + await unlink(this.lockFilePath()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + async readState(): Promise { try { const raw = await readFile(this.filePath, "utf8"); @@ -54,4 +180,19 @@ export class JsonFileBatchExecutionStore implements BatchExecutionStore { await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8"); await rename(tempPath, this.filePath); } + + async withExclusiveState( + updater: (state: BatchExecutionState) => T | Promise + ): Promise { + await this.acquireLock(); + + try { + const state = await this.readState(); + const result = await updater(state); + await this.writeState(state); + return result; + } finally { + await this.releaseLock(); + } + } } diff --git a/src/services/batch-retirement/types.ts b/src/services/batch-retirement/types.ts index 8c1f56b..12b3da9 100644 --- a/src/services/batch-retirement/types.ts +++ b/src/services/batch-retirement/types.ts @@ -120,6 +120,9 @@ export interface BatchExecutionState { export interface BatchExecutionStore { readState(): Promise; writeState(state: BatchExecutionState): Promise; + withExclusiveState?( + updater: (state: BatchExecutionState) => T | Promise + ): Promise; } export interface BatchExecutionHistoryQuery { diff --git a/src/services/pool-accounting/service.ts b/src/services/pool-accounting/service.ts index 0dc05bf..23fac8a 100644 --- a/src/services/pool-accounting/service.ts +++ b/src/services/pool-accounting/service.ts @@ -8,6 +8,7 @@ import type { ContributionReceipt, ContributionRecord, MonthlyPoolSummary, + PoolAccountingState, PoolAccountingStore, UserContributionSummary, } from "./types.js"; @@ -197,62 +198,75 @@ export class PoolAccountingService { private readonly store: PoolAccountingStore = new JsonFilePoolAccountingStore() ) {} - async recordContribution(input: ContributionInput): Promise { + private async mutateState( + updater: (state: PoolAccountingState) => T | Promise + ): Promise { + if (this.store.withExclusiveState) { + return this.store.withExclusiveState(updater); + } + const state = await this.store.readState(); - const externalEventId = normalize(input.externalEventId); + const result = await updater(state); + await this.store.writeState(state); + return result; + } - if (externalEventId) { - const existing = state.contributions.find( - (item) => item.externalEventId === externalEventId - ); - if (existing) { - const userRecords = state.contributions.filter( - (item) => item.userId === existing.userId + async recordContribution(input: ContributionInput): Promise { + return this.mutateState((state) => { + const externalEventId = normalize(input.externalEventId); + + if (externalEventId) { + const existing = state.contributions.find( + (item) => item.externalEventId === externalEventId ); - return { - record: existing, - duplicate: true, - userSummary: summarizeUserRecords(existing.userId, userRecords), - monthSummary: summarizeMonth(existing.month, state.contributions), - }; + if (existing) { + const userRecords = state.contributions.filter( + (item) => item.userId === existing.userId + ); + return { + record: existing, + duplicate: true, + userSummary: summarizeUserRecords(existing.userId, userRecords), + monthSummary: summarizeMonth(existing.month, state.contributions), + }; + } } - } - - const userId = resolveUserId(input); - const contributedAt = toIsoTimestamp(input.contributedAt); - const month = toMonth(contributedAt); - const amountUsdCents = resolveAmountUsdCents(input); - - const record: ContributionRecord = { - id: `contrib_${randomUUID()}`, - userId, - email: normalizeEmail(input.email), - customerId: normalize(input.customerId), - subscriptionId: normalize(input.subscriptionId), - externalEventId, - tierId: input.tierId, - amountUsdCents, - contributedAt, - month, - source: input.source || "subscription", - metadata: input.metadata && Object.keys(input.metadata).length > 0 - ? input.metadata - : undefined, - }; - state.contributions.push(record); - state.contributions.sort((a, b) => - a.contributedAt.localeCompare(b.contributedAt) - ); - await this.store.writeState(state); + const userId = resolveUserId(input); + const contributedAt = toIsoTimestamp(input.contributedAt); + const month = toMonth(contributedAt); + const amountUsdCents = resolveAmountUsdCents(input); + + const record: ContributionRecord = { + id: `contrib_${randomUUID()}`, + userId, + email: normalizeEmail(input.email), + customerId: normalize(input.customerId), + subscriptionId: normalize(input.subscriptionId), + externalEventId, + tierId: input.tierId, + amountUsdCents, + contributedAt, + month, + source: input.source || "subscription", + metadata: input.metadata && Object.keys(input.metadata).length > 0 + ? input.metadata + : undefined, + }; + + state.contributions.push(record); + state.contributions.sort((a, b) => + a.contributedAt.localeCompare(b.contributedAt) + ); - const userRecords = state.contributions.filter((item) => item.userId === userId); - return { - record, - duplicate: false, - userSummary: summarizeUserRecords(userId, userRecords), - monthSummary: summarizeMonth(month, state.contributions), - }; + const userRecords = state.contributions.filter((item) => item.userId === userId); + return { + record, + duplicate: false, + userSummary: summarizeUserRecords(userId, userRecords), + monthSummary: summarizeMonth(month, state.contributions), + }; + }); } async getUserSummary( diff --git a/src/services/pool-accounting/store.ts b/src/services/pool-accounting/store.ts index 79a0553..500c5f7 100644 --- a/src/services/pool-accounting/store.ts +++ b/src/services/pool-accounting/store.ts @@ -1,8 +1,19 @@ -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { + mkdir, + open, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import type { PoolAccountingState, PoolAccountingStore } from "./types.js"; const DEFAULT_RELATIVE_LEDGER_PATH = "data/pool-accounting-ledger.json"; +const DEFAULT_LOCK_WAIT_MS = 10_000; +const DEFAULT_LOCK_RETRY_MS = 25; +const DEFAULT_LOCK_STALE_MS = 60_000; function getDefaultState(): PoolAccountingState { return { version: 1, contributions: [] }; @@ -22,8 +33,125 @@ export function getDefaultPoolAccountingPath(): string { return path.resolve(process.cwd(), DEFAULT_RELATIVE_LEDGER_PATH); } +function resolvePositiveInteger(envName: string, fallback: number): number { + const raw = process.env[envName]?.trim(); + if (!raw) { + return fallback; + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + export class JsonFilePoolAccountingStore implements PoolAccountingStore { - constructor(private readonly filePath: string = getDefaultPoolAccountingPath()) {} + constructor( + private readonly filePath: string = getDefaultPoolAccountingPath(), + private readonly lockWaitMs: number = resolvePositiveInteger( + "REGEN_POOL_ACCOUNTING_LOCK_WAIT_MS", + DEFAULT_LOCK_WAIT_MS + ), + private readonly lockRetryMs: number = resolvePositiveInteger( + "REGEN_POOL_ACCOUNTING_LOCK_RETRY_MS", + DEFAULT_LOCK_RETRY_MS + ), + private readonly lockStaleMs: number = resolvePositiveInteger( + "REGEN_POOL_ACCOUNTING_LOCK_STALE_MS", + DEFAULT_LOCK_STALE_MS + ) + ) {} + + private lockFilePath(): string { + return `${this.filePath}.lock`; + } + + private async tryClearStaleLock(): Promise { + const lockPath = this.lockFilePath(); + + try { + const lockStat = await stat(lockPath); + if (Date.now() - lockStat.mtimeMs < this.lockStaleMs) { + return false; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + return false; + } + + try { + await unlink(lockPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; + } + return false; + } + } + + private async acquireLock(): Promise { + const lockPath = this.lockFilePath(); + const dir = path.dirname(this.filePath); + await mkdir(dir, { recursive: true }); + const startedAt = Date.now(); + + while (true) { + try { + const handle = await open(lockPath, "wx"); + try { + await handle.writeFile( + JSON.stringify( + { pid: process.pid, acquiredAt: new Date().toISOString() }, + null, + 2 + ), + "utf8" + ); + } finally { + await handle.close(); + } + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + throw error; + } + } + + if (await this.tryClearStaleLock()) { + continue; + } + + if (Date.now() - startedAt >= this.lockWaitMs) { + throw new Error( + `Timed out acquiring pool accounting store lock after ${this.lockWaitMs}ms` + ); + } + + await wait(this.lockRetryMs); + } + } + + private async releaseLock(): Promise { + try { + await unlink(this.lockFilePath()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } async readState(): Promise { try { @@ -49,4 +177,19 @@ export class JsonFilePoolAccountingStore implements PoolAccountingStore { await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8"); await rename(tempPath, this.filePath); } + + async withExclusiveState( + updater: (state: PoolAccountingState) => T | Promise + ): Promise { + await this.acquireLock(); + + try { + const state = await this.readState(); + const result = await updater(state); + await this.writeState(state); + return result; + } finally { + await this.releaseLock(); + } + } } diff --git a/src/services/pool-accounting/types.ts b/src/services/pool-accounting/types.ts index 79e51c9..a37a699 100644 --- a/src/services/pool-accounting/types.ts +++ b/src/services/pool-accounting/types.ts @@ -79,6 +79,9 @@ export interface PoolAccountingState { export interface PoolAccountingStore { readState(): Promise; writeState(state: PoolAccountingState): Promise; + withExclusiveState?( + updater: (state: PoolAccountingState) => T | Promise + ): Promise; } export interface ContributionReceipt { diff --git a/tests/batch-execution-store.test.ts b/tests/batch-execution-store.test.ts new file mode 100644 index 0000000..1a1e258 --- /dev/null +++ b/tests/batch-execution-store.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { JsonFileBatchExecutionStore } from "../src/services/batch-retirement/store.js"; +import type { BatchExecutionRecord } from "../src/services/batch-retirement/types.js"; + +const tempDirectories: string[] = []; + +async function createStore() { + const dir = await mkdtemp(path.join(os.tmpdir(), "batch-execution-store-")); + tempDirectories.push(dir); + return new JsonFileBatchExecutionStore( + path.join(dir, "monthly-batch-executions.json"), + 5_000, + 5, + 30_000 + ); +} + +function buildRecord(index: number): BatchExecutionRecord { + return { + id: `batch_${index}`, + month: "2026-03", + dryRun: true, + status: "dry_run", + reason: "test", + budgetUsdCents: 100, + spentMicro: "1000000", + spentDenom: "USDC", + retiredQuantity: "0.500000", + executedAt: `2026-03-01T00:00:${String(index).padStart(2, "0")}.000Z`, + }; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((dir) => + rm(dir, { recursive: true, force: true }) + ) + ); +}); + +describe("JsonFileBatchExecutionStore", () => { + it("serializes concurrent withExclusiveState writes without dropping records", async () => { + const store = await createStore(); + const records = Array.from({ length: 20 }, (_, index) => + buildRecord(index + 1) + ); + + await Promise.all( + records.map((record) => + store.withExclusiveState!(async (state) => { + await new Promise((resolve) => setTimeout(resolve, 2)); + state.executions.push(record); + }) + ) + ); + + const state = await store.readState(); + expect(state.executions).toHaveLength(20); + expect(new Set(state.executions.map((item) => item.id)).size).toBe(20); + }); +}); diff --git a/tests/monthly-batch-executor.test.ts b/tests/monthly-batch-executor.test.ts index d874082..db9d5c0 100644 --- a/tests/monthly-batch-executor.test.ts +++ b/tests/monthly-batch-executor.test.ts @@ -18,6 +18,15 @@ class InMemoryBatchExecutionStore implements BatchExecutionStore { async writeState(state: BatchExecutionState): Promise { this.state = JSON.parse(JSON.stringify(state)) as BatchExecutionState; } + + async withExclusiveState( + updater: (state: BatchExecutionState) => T | Promise + ): Promise { + const state = await this.readState(); + const result = await updater(state); + await this.writeState(state); + return result; + } } describe("MonthlyBatchRetirementExecutor", () => { diff --git a/tests/pool-accounting-store.test.ts b/tests/pool-accounting-store.test.ts new file mode 100644 index 0000000..b9d96f0 --- /dev/null +++ b/tests/pool-accounting-store.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { JsonFilePoolAccountingStore } from "../src/services/pool-accounting/store.js"; +import type { ContributionRecord } from "../src/services/pool-accounting/types.js"; + +const tempDirectories: string[] = []; + +async function createStore() { + const dir = await mkdtemp(path.join(os.tmpdir(), "pool-accounting-store-")); + tempDirectories.push(dir); + return new JsonFilePoolAccountingStore( + path.join(dir, "pool-accounting-ledger.json"), + 5_000, + 5, + 30_000 + ); +} + +function buildRecord(index: number): ContributionRecord { + const contributedAt = `2026-03-01T00:00:${String(index).padStart(2, "0")}.000Z`; + return { + id: `contrib_${index}`, + userId: "user-a", + amountUsdCents: 100, + contributedAt, + month: "2026-03", + source: "subscription", + }; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((dir) => + rm(dir, { recursive: true, force: true }) + ) + ); +}); + +describe("JsonFilePoolAccountingStore", () => { + it("serializes concurrent withExclusiveState writes without dropping records", async () => { + const store = await createStore(); + const records = Array.from({ length: 20 }, (_, index) => + buildRecord(index + 1) + ); + + await Promise.all( + records.map((record) => + store.withExclusiveState!(async (state) => { + await new Promise((resolve) => setTimeout(resolve, 2)); + state.contributions.push(record); + }) + ) + ); + + const state = await store.readState(); + expect(state.contributions).toHaveLength(20); + expect(new Set(state.contributions.map((item) => item.id)).size).toBe(20); + }); +}); diff --git a/tests/pool-accounting.test.ts b/tests/pool-accounting.test.ts index fbd8b0c..7416242 100644 --- a/tests/pool-accounting.test.ts +++ b/tests/pool-accounting.test.ts @@ -192,4 +192,31 @@ describe("PoolAccountingService", () => { expect(march.contributionCount).toBe(1); expect(march.totalUsdCents).toBe(300); }); + + it("deduplicates concurrent records when externalEventId collides", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "pool-accounting-concurrent-")); + const ledgerPath = path.join(tempDir, "ledger.json"); + const concurrentService = new PoolAccountingService( + new JsonFilePoolAccountingStore(ledgerPath) + ); + + const receipts = await Promise.all( + Array.from({ length: 10 }, () => + concurrentService.recordContribution({ + customerId: "cus_concurrent", + amountUsdCents: 300, + contributedAt: "2026-03-10T00:00:00.000Z", + externalEventId: "stripe_invoice:in_concurrent", + source: "subscription", + }) + ) + ); + + expect(receipts.filter((item) => item.duplicate)).toHaveLength(9); + expect(new Set(receipts.map((item) => item.record.id)).size).toBe(1); + + const march = await concurrentService.getMonthlySummary("2026-03"); + expect(march.contributionCount).toBe(1); + expect(march.totalUsdCents).toBe(300); + }); }); From 1a4aa75865a09bfcdffbbb0263d0eed1c7d8eeae Mon Sep 17 00:00:00 2001 From: brawlaphant <35781613+brawlaphant@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:44:16 -0800 Subject: [PATCH 31/31] feat(listing-gtm): ship coinstore gtm stack and review flow --- .github/CONTRIBUTING.md | 2 + .github/pr-context.schema.json | 65 +++ .github/pull_request_template.md | 47 ++ .github/workflows/pr-template-check.yml | 47 ++ .gitignore | 1 + docs/listing-gtm/PR_DRAFT_COINSTORE_GTM.md | 86 +++ docs/listing-gtm/README.md | 73 +++ docs/listing-gtm/TECH_SIGNOFF_PACKET.md | 58 ++ docs/listing-gtm/VALIDATOR_REQUEST_MESSAGE.md | 21 + docs/listing-gtm/WAIT_STATE_CHECKLIST.md | 32 ++ docs/listing-gtm/fastlane-single-owner.md | 37 ++ docs/listing-gtm/scripts/apply-live-urls.mjs | 75 +++ .../scripts/create-release-bundle.mjs | 67 +++ docs/listing-gtm/scripts/preflight-check.mjs | 122 +++++ .../listing-gtm/scripts/update-proof-feed.mjs | 95 ++++ .../templates/kpi-dashboard-metrics.json | 68 +++ docs/listing-gtm/templates/kpi-weekly.csv | 2 + .../templates/listing-day-ops-log.md | 35 ++ .../templates/telegram-14-day-posting-pack.md | 168 ++++++ .../templates/telegram-daily-update.md | 24 + .../templates/telegram-link-values.md | 14 + .../templates/telegram-mod-quick-replies.md | 22 + .../templates/telegram-pinned-message.txt | 24 + .../unit-10-ten-minute-technical-signoff.md | 36 ++ .../unit-11-pr-cut-and-review-routing.md | 34 ++ .../unit-12-listing-day-rehearsal.md | 37 ++ .../unit-13-day-0-content-queue.md | 39 ++ .../unit-2-listing-landing-page.md | 70 +++ docs/listing-gtm/unit-3-proof-page.md | 71 +++ .../unit-4-campaign-buy-participate-proof.md | 66 +++ docs/listing-gtm/unit-5-telegram-ops-kit.md | 51 ++ .../listing-gtm/unit-6-exchange-mm-runbook.md | 62 +++ docs/listing-gtm/unit-7-kpi-dashboard-spec.md | 61 +++ docs/listing-gtm/unit-8-staging-publish.md | 40 ++ ...it-9-url-hydration-and-strict-preflight.md | 40 ++ docs/listing-gtm/web/README.md | 43 ++ .../listing-gtm/web/assets/regen-campaign.css | 516 ++++++++++++++++++ docs/listing-gtm/web/assets/regen-campaign.js | 219 ++++++++ docs/listing-gtm/web/listing-landing.html | 159 ++++++ docs/listing-gtm/web/proof-feed.json | 31 ++ docs/listing-gtm/web/proof-page.html | 320 +++++++++++ docs/listing-gtm/web/supporter-opt-in.html | 457 ++++++++++++++++ package.json | 3 + 43 files changed, 3540 insertions(+) create mode 100644 .github/pr-context.schema.json create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/pr-template-check.yml create mode 100644 docs/listing-gtm/PR_DRAFT_COINSTORE_GTM.md create mode 100644 docs/listing-gtm/README.md create mode 100644 docs/listing-gtm/TECH_SIGNOFF_PACKET.md create mode 100644 docs/listing-gtm/VALIDATOR_REQUEST_MESSAGE.md create mode 100644 docs/listing-gtm/WAIT_STATE_CHECKLIST.md create mode 100644 docs/listing-gtm/fastlane-single-owner.md create mode 100644 docs/listing-gtm/scripts/apply-live-urls.mjs create mode 100644 docs/listing-gtm/scripts/create-release-bundle.mjs create mode 100644 docs/listing-gtm/scripts/preflight-check.mjs create mode 100644 docs/listing-gtm/scripts/update-proof-feed.mjs create mode 100644 docs/listing-gtm/templates/kpi-dashboard-metrics.json create mode 100644 docs/listing-gtm/templates/kpi-weekly.csv create mode 100644 docs/listing-gtm/templates/listing-day-ops-log.md create mode 100644 docs/listing-gtm/templates/telegram-14-day-posting-pack.md create mode 100644 docs/listing-gtm/templates/telegram-daily-update.md create mode 100644 docs/listing-gtm/templates/telegram-link-values.md create mode 100644 docs/listing-gtm/templates/telegram-mod-quick-replies.md create mode 100644 docs/listing-gtm/templates/telegram-pinned-message.txt create mode 100644 docs/listing-gtm/unit-10-ten-minute-technical-signoff.md create mode 100644 docs/listing-gtm/unit-11-pr-cut-and-review-routing.md create mode 100644 docs/listing-gtm/unit-12-listing-day-rehearsal.md create mode 100644 docs/listing-gtm/unit-13-day-0-content-queue.md create mode 100644 docs/listing-gtm/unit-2-listing-landing-page.md create mode 100644 docs/listing-gtm/unit-3-proof-page.md create mode 100644 docs/listing-gtm/unit-4-campaign-buy-participate-proof.md create mode 100644 docs/listing-gtm/unit-5-telegram-ops-kit.md create mode 100644 docs/listing-gtm/unit-6-exchange-mm-runbook.md create mode 100644 docs/listing-gtm/unit-7-kpi-dashboard-spec.md create mode 100644 docs/listing-gtm/unit-8-staging-publish.md create mode 100644 docs/listing-gtm/unit-9-url-hydration-and-strict-preflight.md create mode 100644 docs/listing-gtm/web/README.md create mode 100644 docs/listing-gtm/web/assets/regen-campaign.css create mode 100644 docs/listing-gtm/web/assets/regen-campaign.js create mode 100644 docs/listing-gtm/web/listing-landing.html create mode 100644 docs/listing-gtm/web/proof-feed.json create mode 100644 docs/listing-gtm/web/proof-page.html create mode 100644 docs/listing-gtm/web/supporter-opt-in.html diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1ad7a42..e706d78 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -46,6 +46,8 @@ This project includes a `CLAUDE.md` file that gives Claude Code full context abo - Include a clear description of what changed and why - Update relevant documentation if behavior changes - Add tests for new functionality (when test infrastructure is set up) +- Use the repo PR template (`.github/pull_request_template.md`) and complete the `pr_context` block for fast human/agent handoff +- Assign explicit `owner`, `approver`, and `publisher` in the PR body before requesting final review ## Key Design Principles diff --git a/.github/pr-context.schema.json b/.github/pr-context.schema.json new file mode 100644 index 0000000..bba5780 --- /dev/null +++ b/.github/pr-context.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Pull Request Context", + "description": "Machine-readable context embedded in PR body under `pr_context`.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "change_type", + "scope", + "release_blocking", + "risk_level", + "breaking_change", + "db_migration", + "requires_follow_up", + "owner", + "approver", + "publisher" + ], + "properties": { + "version": { + "type": "integer", + "minimum": 1 + }, + "change_type": { + "type": "string", + "enum": ["feat", "fix", "docs", "refactor", "test", "chore"] + }, + "scope": { + "type": "string", + "minLength": 1 + }, + "linked_issue": { + "type": "string" + }, + "release_blocking": { + "type": "boolean" + }, + "risk_level": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "breaking_change": { + "type": "boolean" + }, + "db_migration": { + "type": "boolean" + }, + "requires_follow_up": { + "type": "boolean" + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "approver": { + "type": "string", + "minLength": 1 + }, + "publisher": { + "type": "string", + "minLength": 1 + } + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..423e712 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,47 @@ +## PR Context (for humans + agents) + +```yaml +pr_context: + version: 1 + change_type: feat # feat|fix|docs|refactor|test|chore + scope: "" # short scope, e.g. listing-gtm/proof-page + linked_issue: "" # optional issue/ticket URL or ID + release_blocking: false + risk_level: low # low|medium|high + breaking_change: false + db_migration: false + requires_follow_up: false + owner: "" # DRI for this PR + approver: "" # final signoff owner + publisher: "" # who deploys/ships +``` + +## Summary +- What changed? +- Why now? + +## Change Set +- Main files/areas touched: +- User-visible behavior changes: +- Non-goals (what this PR does not do): + +## Test Plan +- Commands run: +- Results: +- Manual checks (if any): + +## Risk and Rollback +- Main risks: +- Rollback or mitigation plan: + +## Handoff Notes +- Open follow-ups: +- Decisions needed: +- Deployment/publish steps: + +## Checklist +- [ ] Scope is focused and reviewable. +- [ ] Docs updated (if behavior changed). +- [ ] Tests added/updated where appropriate. +- [ ] No secrets or sensitive data included. +- [ ] Owner/Approver/Publisher set in `pr_context`. diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml new file mode 100644 index 0000000..11e8f2b --- /dev/null +++ b/.github/workflows/pr-template-check.yml @@ -0,0 +1,47 @@ +name: PR Template Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +jobs: + validate-pr-body: + name: Validate PR Body Format + runs-on: ubuntu-latest + steps: + - name: Check required PR template sections + uses: actions/github-script@v7 + with: + script: | + const body = context.payload.pull_request?.body || ""; + + const requiredTokens = [ + "pr_context:", + "change_type:", + "scope:", + "release_blocking:", + "risk_level:", + "breaking_change:", + "db_migration:", + "requires_follow_up:", + "owner:", + "approver:", + "publisher:", + "## Summary", + "## Change Set", + "## Test Plan", + "## Risk and Rollback", + "## Handoff Notes", + "## Checklist" + ]; + + const missing = requiredTokens.filter((token) => !body.includes(token)); + + if (missing.length > 0) { + core.setFailed( + "PR body is missing required template content: " + missing.join(", ") + ); + return; + } + + core.notice("PR template sections detected."); diff --git a/.gitignore b/.gitignore index 1fdf796..ad398dc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ .vscode/ .idea/ data/ +docs/listing-gtm/release/ diff --git a/docs/listing-gtm/PR_DRAFT_COINSTORE_GTM.md b/docs/listing-gtm/PR_DRAFT_COINSTORE_GTM.md new file mode 100644 index 0000000..2da2ffe --- /dev/null +++ b/docs/listing-gtm/PR_DRAFT_COINSTORE_GTM.md @@ -0,0 +1,86 @@ +# Proposed PR Title + +`feat(listing-gtm): Coinstore listing GTM launch stack (landing, supporter opt-in, proof board)` + +# PR Body Draft + +## PR Context (for humans + agents) + +```yaml +pr_context: + version: 1 + change_type: feat + scope: listing-gtm/coinstore-launch + linked_issue: "" + release_blocking: true + risk_level: medium + breaking_change: false + db_migration: false + requires_follow_up: true + owner: TEMP_RELEASE_OWNER + approver: TEMP_RELEASE_OWNER + publisher: TEMP_RELEASE_OWNER +``` + +## Summary +- Adds a 3-page launch web stack for Coinstore listing GTM. +- Introduces supporter opt-in flow that links token and non-token participation to AI prompt cadence and local ecosystem stewardship context. +- Adds proof board flow for retirement/certificate evidence and local proof ops handling. + +## Change Set +- Main files/areas touched: + - `docs/listing-gtm/web/listing-landing.html` + - `docs/listing-gtm/web/supporter-opt-in.html` + - `docs/listing-gtm/web/proof-page.html` + - `docs/listing-gtm/web/proof-feed.json` + - `docs/listing-gtm/web/assets/regen-campaign.css` + - `docs/listing-gtm/web/assets/regen-campaign.js` + - `docs/listing-gtm/templates/*` (Telegram + ops templates) + - `docs/listing-gtm/scripts/apply-live-urls.mjs` +- User-visible behavior changes: + - Supporter opt-in profile now supports holder, non-holder, and hybrid modes. + - Opt-in profile captures long-term commitment and stewardship context. + - Proof board now supports feed-based metrics and local proof submission cache. + - Telegram templates now align to `buy -> opt in -> prove`. +- Non-goals: + - No backend API integration yet. + - No exchange automation integration in this PR. + +## Test Plan +- Commands run: + - `node --check docs/listing-gtm/web/assets/regen-campaign.js` +- Results: + - JS syntax check passes. +- Manual checks: + - [ ] Open each page and validate navigation flow. + - [ ] Submit supporter profile and confirm local snapshot. + - [ ] Submit local proof and copy `#proof` block. + - [ ] Verify proof board reads `proof-feed.json`. + +## Risk and Rollback +- Main risks: + - Static pages can drift from live campaign rules if not updated. + - Local storage cache is browser-local and not shared. +- Rollback or mitigation: + - Revert to previous static prototype pages. + - Keep proof feed updates disciplined via ops log. + +## Handoff Notes +- Open follow-ups: + - Replace placeholder URLs once publishing endpoints are confirmed. + - Assign permanent owner/approver/publisher after fastlane period. +- Decisions needed: + - Final hosting destination for web pages. + - Final rules URL (currently `supporter-opt-in.html#campaign-rules`). +- Deployment/publish steps: + 1. Publish `/docs/listing-gtm/web` directory preserving relative paths. + 2. Run URL replacement script: + `node docs/listing-gtm/scripts/apply-live-urls.mjs --landing <...> --proof <...> --rules <...>` + 3. Post pinned message + Day 1 Telegram update. + +## Checklist +- [ ] Scope is focused and reviewable. +- [ ] Docs updated (if behavior changed). +- [ ] Tests added/updated where appropriate. +- [ ] No secrets or sensitive data included. +- [ ] Owner/Approver/Publisher set in `pr_context`. diff --git a/docs/listing-gtm/README.md b/docs/listing-gtm/README.md new file mode 100644 index 0000000..c23c888 --- /dev/null +++ b/docs/listing-gtm/README.md @@ -0,0 +1,73 @@ +# Listing GTM Units + +This folder tracks execution units for imminent exchange listing GTM. + +## Unit Docs +1. `unit-2-listing-landing-page.md` +2. `unit-3-proof-page.md` +3. `unit-4-campaign-buy-participate-proof.md` +4. `unit-5-telegram-ops-kit.md` +5. `unit-6-exchange-mm-runbook.md` +6. `unit-7-kpi-dashboard-spec.md` +7. `fastlane-single-owner.md` +8. `PR_DRAFT_COINSTORE_GTM.md` +9. `TECH_SIGNOFF_PACKET.md` +10. `VALIDATOR_REQUEST_MESSAGE.md` +11. `WAIT_STATE_CHECKLIST.md` +12. `unit-8-staging-publish.md` +13. `unit-9-url-hydration-and-strict-preflight.md` +14. `unit-10-ten-minute-technical-signoff.md` +15. `unit-11-pr-cut-and-review-routing.md` +16. `unit-12-listing-day-rehearsal.md` +17. `unit-13-day-0-content-queue.md` + +## Web Build (3 Pages) +1. `web/listing-landing.html` +2. `web/supporter-opt-in.html` +3. `web/proof-page.html` +4. `web/proof-feed.json` (proof board data source) +5. `web/assets/regen-campaign.css` +6. `web/assets/regen-campaign.js` + +Brand mode: +1. Default `regen` +2. Alternate `bridge`/`bridge.eco` via query param `?brand=bridge` + +## Templates +1. `templates/telegram-pinned-message.txt` +2. `templates/telegram-daily-update.md` +3. `templates/telegram-mod-quick-replies.md` +4. `templates/telegram-link-values.md` +5. `templates/telegram-14-day-posting-pack.md` +6. `templates/listing-day-ops-log.md` +7. `templates/kpi-weekly.csv` +8. `templates/kpi-dashboard-metrics.json` + +## Scripts +1. `scripts/apply-live-urls.mjs` +2. `scripts/preflight-check.mjs` +3. `scripts/create-release-bundle.mjs` +4. `scripts/update-proof-feed.mjs` + +## Immediate Use +1. Assign DRI/Approver/Publisher in each unit doc. +2. Replace placeholder links and owner fields. +3. Publish 3-page stack in this order: `listing-landing.html` -> `supporter-opt-in.html` -> `proof-page.html`. +4. Activate Telegram ops cadence and listing-day ops log. +5. Start KPI baseline capture on day 0. +6. Use `PR_DRAFT_COINSTORE_GTM.md` for the Coinstore GTM PR title/body. +7. Use `TECH_SIGNOFF_PACKET.md` for fast technical signoff. +8. Send `VALIDATOR_REQUEST_MESSAGE.md` to technical validator. +9. Pause using `WAIT_STATE_CHECKLIST.md` until validator/URL trigger. + +## Commands +1. Preflight (allows unresolved URL placeholders): + `npm run gtm:preflight` +2. Preflight strict (fails if URL placeholders are still unresolved): + `npm run gtm:preflight:strict` +3. Apply live URLs: + `node docs/listing-gtm/scripts/apply-live-urls.mjs --landing <...> --proof <...> --rules <...>` +4. Update proof feed after each verified event: + `node docs/listing-gtm/scripts/update-proof-feed.mjs --retirement-id <...> --tx-hash <...> --certificate-url <...> --credits <...> --notes "<...>"` +5. Create release bundle for publisher handoff: + `npm run gtm:bundle` diff --git a/docs/listing-gtm/TECH_SIGNOFF_PACKET.md b/docs/listing-gtm/TECH_SIGNOFF_PACKET.md new file mode 100644 index 0000000..adfb7d4 --- /dev/null +++ b/docs/listing-gtm/TECH_SIGNOFF_PACKET.md @@ -0,0 +1,58 @@ +# Coinstore GTM Technical Signoff Packet (10-Minute Review) + +## Goal +Enable one technical owner to approve and ship with minimal back-and-forth. + +## What Is Included +1. 3-page static launch flow: + - `web/listing-landing.html` + - `web/supporter-opt-in.html` + - `web/proof-page.html` +2. Shared assets: + - `web/assets/regen-campaign.css` + - `web/assets/regen-campaign.js` +3. Proof data source: + - `web/proof-feed.json` +4. Ops templates and scripts for URL patching, proof updates, and preflight checks. + +## Key Product Guarantee +Anyone can opt in to align AI prompting with regeneration: +1. Holder mode +2. Non-holder supporter mode +3. Hybrid mode + +## 10-Minute Reviewer Path +1. Open all 3 pages and confirm nav works between them. +2. Confirm brand mode toggles: + - append `?brand=regen` + - append `?brand=bridge` +3. On supporter page: + - submit one holder profile + - submit one non-holder profile + - verify `#supporter-optin` copy block +4. On proof page: + - submit a local proof entry + - verify `#proof` copy block + - confirm `proof-feed.json` entries render +5. Run checks: + - `npm run gtm:preflight` + - `node --check docs/listing-gtm/web/assets/regen-campaign.js` + +## Launch-Time Commands +1. Apply live URLs: + `node docs/listing-gtm/scripts/apply-live-urls.mjs --landing <...> --proof <...> --rules <...>` +2. Strict preflight: + `npm run gtm:preflight:strict` +3. Create handoff bundle: + `npm run gtm:bundle` + +## PR Metadata +Use: +- `PR_DRAFT_COINSTORE_GTM.md` for title/body. +- Title: + `feat(listing-gtm): Coinstore listing GTM launch stack (landing, supporter opt-in, proof board)` + +## Known Limits +1. Static front-end only; no backend persistence. +2. Local storage is browser-local for quick ops. +3. Live URLs still required for final template token replacement. diff --git a/docs/listing-gtm/VALIDATOR_REQUEST_MESSAGE.md b/docs/listing-gtm/VALIDATOR_REQUEST_MESSAGE.md new file mode 100644 index 0000000..fdc68e9 --- /dev/null +++ b/docs/listing-gtm/VALIDATOR_REQUEST_MESSAGE.md @@ -0,0 +1,21 @@ +# Validator Request Message (Send As-Is) + +Need one technical validator for the Coinstore GTM stack. + +Please run this 10-minute signoff: +1. Open: + - `listing-landing.html` + - `supporter-opt-in.html` + - `proof-page.html` +2. Validate holder + non-holder opt-in flow. +3. Validate proof submission/copy flow. +4. Confirm brand switch works (`?brand=regen` and `?brand=bridge`). +5. Run: + - `npm run gtm:preflight` + - `node --check docs/listing-gtm/web/assets/regen-campaign.js` + +Reference doc: +`docs/listing-gtm/TECH_SIGNOFF_PACKET.md` + +Please return: +`[UTC] [name] [pass/fail] [blocking notes]` diff --git a/docs/listing-gtm/WAIT_STATE_CHECKLIST.md b/docs/listing-gtm/WAIT_STATE_CHECKLIST.md new file mode 100644 index 0000000..e82498e --- /dev/null +++ b/docs/listing-gtm/WAIT_STATE_CHECKLIST.md @@ -0,0 +1,32 @@ +# Wait State Checklist (Pre-PR) + +Status: READY TO PAUSE FOR VALIDATION + +## Completed Now +1. 3-page build is done: + - `web/listing-landing.html` + - `web/supporter-opt-in.html` + - `web/proof-page.html` +2. Anyone-can-opt-in flow is implemented (holder/non-holder/hybrid). +3. Brand mode is implemented (`regen` and `bridge`). +4. Ops templates and scripts are in place. +5. PR draft and technical signoff packet are in place. +6. Release bundles are excluded from PR scope via `.gitignore`. + +## Waiting On +1. One technical validator to run signoff packet. +2. Final live URLs (`LANDING_URL`, `PROOF_URL`, `RULES_URL`). + +## Resume Triggers +Resume only when at least one of these is true: +1. Validator returns blocker findings. +2. Validator returns pass and asks for PR cut. +3. Live URLs arrive. + +## Resume Commands +1. Apply URLs: + `node docs/listing-gtm/scripts/apply-live-urls.mjs --landing <...> --proof <...> --rules <...>` +2. Strict check: + `npm run gtm:preflight:strict` +3. Open PR using: + `docs/listing-gtm/PR_DRAFT_COINSTORE_GTM.md` diff --git a/docs/listing-gtm/fastlane-single-owner.md b/docs/listing-gtm/fastlane-single-owner.md new file mode 100644 index 0000000..574d8da --- /dev/null +++ b/docs/listing-gtm/fastlane-single-owner.md @@ -0,0 +1,37 @@ +# Fast Lane: Single-Owner Mode + +## Purpose +Unblock execution when one person temporarily acts as: +1. DRI +2. Approver +3. Publisher + +## Rule +Use one temporary role: + +`TEMP_RELEASE_OWNER = ` + +This person can approve and publish all listing GTM artifacts until a broader owner group is assigned. + +## Guardrails +1. Log every publish decision in writing. +2. No financial promises in public copy. +3. No production publish without one final checklist pass. + +## Work We Can Do Now (No URL Blockers) +1. Finalize all owner matrices with `TEMP_RELEASE_OWNER`. +2. Fill MM/runbook guardrails in `unit-6-exchange-mm-runbook.md`. +3. Fill KPI thresholds and data sources in `unit-7-kpi-dashboard-spec.md`. +4. Pre-draft Days 1-14 Telegram posts from `templates/telegram-14-day-posting-pack.md`. +5. Dry run one proof submission and mod handling. + +## Work That Starts Immediately After URLs Arrive +1. Set `LANDING_URL`, `PROOF_URL`, `RULES_URL` in `templates/telegram-link-values.md`. +2. Replace all template tokens across Telegram files. +3. Publish pinned message and Day 1 post. +4. Publish landing and proof pages. + +## Release Decision Log +Use this format for each ship action: + +`[UTC timestamp] [asset] [approved by TEMP_RELEASE_OWNER] [published by TEMP_RELEASE_OWNER] [notes]` diff --git a/docs/listing-gtm/scripts/apply-live-urls.mjs b/docs/listing-gtm/scripts/apply-live-urls.mjs new file mode 100644 index 0000000..c5c223f --- /dev/null +++ b/docs/listing-gtm/scripts/apply-live-urls.mjs @@ -0,0 +1,75 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--landing") out.landing = argv[i + 1]; + if (arg === "--proof") out.proof = argv[i + 1]; + if (arg === "--rules") out.rules = argv[i + 1]; + } + return out; +} + +function usage() { + return [ + "Usage:", + " node docs/listing-gtm/scripts/apply-live-urls.mjs \\", + " --landing https://.../listing-landing.html \\", + " --proof https://.../proof-page.html \\", + " --rules https://.../supporter-opt-in.html#campaign-rules", + "", + "This replaces template tokens: {{LANDING_URL}}, {{PROOF_URL}}, {{RULES_URL}}", + ].join("\n"); +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.landing || !args.proof || !args.rules) { + console.error(usage()); + process.exit(1); +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const templatesDir = path.resolve(__dirname, "../templates"); + +const targetFiles = [ + "telegram-pinned-message.txt", + "telegram-daily-update.md", + "telegram-mod-quick-replies.md", + "telegram-14-day-posting-pack.md", +]; + +function patchTemplateTokens(filePath) { + let content = readFileSync(filePath, "utf8"); + content = content + .replaceAll("{{LANDING_URL}}", args.landing) + .replaceAll("{{PROOF_URL}}", args.proof) + .replaceAll("{{RULES_URL}}", args.rules); + writeFileSync(filePath, content, "utf8"); +} + +for (const filename of targetFiles) { + patchTemplateTokens(path.join(templatesDir, filename)); +} + +const linkValuesPath = path.join(templatesDir, "telegram-link-values.md"); +let linkValues = readFileSync(linkValuesPath, "utf8"); +linkValues = linkValues + .replace( + /3\. `LANDING_URL`: `.*`/g, + `3. \`LANDING_URL\`: \`${args.landing}\`` + ) + .replace( + /4\. `PROOF_URL`: `.*`/g, + `4. \`PROOF_URL\`: \`${args.proof}\`` + ) + .replace( + /5\. `RULES_URL`: `.*`/g, + `5. \`RULES_URL\`: \`${args.rules}\`` + ); +writeFileSync(linkValuesPath, linkValues, "utf8"); + +console.log("Applied live URLs to listing GTM Telegram templates."); diff --git a/docs/listing-gtm/scripts/create-release-bundle.mjs b/docs/listing-gtm/scripts/create-release-bundle.mjs new file mode 100644 index 0000000..7a2ff26 --- /dev/null +++ b/docs/listing-gtm/scripts/create-release-bundle.mjs @@ -0,0 +1,67 @@ +import { cpSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const gtmRoot = path.resolve(__dirname, ".."); + +function timestamp() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +function collectFiles(baseDir, acc = []) { + const entries = readdirSync(baseDir); + for (const entry of entries) { + const full = path.join(baseDir, entry); + const st = statSync(full); + if (st.isDirectory()) { + collectFiles(full, acc); + } else { + acc.push(full); + } + } + return acc; +} + +const releaseRoot = path.join(gtmRoot, "release"); +mkdirSync(releaseRoot, { recursive: true }); +const bundleDir = path.join(releaseRoot, `coinstore-gtm-${timestamp()}`); +mkdirSync(bundleDir, { recursive: true }); + +const copyItems = [ + "README.md", + "PR_DRAFT_COINSTORE_GTM.md", + "fastlane-single-owner.md", + "unit-2-listing-landing-page.md", + "unit-3-proof-page.md", + "unit-4-campaign-buy-participate-proof.md", + "unit-5-telegram-ops-kit.md", + "unit-6-exchange-mm-runbook.md", + "unit-7-kpi-dashboard-spec.md", + "templates", + "web", +]; + +for (const rel of copyItems) { + const src = path.join(gtmRoot, rel); + const dest = path.join(bundleDir, rel); + cpSync(src, dest, { recursive: true }); +} + +const files = collectFiles(bundleDir).map((file) => path.relative(bundleDir, file)); +const manifest = { + generated_at: new Date().toISOString(), + bundle_path: bundleDir, + file_count: files.length, + files, +}; + +writeFileSync( + path.join(bundleDir, "bundle-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8" +); + +console.log(`Created release bundle: ${bundleDir}`); +console.log(`Files copied: ${files.length}`); diff --git a/docs/listing-gtm/scripts/preflight-check.mjs b/docs/listing-gtm/scripts/preflight-check.mjs new file mode 100644 index 0000000..a3dd55b --- /dev/null +++ b/docs/listing-gtm/scripts/preflight-check.mjs @@ -0,0 +1,122 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const gtmRoot = path.resolve(__dirname, ".."); + +const strictUrls = process.argv.includes("--strict-urls"); + +const requiredFiles = [ + "README.md", + "PR_DRAFT_COINSTORE_GTM.md", + "fastlane-single-owner.md", + "unit-2-listing-landing-page.md", + "unit-3-proof-page.md", + "unit-4-campaign-buy-participate-proof.md", + "unit-5-telegram-ops-kit.md", + "unit-6-exchange-mm-runbook.md", + "unit-7-kpi-dashboard-spec.md", + "web/listing-landing.html", + "web/supporter-opt-in.html", + "web/proof-page.html", + "web/proof-feed.json", + "web/assets/regen-campaign.css", + "web/assets/regen-campaign.js", + "templates/telegram-pinned-message.txt", + "templates/telegram-daily-update.md", + "templates/telegram-mod-quick-replies.md", + "templates/telegram-link-values.md", + "templates/telegram-14-day-posting-pack.md", +]; + +function loadFile(relativePath) { + return readFileSync(path.join(gtmRoot, relativePath), "utf8"); +} + +const errors = []; +const warnings = []; + +for (const rel of requiredFiles) { + const full = path.join(gtmRoot, rel); + if (!existsSync(full)) { + errors.push(`Missing required file: ${rel}`); + } +} + +function assertIncludes(relativePath, token) { + const content = loadFile(relativePath); + if (!content.includes(token)) { + errors.push(`Expected token "${token}" in ${relativePath}`); + } +} + +assertIncludes("web/listing-landing.html", "./supporter-opt-in.html"); +assertIncludes("web/listing-landing.html", "./proof-page.html"); +assertIncludes("web/listing-landing.html", "data-brand-text=\"stackName\""); +assertIncludes("web/listing-landing.html", "window.RegenCampaign.applyBrand()"); +assertIncludes("web/supporter-opt-in.html", "#campaign-rules"); +assertIncludes("web/supporter-opt-in.html", "Non-holder supporter"); +assertIncludes("web/supporter-opt-in.html", "window.RegenCampaign.applyBrand()"); +assertIncludes("web/proof-page.html", "./proof-feed.json"); +assertIncludes("web/proof-page.html", "window.RegenCampaign.applyBrand()"); + +try { + const proofFeed = JSON.parse(loadFile("web/proof-feed.json")); + if (!proofFeed.updated_at) errors.push("proof-feed.json missing updated_at"); + if (!proofFeed.totals) errors.push("proof-feed.json missing totals"); + if (!Array.isArray(proofFeed.recent_retirements)) { + errors.push("proof-feed.json recent_retirements must be an array"); + } + if (typeof proofFeed?.totals?.retirements !== "number") { + errors.push("proof-feed.json totals.retirements must be a number"); + } + if (typeof proofFeed?.totals?.credits_retired !== "string") { + errors.push("proof-feed.json totals.credits_retired must be a string"); + } +} catch (error) { + errors.push(`proof-feed.json parse failed: ${error instanceof Error ? error.message : "unknown error"}`); +} + +const urlTokenChecks = [ + "templates/telegram-pinned-message.txt", + "templates/telegram-daily-update.md", + "templates/telegram-mod-quick-replies.md", + "templates/telegram-14-day-posting-pack.md", +]; + +for (const rel of urlTokenChecks) { + const content = loadFile(rel); + const unresolvedCount = + (content.match(/\{\{LANDING_URL\}\}/g) || []).length + + (content.match(/\{\{PROOF_URL\}\}/g) || []).length + + (content.match(/\{\{RULES_URL\}\}/g) || []).length; + + if (unresolvedCount > 0) { + const msg = `${rel} has ${unresolvedCount} unresolved URL token(s)`; + if (strictUrls) { + errors.push(msg); + } else { + warnings.push(msg); + } + } +} + +function printGroup(title, items) { + if (!items.length) return; + console.log(`\n${title}`); + for (const item of items) { + console.log(`- ${item}`); + } +} + +console.log("Listing GTM preflight summary:"); +printGroup("Warnings", warnings); +printGroup("Errors", errors); + +if (!warnings.length && !errors.length) { + console.log("\n- All checks passed."); +} + +process.exit(errors.length ? 1 : 0); diff --git a/docs/listing-gtm/scripts/update-proof-feed.mjs b/docs/listing-gtm/scripts/update-proof-feed.mjs new file mode 100644 index 0000000..30bca3d --- /dev/null +++ b/docs/listing-gtm/scripts/update-proof-feed.mjs @@ -0,0 +1,95 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function usage() { + return [ + "Usage:", + " node docs/listing-gtm/scripts/update-proof-feed.mjs \\", + " --retirement-id Wy123 \\", + " --tx-hash ABCDEF \\", + " --certificate-url https://regen.network/certificate/xyz \\", + " --credits 1.250000 \\", + " --notes \"Listing day batch\"", + "", + "Optional:", + " --date YYYY-MM-DD (default: today UTC date)", + " --supporters 42 (set totals.supporters_opted_in)", + " --feed /absolute/path.json (default: docs/listing-gtm/web/proof-feed.json)", + ].join("\n"); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + if (!key.startsWith("--")) continue; + out[key.slice(2)] = argv[i + 1]; + } + return out; +} + +function formatDateUtc(date) { + return date.toISOString().slice(0, 10); +} + +function formatCredits(value) { + return Number(value).toFixed(6); +} + +const args = parseArgs(process.argv.slice(2)); +if (!args["retirement-id"] || !args["tx-hash"] || !args["certificate-url"]) { + console.error(usage()); + process.exit(1); +} + +const creditsToAdd = Number(args.credits || "0"); +if (!Number.isFinite(creditsToAdd) || creditsToAdd < 0) { + console.error("--credits must be a non-negative number"); + process.exit(1); +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const defaultFeedPath = path.resolve(__dirname, "../web/proof-feed.json"); +const feedPath = args.feed ? path.resolve(process.cwd(), args.feed) : defaultFeedPath; + +const raw = readFileSync(feedPath, "utf8"); +const feed = JSON.parse(raw); + +if (!feed.totals || !Array.isArray(feed.recent_retirements)) { + throw new Error("Invalid proof feed schema"); +} + +const now = new Date(); +const entry = { + date: args.date || formatDateUtc(now), + retirement_id: args["retirement-id"], + tx_hash: args["tx-hash"], + certificate_url: args["certificate-url"], + notes: args.notes || "", +}; + +feed.updated_at = now.toISOString(); +feed.recent_retirements = [entry, ...feed.recent_retirements].slice(0, 50); +feed.totals.retirements = Number(feed.totals.retirements || 0) + 1; + +const currentCredits = Number(feed.totals.credits_retired || "0"); +feed.totals.credits_retired = formatCredits(currentCredits + creditsToAdd); + +if (args.supporters !== undefined) { + const supporters = Number(args.supporters); + if (!Number.isInteger(supporters) || supporters < 0) { + throw new Error("--supporters must be a non-negative integer"); + } + feed.totals.supporters_opted_in = supporters; +} + +writeFileSync(feedPath, `${JSON.stringify(feed, null, 2)}\n`, "utf8"); + +console.log("Updated proof feed:"); +console.log(`- feed: ${feedPath}`); +console.log(`- retirement_id: ${entry.retirement_id}`); +console.log(`- tx_hash: ${entry.tx_hash}`); +console.log(`- credits_retired_total: ${feed.totals.credits_retired}`); +console.log(`- retirements_total: ${feed.totals.retirements}`); diff --git a/docs/listing-gtm/templates/kpi-dashboard-metrics.json b/docs/listing-gtm/templates/kpi-dashboard-metrics.json new file mode 100644 index 0000000..66bce40 --- /dev/null +++ b/docs/listing-gtm/templates/kpi-dashboard-metrics.json @@ -0,0 +1,68 @@ +{ + "version": 1, + "metrics": [ + { + "id": "new_holders_weekly", + "name": "New holders (weekly)", + "owner": "", + "source": "", + "cadence": "weekly" + }, + { + "id": "holder_retention_30d_pct", + "name": "30-day holder retention (%)", + "owner": "", + "source": "", + "cadence": "weekly" + }, + { + "id": "participants_with_verified_action", + "name": "Participants with verified action", + "owner": "", + "source": "", + "cadence": "daily" + }, + { + "id": "verification_actions_total", + "name": "Total verification actions", + "owner": "", + "source": "", + "cadence": "daily" + }, + { + "id": "credits_retired_total", + "name": "Credits retired (total)", + "owner": "", + "source": "", + "cadence": "daily" + }, + { + "id": "certificates_published_total", + "name": "Certificates published (total)", + "owner": "", + "source": "", + "cadence": "daily" + }, + { + "id": "buy_sell_ratio", + "name": "Buy/Sell ratio", + "owner": "", + "source": "", + "cadence": "daily" + }, + { + "id": "avg_spread_bps", + "name": "Average spread (bps)", + "owner": "", + "source": "", + "cadence": "daily" + }, + { + "id": "top_of_book_depth_usd", + "name": "Top-of-book depth (USD)", + "owner": "", + "source": "", + "cadence": "daily" + } + ] +} diff --git a/docs/listing-gtm/templates/kpi-weekly.csv b/docs/listing-gtm/templates/kpi-weekly.csv new file mode 100644 index 0000000..0b1c081 --- /dev/null +++ b/docs/listing-gtm/templates/kpi-weekly.csv @@ -0,0 +1,2 @@ +week_start_utc,week_end_utc,new_holders_weekly,holder_retention_30d_pct,participants_with_verified_action,verification_actions_total,credits_retired_total,certificates_published_total,buy_sell_ratio,avg_spread_bps,top_of_book_depth_usd,notes +2026-02-24,2026-03-02,0,0,0,0,0,0,0,0,0,baseline diff --git a/docs/listing-gtm/templates/listing-day-ops-log.md b/docs/listing-gtm/templates/listing-day-ops-log.md new file mode 100644 index 0000000..26d1b31 --- /dev/null +++ b/docs/listing-gtm/templates/listing-day-ops-log.md @@ -0,0 +1,35 @@ +# Listing Day Ops Log + +## Metadata +- Date (UTC): +- Listing pair: +- Exchange: +- Ops lead: +- Backup lead: + +## Timeline Events +| Time (UTC) | Event | Owner | Status | Notes | +|---|---|---|---|---| +| T-120m | Preflight checks complete | | | | +| T-60m | Change freeze active | | | | +| T-15m | Exchange + MM online check | | | | +| T0 | Listing opened | | | | +| T+15m | Market quality check 1 | | | | +| T+60m | Public status post | | | | +| T+4h | Mid-run report | | | | +| T+24h | Day-one summary | | | | + +## Market Quality Snapshot +- Observed spread: +- Observed top-of-book depth: +- Slippage check result: +- Guardrail breaches: + +## Incident Log +| Time (UTC) | Severity | Symptom | Impact | Owner | Action | Resolved | +|---|---|---|---|---|---|---| +| | | | | | | | + +## Closeout +- Final status: +- Follow-up actions: diff --git a/docs/listing-gtm/templates/telegram-14-day-posting-pack.md b/docs/listing-gtm/templates/telegram-14-day-posting-pack.md new file mode 100644 index 0000000..9837131 --- /dev/null +++ b/docs/listing-gtm/templates/telegram-14-day-posting-pack.md @@ -0,0 +1,168 @@ +# Telegram 14-Day Posting Pack + +Replace `{{LANDING_URL}}`, `{{PROOF_URL}}`, and `{{RULES_URL}}` once, then post as-is. + +--- + +## Day 1 - Launch +Listing is live. Campaign is now active. + +Flow: +1. Buy: https://www.coinstore.com/home +2. Complete supporter opt-in (holder or non-holder): {{RULES_URL}} +3. Submit proof with `#proof` (tx hash + certificate URL + wallet) + +Start here: {{LANDING_URL}} +Live proof board: {{PROOF_URL}} +Rules: {{RULES_URL}} + +--- + +## Day 2 - How to Participate +Quick participation reminder: + +1. Buy or top up on Coinstore +2. Complete supporter opt-in (holder or non-holder) + one qualified action +3. Submit proof before cutoff + +Campaign flow: {{LANDING_URL}} +Proof board: {{PROOF_URL}} + +--- + +## Day 3 - Proof Education +How to verify proof in under 60 seconds: + +1. Open certificate URL +2. Match retirement ID and tx hash +3. Confirm date and reason + +Today’s references are on: {{PROOF_URL}} + +--- + +## Day 4 - Midweek Push +Midweek checkpoint. If you joined, now is the time to submit proof. + +Required format: +`#proof` +`tx_hash: ...` +`certificate_url: ...` +`wallet: ...` + +Rules: {{RULES_URL}} + +--- + +## Day 5 - Safety Reminder +Safety notice: +1. No one from the team will ask for keys or seed phrase. +2. No return guarantees are offered. +3. Only verified proof submissions count. + +Official links: +- Listing: https://www.coinstore.com/home +- Landing: {{LANDING_URL}} +- Proof: {{PROOF_URL}} + +--- + +## Day 6 - Weekend Action +Weekend action sprint: +1. Buy or top up +2. Complete one participation action +3. Submit proof before day close + +Live proof feed: {{PROOF_URL}} + +--- + +## Day 7 - Week 1 Recap +Week 1 recap drops today. + +What we will publish: +1. Verified actions count +2. Proof highlights +3. Next-week targets + +Track updates: {{PROOF_URL}} + +--- + +## Day 8 - Week 2 Kickoff +Week 2 starts now. Goal is consistent participation, not one-time activity. + +Do one action today: +1. Buy +2. Participate +3. Prove + +Start: {{LANDING_URL}} + +--- + +## Day 9 - Rule Clarification +Rule clarification: +1. Duplicate proof artifacts are invalid. +2. Invalid tx/certificate references are rejected. +3. Disputes are reviewed by ops + technical lead. + +Rules: {{RULES_URL}} + +--- + +## Day 10 - Operator Update +Operator update: +1. Market status monitored continuously. +2. Proof feed updated daily. +3. Incident path is active for urgent issues. + +Proof board: {{PROOF_URL}} + +--- + +## Day 11 - Community Recognition +Recognition day: +We will highlight top verified participants from this cycle. + +To qualify: +1. Ensure your proof submission is complete. +2. Submit before cutoff. + +Reference: {{RULES_URL}} + +--- + +## Day 12 - Final Stretch +Final stretch starts now. + +If you have not submitted proof yet: +1. Complete your participation action +2. Submit full proof block today + +Instructions: {{LANDING_URL}} + +--- + +## Day 13 - Last Full Day +Last full day before campaign close. + +Checklist: +1. Buy/top up complete +2. Participation complete +3. Proof submitted and acknowledged + +Proof board: {{PROOF_URL}} + +--- + +## Day 14 - Close and Next Steps +Campaign closes today at cutoff. + +After close we will publish: +1. Final verified action totals +2. Proof summary +3. Next cycle schedule + +Thanks to everyone who participated with verified proof. +Rules and closeout process: {{RULES_URL}} diff --git a/docs/listing-gtm/templates/telegram-daily-update.md b/docs/listing-gtm/templates/telegram-daily-update.md new file mode 100644 index 0000000..df9584d --- /dev/null +++ b/docs/listing-gtm/templates/telegram-daily-update.md @@ -0,0 +1,24 @@ +# Daily Campaign Update - {{DATE_UTC}} + +## Status +- Day: {{DAY_NUMBER}} / 14 +- Campaign phase: {{PHASE}} +- Time remaining: {{TIME_REMAINING}} + +## Scoreboard +- New participants today: {{NEW_PARTICIPANTS}} +- Verified actions today: {{VERIFIED_ACTIONS}} +- Total verified actions: {{TOTAL_VERIFIED_ACTIONS}} + +## Proof Highlight +- Retirement/certificate reference: {{PROOF_REFERENCE}} +- Verification link: {{PROOF_URL}} + +## Action for Today +1. Buy or top up via listing venue: https://www.coinstore.com/home +2. Complete supporter opt-in (holder or non-holder): {{RULES_URL}} +3. Submit proof before cutoff: {{SUBMISSION_PROCESS}} + +## Reminder +- No return guarantees are offered. +- Only verified submissions count. diff --git a/docs/listing-gtm/templates/telegram-link-values.md b/docs/listing-gtm/templates/telegram-link-values.md new file mode 100644 index 0000000..cad1976 --- /dev/null +++ b/docs/listing-gtm/templates/telegram-link-values.md @@ -0,0 +1,14 @@ +# Telegram Link Values (Set Once) + +Use these values across pinned messages, daily updates, and mod replies. + +1. `LISTING_URL`: `https://www.coinstore.com/home` +2. `TELEGRAM_COMMUNITY_URL`: `https://t.me/regennetwork_public` +3. `LANDING_URL`: `{{SET_THIS_TO_YOUR_PUBLISHED_LANDING_PAGE}}` +4. `PROOF_URL`: `{{SET_THIS_TO_YOUR_PUBLISHED_PROOF_PAGE}}` +5. `RULES_URL`: `{{SET_THIS_TO_YOUR_PUBLISHED_SUPPORTER_OPTIN_PAGE}}#campaign-rules` + +Proof submission process token: + +`SUBMISSION_PROCESS`: +`Post in this group with #proof + tx_hash + certificate_url + wallet before daily cutoff.` diff --git a/docs/listing-gtm/templates/telegram-mod-quick-replies.md b/docs/listing-gtm/templates/telegram-mod-quick-replies.md new file mode 100644 index 0000000..0689dd2 --- /dev/null +++ b/docs/listing-gtm/templates/telegram-mod-quick-replies.md @@ -0,0 +1,22 @@ +# Moderator Quick Replies + +## Price Prediction Request +We cannot provide price predictions or investment advice. Campaign details and participation rules are here: {{RULES_URL}} + +## How to Participate +Steps are simple: buy (optional for non-holders), complete supporter opt-in, complete one participation action, then submit proof. Start here: {{LANDING_URL}} + +## How to Verify Proof +Use the proof board to check certificate or transaction references: {{PROOF_URL}} + +## Submission Rejected +Please share your submission ID and proof reference with mods. Duplicate or invalid proof artifacts are not eligible. + +## Exchange Issue +Please share timestamp, screenshot, and issue details. We will route this to exchange ops immediately. + +## Scam Warning +Admins never ask for private keys or seed phrases. Only trust links pinned in this group. + +## Where to Buy +Use the official listing venue link only: https://www.coinstore.com/home diff --git a/docs/listing-gtm/templates/telegram-pinned-message.txt b/docs/listing-gtm/templates/telegram-pinned-message.txt new file mode 100644 index 0000000..08fc82b --- /dev/null +++ b/docs/listing-gtm/templates/telegram-pinned-message.txt @@ -0,0 +1,24 @@ +Listing GTM: Buy + Participate + Proof + +This campaign is focused on long-term aligned participation. +Anyone can opt in (holder or non-holder supporter). + +How to join: +1. Buy on Coinstore: https://www.coinstore.com/home +2. Complete supporter opt-in + one regeneration participation action. +3. Submit proof in this group using: + #proof + tx_hash: + certificate_url: + wallet: + +Live links: +- Landing page: {{LANDING_URL}} +- Proof board: {{PROOF_URL}} +- Campaign rules: {{RULES_URL}} +- Telegram community (external): https://t.me/regennetwork_public + +Important: +- No financial advice or return promises. +- Verification is required for campaign credit. +- Duplicate or fraudulent submissions are disqualified. diff --git a/docs/listing-gtm/unit-10-ten-minute-technical-signoff.md b/docs/listing-gtm/unit-10-ten-minute-technical-signoff.md new file mode 100644 index 0000000..e572ffa --- /dev/null +++ b/docs/listing-gtm/unit-10-ten-minute-technical-signoff.md @@ -0,0 +1,36 @@ +# Unit 10: Ten-Minute Technical Signoff + +## Objective +Get one technical signer to approve coherence and operational readiness fast. + +## Reference +Use: +`docs/listing-gtm/TECH_SIGNOFF_PACKET.md` + +## Signoff Path (10 minutes) +1. Open all 3 pages and verify navigation. +2. Submit one holder profile on supporter page. +3. Submit one non-holder profile on supporter page. +4. Copy `#supporter-optin` block. +5. Submit one local proof on proof page. +6. Copy `#proof` block. +7. Confirm proof feed rows render. +8. Run: + - `npm run gtm:preflight` + - `node --check docs/listing-gtm/web/assets/regen-campaign.js` + +## Approval Output +Record in one line: +`[UTC] [signer] [pass/fail] [blocking notes]` + +## Owner Matrix +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Target Time (UTC): T0-10h + +## Definition of Done +1. One named technical signer completes the path. +2. Pass/fail line recorded. +3. Any blockers converted to explicit tasks. diff --git a/docs/listing-gtm/unit-11-pr-cut-and-review-routing.md b/docs/listing-gtm/unit-11-pr-cut-and-review-routing.md new file mode 100644 index 0000000..6d0e293 --- /dev/null +++ b/docs/listing-gtm/unit-11-pr-cut-and-review-routing.md @@ -0,0 +1,34 @@ +# Unit 11: PR Cut and Review Routing + +## Objective +Open a targeted PR that a busy technical lead can approve with minimal friction. + +## PR Positioning +Use a listing-specific title and body: +- Title/body source: + `docs/listing-gtm/PR_DRAFT_COINSTORE_GTM.md` + +## Required Review Assignments +1. Primary technical reviewer (e.g., bridge.eco or Regen R&D). +2. Secondary fallback reviewer. +3. Final publisher. + +## Execution Steps +1. Create branch with `codex/` prefix. +2. Commit GTM stack changes. +3. Open PR using draft title/body. +4. Request review from primary + fallback. +5. Post PR link in team Telegram with ask: + `Need technical signoff for listing deployment path.` + +## Owner Matrix +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Target Time (UTC): T0-8h + +## Definition of Done +1. PR opened with listing-targeted title. +2. Reviewer assignments are explicit. +3. Merge/publish owner is identified in PR context block. diff --git a/docs/listing-gtm/unit-12-listing-day-rehearsal.md b/docs/listing-gtm/unit-12-listing-day-rehearsal.md new file mode 100644 index 0000000..8324bc2 --- /dev/null +++ b/docs/listing-gtm/unit-12-listing-day-rehearsal.md @@ -0,0 +1,37 @@ +# Unit 12: Listing-Day Rehearsal + +## Objective +Run a full dry run before launch to reduce operational surprises. + +## Scope +1. Simulate supporter opt-in submission. +2. Simulate proof submission and verification. +3. Simulate community moderation + escalation. + +## Required References +1. `templates/listing-day-ops-log.md` +2. `templates/telegram-mod-quick-replies.md` +3. `web/supporter-opt-in.html` +4. `web/proof-page.html` + +## Rehearsal Script +1. Post one mock `#supporter-optin` block. +2. Post one mock `#proof` block. +3. Update proof feed with: + ```bash + node docs/listing-gtm/scripts/update-proof-feed.mjs --retirement-id WyDryRun001 --tx-hash DryRunTx001 --certificate-url https://regen.network/certificate/dry-run --credits 0.100000 --notes "Dry run" + ``` +4. Run one moderator response cycle from quick replies. +5. Log timestamps and outcomes in ops log template. + +## Owner Matrix +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Target Time (UTC): T0-6h + +## Definition of Done +1. Dry run executed end-to-end. +2. Ops log completed with notes. +3. Any failure points documented with owner and fix ETA. diff --git a/docs/listing-gtm/unit-13-day-0-content-queue.md b/docs/listing-gtm/unit-13-day-0-content-queue.md new file mode 100644 index 0000000..a679aa6 --- /dev/null +++ b/docs/listing-gtm/unit-13-day-0-content-queue.md @@ -0,0 +1,39 @@ +# Unit 13: Day-0 Content Queue + +## Objective +Pre-stage core community posts so launch communications do not block on manual drafting. + +## Scope +1. Pinned message. +2. Day 1, Day 2, Day 3 campaign posts. +3. One emergency clarification post. + +## Source Files +1. `templates/telegram-pinned-message.txt` +2. `templates/telegram-14-day-posting-pack.md` +3. `templates/telegram-mod-quick-replies.md` + +## Queue Build Steps +1. Pull Day 1-3 content blocks into scheduler/draft queue. +2. Confirm links match hydrated values. +3. Add one fallback safety post: + - no financial advice + - official links only + - proof format reminder +4. Pre-approve all four messages. + +## Fallback Safety Post +`Official launch reminder: use only pinned links, no one will ask for seed phrase, and only verified #proof submissions count.` + +## Owner Matrix +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Target Time (UTC): T0-4h + +## Definition of Done +1. Pinned message staged. +2. Day 1-3 posts staged. +3. Safety fallback post staged. +4. Publisher confirms final send order. diff --git a/docs/listing-gtm/unit-2-listing-landing-page.md b/docs/listing-gtm/unit-2-listing-landing-page.md new file mode 100644 index 0000000..b77f8ba --- /dev/null +++ b/docs/listing-gtm/unit-2-listing-landing-page.md @@ -0,0 +1,70 @@ +# Unit 2: Listing Landing Page + +## Objective +Convert exchange listing traffic into long-term aligned holders who take the next step: + +`Buy -> Participate -> Proof` + +Secondary path: + +`Opt in as non-holder supporter -> Participate -> Proof` + +## Page Goal (v1) +One page that answers: +1. Why this token exists. +2. Why someone should stay involved after buying. +3. What action to take next. + +## Required Sections +1. Hero +- Headline: `AI usage funds verified ecological regeneration.` +- Subhead: `Every retirement is on-chain and publicly verifiable.` +- CTA buttons: + - `Trade on Coinstore` + - `See Live Proof` + - `Join Telegram` + +2. Why Hold (utility, not price promises) +- Access to participation campaigns. +- Priority access to product/community programs. +- Public attribution to impact actions. + +3. How It Works +- `Buy token` +- `Participate in regeneration action` +- `Verify proof on-chain` + +4. Trust + Safety +- No investment return promises. +- Transparent methodology and verification links. +- Clear risk and disclosure language. + +5. FAQ (short) +- What is this token used for? +- How is impact verified? +- Is this a financial product? +- Where can I track retirements? + +## Ready-to-Paste Hero Copy +`Regen for AI turns compute activity into verifiable ecological regeneration.` + +`This is not a hype cycle token page. This is a participation and proof system: buy, participate, and verify on-chain.` + +## Owner Matrix (fill now) +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Publish Deadline (UTC): T0-24h + +## Definition of Done +1. URL live and mobile-tested. +2. All three CTA links active. +3. Compliance disclaimer present. +4. Approved by Approver and published by Publisher. + +## Publish Checklist +1. Final copy check. +2. Link QA (Coinstore, proof page, Telegram). +3. Mobile and desktop screenshots captured. +4. Post URL in Telegram pinned message. diff --git a/docs/listing-gtm/unit-3-proof-page.md b/docs/listing-gtm/unit-3-proof-page.md new file mode 100644 index 0000000..2d4f2d6 --- /dev/null +++ b/docs/listing-gtm/unit-3-proof-page.md @@ -0,0 +1,71 @@ +# Unit 3: Proof Page + +## Objective +Make trust visible. Show that participation results in verifiable regeneration activity. + +## Page Goal (v1) +Single public page with: +1. Live or daily-updated impact totals. +2. Recent certificates and transaction references. +3. A clear verification path. + +## Required Sections +1. Impact Counter +- Total credits retired +- Total retirements +- Latest update timestamp + +2. Recent Proof Feed +- Last 10 retirement entries with: + - Date + - Credit/project reference + - Retirement/certificate ID + - Transaction hash or link + +3. Verification Guide +- `How to verify a retirement in under 60 seconds` +- Links to explorer/indexer/certificate pages + +4. Method Note +- Clarify this is regenerative contribution tracking. +- Avoid "carbon neutral" claims unless legally reviewed. + +## Data Contract (v1, manual-safe) +Use this structure for updates: + +```json +{ + "updated_at": "2026-02-24T00:00:00Z", + "totals": { + "retirements": 0, + "credits_retired": "0.000000" + }, + "recent": [ + { + "date": "2026-02-24", + "retirement_id": "Wy...", + "tx_hash": "ABC...", + "certificate_url": "https://regen.network/certificate/...", + "notes": "Monthly pooled retirement" + } + ] +} +``` + +## Owner Matrix (fill now) +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Publish Deadline (UTC): T0-24h + +## Definition of Done +1. Public proof URL is live. +2. At least 3 real proof entries displayed (or marked pending if pre-listing). +3. Verification instructions are visible and tested. +4. Timestamp reflects current update. + +## Operating Cadence +1. Update proof feed daily during listing week. +2. Pin latest proof link in Telegram. +3. Include proof snapshot in weekly call notes. diff --git a/docs/listing-gtm/unit-4-campaign-buy-participate-proof.md b/docs/listing-gtm/unit-4-campaign-buy-participate-proof.md new file mode 100644 index 0000000..a3926a2 --- /dev/null +++ b/docs/listing-gtm/unit-4-campaign-buy-participate-proof.md @@ -0,0 +1,66 @@ +# Unit 4: Campaign Spec - Buy + Participate + Proof + +## Objective +Use listing attention to create repeat participation behavior, not one-day speculation. + +Campaign anchor: + +`Buy -> Participate -> Proof` + +## Campaign Window +- Start: Listing Day (T0) +- End: T0 + 14 days +- Reporting: Daily short update in Telegram + +## Eligibility (simple v1) +1. Complete a buy action on listing venue. +2. Complete one participation action (retirement, contribution, or verified regeneration action). +3. Submit proof artifact (tx hash, certificate URL, or approved equivalent). + +Alternative eligibility path (non-holder): +1. Complete supporter opt-in. +2. Complete one participation action. +3. Submit proof artifact. + +## Reward Design Principles +1. No return guarantees. +2. Prefer non-cash/status rewards (recognition, access, badges, priority participation). +3. Publish transparent scoring criteria before start. + +## Recommended Scoring (v1) +1. Buy action: 1 point +2. Participation action: 2 points +3. Proof submission: 2 points +4. Weekly streak bonus: +2 points + +## Anti-Abuse Rules (minimum) +1. One identity per participant (email/Telegram + wallet check). +2. Duplicate proof artifacts invalid. +3. Team reserves right to disqualify clear sybil behavior. + +## Daily Ops Checklist (listing week) +1. Post daily standings/time-left update. +2. Publish proof highlights (top verified actions). +3. Answer FAQ and direct users to landing + proof pages. +4. Log incidents or rule disputes for next call. + +## Owner Matrix (fill now) +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Launch Deadline (UTC): T0 + +## Definition of Done +1. Campaign rules published publicly. +2. Submission format documented. +3. Daily operator assigned. +4. Dispute path defined. +5. End-of-campaign recap template ready. + +## Recap Template (post-campaign) +1. Total participants +2. Total verified actions +3. Total proof artifacts +4. Top learnings +5. Next-cycle improvements diff --git a/docs/listing-gtm/unit-5-telegram-ops-kit.md b/docs/listing-gtm/unit-5-telegram-ops-kit.md new file mode 100644 index 0000000..9f4e65b --- /dev/null +++ b/docs/listing-gtm/unit-5-telegram-ops-kit.md @@ -0,0 +1,51 @@ +# Unit 5: Telegram Ops Kit + +## Objective +Keep community communication clear, repetitive, and action-focused during listing week. + +## Core Rule +Every post should map to one of three outcomes: +1. Buy +2. Participate +3. Verify proof + +## Required Assets +1. Pinned message +2. Daily update template +3. Moderator quick replies +4. Escalation protocol for high-risk questions + +## Posting Cadence (14-day campaign) +1. One daily campaign status update +2. One daily proof highlight +3. One daily FAQ or education post +4. One reminder before day close + +## Content Types +1. `Status`: where campaign stands +2. `Proof`: real certificate or tx evidence +3. `Action`: exact next step users should take +4. `Safety`: no financial promises, risk disclosure + +## Escalation Rules +1. Price prediction or investment advice requests -> use approved disclaimer and redirect to campaign rules. +2. Technical proof disputes -> tag technical lead and pause public argument. +3. Exchange operations complaints -> route to exchange ops owner. + +## Owner Matrix (fill now) +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Start Date (UTC): T0 + +## Definition of Done +1. Pinned message posted and locked. +2. 14 daily updates pre-drafted. +3. Moderator quick replies approved. +4. Escalation owner list published internally. + +## File References +1. `templates/telegram-pinned-message.txt` +2. `templates/telegram-daily-update.md` +3. `templates/telegram-mod-quick-replies.md` diff --git a/docs/listing-gtm/unit-6-exchange-mm-runbook.md b/docs/listing-gtm/unit-6-exchange-mm-runbook.md new file mode 100644 index 0000000..76b417a --- /dev/null +++ b/docs/listing-gtm/unit-6-exchange-mm-runbook.md @@ -0,0 +1,62 @@ +# Unit 6: Exchange and Market-Making Runbook + +## Objective +Protect market quality and response speed during listing launch and the first 14 days. + +## Scope +1. Exchange listing coordination +2. Market-making guardrails +3. Incident response and escalation + +## Pre-Listing Checklist +1. Confirm final listing timestamp and timezone. +2. Confirm spot pair symbol and decimals. +3. Confirm deposit/withdrawal status windows. +4. Confirm market-maker contact channels and backup contacts. +5. Confirm spread/depth guardrails in writing. +6. Confirm internal incident owner and 24h reachability. + +## Market Quality Guardrails (fill exact numbers) +1. Target spread: +2. Minimum top-of-book depth: +3. Max tolerated slippage at standard clip: +4. Requote interval: +5. Circuit-breaker condition: + +## Listing-Day Timeline +1. T-120 min: final systems check, confirm contacts online. +2. T-60 min: freeze non-critical changes, open ops log. +3. T-15 min: verify exchange status page and internal channels. +4. T0: listing open, publish official links in Telegram. +5. T+15 min: first market quality check. +6. T+60 min: first public proof/status post. +7. T+4h: publish summary and next checkpoint. +8. T+24h: day-one report. + +## Incident Severity +1. Sev 1: trading unavailable, wrong market config, or security event. +2. Sev 2: sustained spread/depth outside guardrails. +3. Sev 3: isolated user complaints or temporary latency. + +## Incident Response +1. Log timestamp, symptom, and impact. +2. Assign incident lead. +3. Notify exchange and market-maker within 5 minutes for Sev 1/2. +4. Publish community update when user impact exists. +5. Close with root cause and mitigation action. + +## Owner Matrix (fill now) +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Launch Date (UTC): T0 + +## Definition of Done +1. Guardrails documented and signed off. +2. Contacts and escalation tree tested. +3. Listing-day timeline assigned by owner. +4. Incident template ready. + +## File Reference +1. `templates/listing-day-ops-log.md` diff --git a/docs/listing-gtm/unit-7-kpi-dashboard-spec.md b/docs/listing-gtm/unit-7-kpi-dashboard-spec.md new file mode 100644 index 0000000..fa3a72c --- /dev/null +++ b/docs/listing-gtm/unit-7-kpi-dashboard-spec.md @@ -0,0 +1,61 @@ +# Unit 7: KPI Dashboard Spec + +## Objective +Measure whether listing attention converts into durable, proof-linked participation. + +## KPI Categories +1. Holder growth +2. Holder retention +3. Participation actions +4. Proof outputs +5. Market quality + +## Weekly Core KPIs +1. `new_holders_weekly` +2. `holder_retention_30d_pct` +3. `participants_with_verified_action` +4. `verification_actions_total` +5. `credits_retired_total` +6. `certificates_published_total` +7. `buy_sell_ratio` +8. `avg_spread_bps` +9. `top_of_book_depth_usd` + +## KPI Definitions +1. `holder_retention_30d_pct` = holders from day N still holding on day N+30 / holders on day N * 100 +2. `participants_with_verified_action` = distinct participants with at least one accepted proof artifact +3. `buy_sell_ratio` = buy volume / sell volume over same period +4. `avg_spread_bps` = average best ask-bid spread in basis points during tracking window + +## Data Sources (fill now) +1. Exchange reporting source: +2. Market-maker reporting source: +3. On-chain/indexer source: +4. Internal campaign database/source: + +## Cadence +1. Daily snapshot during first 14 days +2. Weekly rollup for leadership call +3. Monthly summary after listing cycle + +## Thresholds (fill exact targets) +1. Green threshold: +2. Yellow threshold: +3. Red threshold: + +## Owner Matrix (fill now) +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- First Reporting Date (UTC): T0+24h + +## Definition of Done +1. KPI sheet created with all required columns. +2. Data source owner assigned for each metric. +3. First baseline snapshot completed. +4. Weekly reporting rhythm active. + +## File References +1. `templates/kpi-weekly.csv` +2. `templates/kpi-dashboard-metrics.json` diff --git a/docs/listing-gtm/unit-8-staging-publish.md b/docs/listing-gtm/unit-8-staging-publish.md new file mode 100644 index 0000000..877907e --- /dev/null +++ b/docs/listing-gtm/unit-8-staging-publish.md @@ -0,0 +1,40 @@ +# Unit 8: Staging Publish + +## Objective +Publish the 3-page stack to staging so technical lead can validate a real URL flow. + +## Scope +1. Upload web bundle preserving relative paths. +2. Verify navigation and assets load. +3. Confirm proof feed read path works. + +## Required Inputs +1. Staging host or static bucket access. +2. Publisher credentials. +3. Bundle path: + `/Users/EcoWealth/dev/regenerative-compute/docs/listing-gtm/release/coinstore-gtm-2026-02-24T21-05-48-243Z/web` + +## Execution Steps +1. Upload entire `web/` folder contents as one deployment. +2. Open: + - `listing-landing.html` + - `supporter-opt-in.html` + - `proof-page.html` +3. Validate: + - top nav links + - styles and scripts + - proof feed table rendering +4. Capture one screenshot per page for signoff record. + +## Owner Matrix +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Target Time (UTC): T0-24h + +## Definition of Done +1. Staging URL live. +2. All three pages accessible from top nav. +3. No broken asset paths. +4. Screenshots posted to internal ops thread. diff --git a/docs/listing-gtm/unit-9-url-hydration-and-strict-preflight.md b/docs/listing-gtm/unit-9-url-hydration-and-strict-preflight.md new file mode 100644 index 0000000..ff0205a --- /dev/null +++ b/docs/listing-gtm/unit-9-url-hydration-and-strict-preflight.md @@ -0,0 +1,40 @@ +# Unit 9: URL Hydration and Strict Preflight + +## Objective +Replace placeholder URLs and verify launch artifacts are fully production-ready. + +## Scope +1. Inject live URLs into templates. +2. Run strict preflight checks. +3. Produce pass/fail record. + +## Required Inputs +1. `LANDING_URL` +2. `PROOF_URL` +3. `RULES_URL` (recommended: supporter opt-in page with `#campaign-rules`) + +## Commands +```bash +node docs/listing-gtm/scripts/apply-live-urls.mjs --landing --proof --rules +npm run gtm:preflight:strict +``` + +## Verification +1. Check templates: + - `templates/telegram-pinned-message.txt` + - `templates/telegram-daily-update.md` + - `templates/telegram-mod-quick-replies.md` + - `templates/telegram-14-day-posting-pack.md` +2. Ensure no `{{LANDING_URL}}`, `{{PROOF_URL}}`, `{{RULES_URL}}` tokens remain. + +## Owner Matrix +- DRI: TEMP_RELEASE_OWNER +- Approver: TEMP_RELEASE_OWNER +- Publisher: TEMP_RELEASE_OWNER +- Fallback Publisher: TEMP_RELEASE_OWNER +- Target Time (UTC): T0-12h + +## Definition of Done +1. URL patch command completed successfully. +2. `gtm:preflight:strict` passes. +3. URL token audit confirms no unresolved placeholders. diff --git a/docs/listing-gtm/web/README.md b/docs/listing-gtm/web/README.md new file mode 100644 index 0000000..328d111 --- /dev/null +++ b/docs/listing-gtm/web/README.md @@ -0,0 +1,43 @@ +# Listing Web Stack + +Three-page launch flow: + +1. `listing-landing.html` +2. `supporter-opt-in.html` +3. `proof-page.html` + +Supporting assets: + +1. `assets/regen-campaign.css` +2. `assets/regen-campaign.js` +3. `proof-feed.json` + +## Publish Order +1. Publish all files in this folder with relative paths intact. +2. Set final public URLs in `../templates/telegram-link-values.md`. +3. Use `supporter-opt-in.html#campaign-rules` as `RULES_URL`. +4. Optional fast patch command when URLs are ready: + `node ../scripts/apply-live-urls.mjs --landing <...> --proof <...> --rules <...>` +5. Run preflight: + `npm run gtm:preflight` +6. For launch-time strict check (all URLs must be live): + `npm run gtm:preflight:strict` + +## Brand Mode +Default brand: `regen` + +Switch to Bridge.eco mode by adding query param: +- `listing-landing.html?brand=bridge` +- `supporter-opt-in.html?brand=bridge` +- `proof-page.html?brand=bridge` + +Notes: +1. Nav links preserve `brand` query once selected. +2. Brand mode is remembered in browser local storage. +3. Supported values: `regen`, `bridge`, `bridge.eco`. + +## Live Data Update +Update `proof-feed.json` during listing week. `proof-page.html` loads this file directly and falls back to sample data only if fetch fails. + +Update command: +`node ../scripts/update-proof-feed.mjs --retirement-id <...> --tx-hash <...> --certificate-url <...> --credits <...>` diff --git a/docs/listing-gtm/web/assets/regen-campaign.css b/docs/listing-gtm/web/assets/regen-campaign.css new file mode 100644 index 0000000..0a0735f --- /dev/null +++ b/docs/listing-gtm/web/assets/regen-campaign.css @@ -0,0 +1,516 @@ +:root { + --ink-900: #102133; + --ink-700: #324b63; + --ink-500: #5f7a94; + --line-300: #a8bfd2; + --line-200: #cbdbe7; + --paper: rgba(255, 255, 255, 0.9); + --mist: #eef4fa; + --leaf: #0f7a58; + --leaf-strong: #09583f; + --primary-grad-top: #148861; + --primary-grad-bottom: #0c6a4d; + --sky: #e3f0ff; + --mint: #ddf3e7; + --warn-bg: #fff4ea; + --warn-ink: #7f4f2c; + --brand-dot-top: #0b9367; + --brand-dot-bottom: #056348; + --brand-dot-shadow: rgba(15, 122, 88, 0.14); + --kicker-line: #8cccad; + --kicker-bg: #e7f7ee; + --kicker-ink: #0a6f4d; + --loop-dot-line: #8ec9af; + --loop-dot-ink: #0e6649; + --message-line: #99ccba; + --message-bg: #ecf8f2; + --message-ink: #0a5f43; + --body-accent: #d7eadf; + --body-grad-start: #eff4f9; + --body-grad-end: #dfe9f3; +} + +body[data-brand="bridge"] { + --line-300: #9ebcd7; + --line-200: #c7daeb; + --mist: #e9f2fb; + --leaf: #0b67ab; + --leaf-strong: #0a4f82; + --primary-grad-top: #0f79c3; + --primary-grad-bottom: #0a5a93; + --sky: #e1eefb; + --mint: #e4f0fb; + --brand-dot-top: #33b2ef; + --brand-dot-bottom: #1f6cb9; + --brand-dot-shadow: rgba(34, 126, 206, 0.18); + --kicker-line: #8fbbdf; + --kicker-bg: #e5f1fb; + --kicker-ink: #0c5e96; + --loop-dot-line: #8fb3d8; + --loop-dot-ink: #135989; + --message-line: #9fc1df; + --message-bg: #edf5fc; + --message-ink: #114d78; + --body-accent: #d6e6f5; + --body-grad-start: #eef4fb; + --body-grad-end: #dee9f7; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + min-height: 100vh; + color: var(--ink-900); + font-family: "Sora", "Avenir Next", sans-serif; + background: + radial-gradient(110% 95% at 5% 0%, #ffffff 0%, transparent 60%), + radial-gradient(100% 90% at 100% 100%, var(--body-accent) 0%, transparent 65%), + linear-gradient(155deg, var(--body-grad-start), var(--body-grad-end)); +} + +a { + color: var(--leaf); +} + +.page-shell { + width: min(1120px, 100% - 2rem); + margin: 1.2rem auto 2rem; +} + +.top-nav { + position: sticky; + top: 0.8rem; + z-index: 30; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.7rem 0.9rem; + border: 1px solid var(--line-300); + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(8px); + box-shadow: 0 14px 30px rgba(16, 33, 51, 0.1); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + font-family: "Fraunces", "Georgia", serif; + font-weight: 700; + letter-spacing: 0.01em; + color: var(--ink-900); + text-decoration: none; +} + +.brand-dot { + width: 0.8rem; + height: 0.8rem; + border-radius: 999px; + background: linear-gradient(180deg, var(--brand-dot-top), var(--brand-dot-bottom)); + box-shadow: 0 0 0 4px var(--brand-dot-shadow); +} + +.nav-links { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.nav-link { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line-300); + border-radius: 999px; + padding: 0.45rem 0.8rem; + text-decoration: none; + font-size: 0.86rem; + color: var(--ink-700); + background: #ffffff; +} + +.nav-link.is-active { + color: #ffffff; + background: var(--leaf); + border-color: var(--leaf-strong); +} + +.hero { + margin-top: 0.95rem; + border: 1px solid var(--line-300); + border-radius: 22px; + background: var(--paper); + box-shadow: 0 22px 48px rgba(16, 33, 51, 0.12); + overflow: hidden; + animation: rise-in 360ms ease-out; +} + +.hero-body { + padding: clamp(1.1rem, 3vw, 2rem); +} + +.kicker { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid var(--kicker-line); + background: var(--kicker-bg); + color: var(--kicker-ink); + padding: 0.36rem 0.66rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; + font-weight: 600; +} + +.hero h1 { + margin: 0.7rem 0 0.55rem; + font-family: "Fraunces", "Georgia", serif; + font-size: clamp(2rem, 5vw, 3.3rem); + line-height: 1.03; + letter-spacing: -0.012em; +} + +.hero p { + margin: 0; + max-width: 70ch; + color: var(--ink-700); +} + +.hero-cta-row { + display: flex; + flex-wrap: wrap; + gap: 0.62rem; + margin-top: 1.1rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: 1px solid var(--line-300); + background: #ffffff; + color: var(--ink-900); + padding: 0.62rem 0.95rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + color: #ffffff; + border-color: var(--leaf-strong); + background: linear-gradient(180deg, var(--primary-grad-top), var(--primary-grad-bottom)); +} + +.btn-ghost { + background: rgba(255, 255, 255, 0.54); +} + +.section { + margin-top: 0.85rem; + border: 1px solid var(--line-300); + border-radius: 18px; + background: var(--paper); + box-shadow: 0 12px 28px rgba(16, 33, 51, 0.08); + padding: clamp(1rem, 2.2vw, 1.4rem); +} + +.section h2 { + margin: 0 0 0.7rem; + font-family: "Fraunces", "Georgia", serif; + font-size: clamp(1.4rem, 2.7vw, 1.95rem); + line-height: 1.06; +} + +.section .subtle { + margin: 0; + color: var(--ink-700); +} + +.grid-3, +.grid-2 { + display: grid; + gap: 0.68rem; + margin-top: 0.8rem; +} + +.grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.card { + border: 1px solid var(--line-200); + border-radius: 14px; + background: + radial-gradient(90% 120% at 100% 0%, rgba(227, 240, 255, 0.56), transparent), + #ffffff; + padding: 0.9rem; +} + +.card h3 { + margin: 0 0 0.4rem; + font-size: 1.02rem; +} + +.card p { + margin: 0; + color: var(--ink-700); + font-size: 0.95rem; +} + +.loop-step { + display: flex; + gap: 0.62rem; +} + +.loop-step strong { + width: 1.6rem; + height: 1.6rem; + flex-shrink: 0; + border-radius: 999px; + border: 1px solid var(--loop-dot-line); + background: var(--mint); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.82rem; + color: var(--loop-dot-ink); +} + +.panel { + border: 1px solid var(--line-200); + border-radius: 14px; + background: #ffffff; + padding: 0.86rem; +} + +.panel h3 { + margin: 0 0 0.34rem; + font-size: 1rem; +} + +.panel p { + margin: 0; + color: var(--ink-700); +} + +.fine-print { + margin-top: 0.72rem; + border-left: 3px solid #df8d53; + background: var(--warn-bg); + color: var(--warn-ink); + padding: 0.58rem 0.68rem; + border-radius: 8px; +} + +.kv-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.62rem; +} + +.kv { + border: 1px solid var(--line-200); + border-radius: 12px; + background: #ffffff; + padding: 0.7rem; +} + +.kv .k { + font-size: 0.72rem; + color: var(--ink-500); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.kv .v { + margin-top: 0.36rem; + font-size: 1rem; + word-break: break-word; +} + +.metric-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.62rem; + margin-top: 0.72rem; +} + +.metric { + border: 1px solid var(--line-200); + border-radius: 13px; + background: #ffffff; + padding: 0.72rem; +} + +.metric .name { + font-size: 0.74rem; + color: var(--ink-500); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.metric .value { + margin-top: 0.32rem; + font-size: 1.15rem; + font-weight: 700; +} + +.mono { + font-family: "IBM Plex Mono", "Menlo", monospace; +} + +.table-wrap { + margin-top: 0.68rem; + border: 1px solid var(--line-200); + border-radius: 12px; + overflow: auto; + background: #ffffff; +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, +td { + text-align: left; + border-top: 1px solid var(--line-200); + padding: 0.58rem 0.62rem; + font-size: 0.91rem; + vertical-align: top; +} + +th { + border-top: none; + background: var(--mist); + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.form-grid { + display: grid; + gap: 0.68rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field { + display: grid; + gap: 0.3rem; +} + +.field label { + font-size: 0.84rem; + color: var(--ink-700); +} + +.field input, +.field select, +.field textarea { + width: 100%; + border-radius: 10px; + border: 1px solid var(--line-300); + background: #ffffff; + color: var(--ink-900); + font: inherit; + padding: 0.56rem 0.62rem; +} + +.field textarea { + min-height: 84px; + resize: vertical; +} + +.field.full { + grid-column: 1 / -1; +} + +.chip-set { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + border-radius: 999px; + border: 1px solid var(--line-300); + background: #ffffff; + padding: 0.38rem 0.58rem; + font-size: 0.84rem; +} + +.chip input { + margin: 0; +} + +.checkline { + display: flex; + align-items: flex-start; + gap: 0.45rem; +} + +.message { + margin-top: 0.7rem; + border-radius: 10px; + border: 1px solid var(--message-line); + background: var(--message-bg); + color: var(--message-ink); + padding: 0.62rem 0.7rem; +} + +.footer-note { + margin-top: 1rem; + color: var(--ink-500); + font-size: 0.85rem; +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 900px) { + .grid-3, + .grid-2, + .metric-row, + .form-grid, + .kv-grid { + grid-template-columns: 1fr; + } + + .top-nav { + position: static; + } +} diff --git a/docs/listing-gtm/web/assets/regen-campaign.js b/docs/listing-gtm/web/assets/regen-campaign.js new file mode 100644 index 0000000..d6b0a49 --- /dev/null +++ b/docs/listing-gtm/web/assets/regen-campaign.js @@ -0,0 +1,219 @@ +const SUPPORTER_STORAGE_KEY = "regen_listing_supporter_profile_v1"; +const PROOF_STORAGE_KEY = "regen_listing_local_proofs_v1"; +const BRAND_STORAGE_KEY = "regen_listing_brand_mode_v1"; + +const BRAND_COPY = { + regen: { + stackName: "Regen AI Supporter Stack", + }, + bridge: { + stackName: "Bridge.eco Supporter Stack", + }, +}; + +function safeJsonParse(input, fallback) { + try { + return JSON.parse(input); + } catch { + return fallback; + } +} + +function slug(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function normalizeBrand(raw) { + const value = String(raw || "").trim().toLowerCase(); + if (!value) return "regen"; + if (value === "bridge" || value === "bridge.eco" || value.startsWith("bridge")) { + return "bridge"; + } + return "regen"; +} + +function getStoredBrand() { + try { + return normalizeBrand(localStorage.getItem(BRAND_STORAGE_KEY)); + } catch { + return "regen"; + } +} + +function setStoredBrand(brand) { + try { + localStorage.setItem(BRAND_STORAGE_KEY, normalizeBrand(brand)); + } catch { + // no-op + } +} + +function getBrandFromQuery() { + try { + const params = new URLSearchParams(window.location.search); + const brand = params.get("brand"); + return brand ? normalizeBrand(brand) : null; + } catch { + return null; + } +} + +function detectBrand() { + const fromQuery = getBrandFromQuery(); + if (fromQuery) { + setStoredBrand(fromQuery); + return fromQuery; + } + + return getStoredBrand(); +} + +function applyBrandText(brand) { + const copy = BRAND_COPY[normalizeBrand(brand)] || BRAND_COPY.regen; + document.querySelectorAll("[data-brand-text]").forEach((node) => { + const key = node.getAttribute("data-brand-text"); + if (!key || !copy[key]) return; + node.textContent = copy[key]; + }); +} + +function decorateRelativeLinks(brand) { + document.querySelectorAll("a[href]").forEach((link) => { + const raw = link.getAttribute("href") || ""; + if (!raw.startsWith("./")) return; + if (!raw.includes(".html")) return; + if (raw.includes("brand=")) return; + + const [pathWithQuery, hashPart] = raw.split("#"); + const joiner = pathWithQuery.includes("?") ? "&" : "?"; + const next = `${pathWithQuery}${joiner}brand=${encodeURIComponent(brand)}${ + hashPart ? `#${hashPart}` : "" + }`; + link.setAttribute("href", next); + }); +} + +function applyBrand() { + if (typeof window === "undefined" || typeof document === "undefined") { + return "regen"; + } + + const brand = detectBrand(); + document.body.setAttribute("data-brand", brand); + applyBrandText(brand); + decorateRelativeLinks(brand); + return brand; +} + +function makeSupporterId(name, identityRef) { + const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const left = slug(name).slice(0, 10) || "supporter"; + const right = slug(identityRef).slice(0, 8) || "community"; + return `rg-${left}-${right}-${stamp}`; +} + +function loadSupporterProfile() { + const raw = localStorage.getItem(SUPPORTER_STORAGE_KEY); + return raw ? safeJsonParse(raw, null) : null; +} + +function saveSupporterProfile(profile) { + localStorage.setItem(SUPPORTER_STORAGE_KEY, JSON.stringify(profile)); +} + +function loadLocalProofs() { + const raw = localStorage.getItem(PROOF_STORAGE_KEY); + const parsed = raw ? safeJsonParse(raw, []) : []; + return Array.isArray(parsed) ? parsed : []; +} + +function saveLocalProofs(items) { + localStorage.setItem(PROOF_STORAGE_KEY, JSON.stringify(items)); +} + +function appendLocalProof(item) { + const entries = loadLocalProofs(); + entries.unshift(item); + saveLocalProofs(entries.slice(0, 50)); +} + +function formatIsoDate(iso) { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString(); +} + +async function copyText(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const helper = document.createElement("textarea"); + helper.value = text; + document.body.appendChild(helper); + helper.select(); + document.execCommand("copy"); + helper.remove(); +} + +function downloadJson(filename, data) { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + +function buildSupporterPost(profile) { + const focus = (profile.stewardshipFocus || []).join(", ") || "mixed"; + const walletRef = profile.walletReference || "none"; + const email = profile.contactEmail || "none"; + const telegram = profile.telegramHandle || "none"; + return [ + "#supporter-optin", + `supporter_id: ${profile.supporterId}`, + `participation_mode: ${profile.participationMode}`, + `offset_path: ${profile.offsetPath}`, + `telegram: ${telegram}`, + `contact_email: ${email}`, + `wallet_reference: ${walletRef}`, + `horizon_months: ${profile.holdingHorizonMonths}`, + `monthly_regen_usd: ${profile.monthlyRegenUsd}`, + `ai_prompt_cadence: ${profile.aiPromptCadence}`, + `local_ecosystem: ${profile.localEcosystem}`, + `stewardship_focus: ${focus}`, + ].join("\n"); +} + +function buildProofPost(entry, supporterId) { + return [ + "#proof", + `supporter_id: ${supporterId || "unknown"}`, + `retirement_id: ${entry.retirementId}`, + `tx_hash: ${entry.txHash}`, + `certificate_url: ${entry.certificateUrl}`, + `note: ${entry.note || "proof submission"}`, + ].join("\n"); +} + +window.RegenCampaign = { + applyBrand, + makeSupporterId, + loadSupporterProfile, + saveSupporterProfile, + loadLocalProofs, + appendLocalProof, + formatIsoDate, + copyText, + downloadJson, + buildSupporterPost, + buildProofPost, +}; diff --git a/docs/listing-gtm/web/listing-landing.html b/docs/listing-gtm/web/listing-landing.html new file mode 100644 index 0000000..4e0286c --- /dev/null +++ b/docs/listing-gtm/web/listing-landing.html @@ -0,0 +1,159 @@ + + + + + + Regenerative AI Supporter Launch + + + + + + + +
+
+ + + Regen AI Supporter Stack + + +
+ +
+
+ Listing Campaign +

Sync your token conviction with every AI prompt.

+

+ This campaign is built for retail traders who want a long-term role in + regenerative AI. Buy access on exchange, or opt in as a non-holder supporter, + then verify ecological outcomes with public proof. +

+ +
+
+ +
+

The Sync Loop

+

+ The goal is not one-day volume. The goal is recurring alignment between + investment behavior, AI usage, and verifiable regeneration. +

+
+
+ 1 +
+

Take Position

+

Access the token through the listing venue and set your support horizon.

+
+
+
+ 2 +
+

Prompt with Intent

+

Pair AI prompting activity with a monthly regeneration commitment.

+
+
+
+ 3 +
+

Anchor to Stewardship

+

Select local ecosystem context like watershed, forest edge, soil, or coast.

+
+
+
+ 4 +
+

Verify Publicly

+

Publish tx hash and certificate references so the community can verify outcomes.

+
+
+
+
+ +
+

Retail Supporter Design

+
+
+

Long-Term by Default

+

+ Supporters choose a holding horizon and contribution rhythm to reduce + short-cycle behavior. +

+
+
+

Local Context Included

+

+ Every supporter can record local stewardship context, connecting global + AI use with place-based ecological priorities. +

+
+
+

Proof-Driven Culture

+

+ Campaign status is based on verified submissions and certificates, not + narrative alone. +

+
+
+
+ +
+

What to Do Right Now

+
+
+

Step A: Opt In

+

+ Fill supporter profile with AI cadence, holding horizon, and local ecosystem context. +

+

Open supporter opt-in page

+
+
+

Step B: Submit Proof

+

+ After completing a qualified action, submit proof entries and keep your + participation trackable. +

+

Open proof board

+
+
+

+ This page is informational. No return guarantees or investment promises are + made. Participation in digital assets carries risk. +

+
+ + +
+ + + + diff --git a/docs/listing-gtm/web/proof-feed.json b/docs/listing-gtm/web/proof-feed.json new file mode 100644 index 0000000..1ff0a5d --- /dev/null +++ b/docs/listing-gtm/web/proof-feed.json @@ -0,0 +1,31 @@ +{ + "updated_at": "2026-02-24T12:00:00Z", + "totals": { + "retirements": 3, + "credits_retired": "14.320000", + "supporters_opted_in": 27 + }, + "recent_retirements": [ + { + "date": "2026-02-24", + "retirement_id": "WyLaunch001", + "tx_hash": "ABC123-LAUNCH-TX", + "certificate_url": "https://regen.network/certificate/sample-001", + "notes": "Listing day pooled retirement" + }, + { + "date": "2026-02-23", + "retirement_id": "WyLaunch000", + "tx_hash": "ABC122-PRELAUNCH", + "certificate_url": "https://regen.network/certificate/sample-000", + "notes": "Pre-launch rehearsal batch" + }, + { + "date": "2026-02-22", + "retirement_id": "WyPilot199", + "tx_hash": "ABC121-PILOT", + "certificate_url": "https://regen.network/certificate/sample-199", + "notes": "Pilot run verification" + } + ] +} diff --git a/docs/listing-gtm/web/proof-page.html b/docs/listing-gtm/web/proof-page.html new file mode 100644 index 0000000..ad536f0 --- /dev/null +++ b/docs/listing-gtm/web/proof-page.html @@ -0,0 +1,320 @@ + + + + + + Regenerative AI Proof Board + + + + + + + +
+
+ + + Regen AI Supporter Stack + + +
+ +
+
+ Verification Layer +

Proof over hype.

+

+ This board tracks regeneration actions and supporter participation evidence. + Use it as the primary reference for campaign credibility. +

+ +
+
+ +
+

Campaign Metrics

+
+
+
Total Retirements
+
0
+
+
+
Credits Retired
+
0.000000
+
+
+
Opted-In Supporters
+
0
+
+
+ +
+ +
+

Verified Retirement Feed

+
+ + + + + + + + + + + +
DateRetirement IDTx HashCertificateNotes
+
+
+ +
+

Local Proof Submissions (Operator Cache)

+

+ This section stores quick submissions in local browser storage during live ops. + It is useful for triage before final indexing. +

+
+ + + + + + + + + + + +
SubmittedRetirement IDTx HashCertificate URLNote
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+ + + + + diff --git a/docs/listing-gtm/web/supporter-opt-in.html b/docs/listing-gtm/web/supporter-opt-in.html new file mode 100644 index 0000000..c5f802b --- /dev/null +++ b/docs/listing-gtm/web/supporter-opt-in.html @@ -0,0 +1,457 @@ + + + + + + Supporter Opt-In | Regenerative AI + + + + + + + +
+
+ + + Regen AI Supporter Stack + + +
+ +
+
+ Long-Term Supporter Intake +

Opt in with intent, not just entry timing.

+

+ Anyone can opt in, including non-holders. This form captures your support profile: + participation mode, AI prompt cadence, and local ecosystem context. After submission, + copy your opt-in block for community proof. +

+
+
+ +
+

Open Opt-In Paths

+
+
+

Holder Path

+

Hold token and attach support behavior to ongoing AI prompting.

+
+
+

Non-Holder Path

+

No token required. Join as a contributor and submit verifiable proof actions.

+
+
+

Hybrid Path

+

Combine token alignment with direct recurring regeneration support.

+
+
+
+ +
+

Supporter Profile

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + +
+
+
+ + +
+
+ + +

+ Minimum contact requirement: provide Telegram handle or contact email. +

+
+
+
+ + + +
+
+
+
+
+ +
+

Current Profile Snapshot

+
+
+
Supporter ID
+
-
+
+
+
Telegram
+
-
+
+
+
Contact Email
+
-
+
+
+
Participation Mode
+
-
+
+
+
Offset Path
+
-
+
+
+
Wallet/Exchange Ref
+
-
+
+
+
Holding Horizon
+
-
+
+
+
Monthly Support
+
-
+
+
+
AI Cadence
+
-
+
+
+
Local Ecosystem Context
+
-
+
+
+ +

+ Supporter opt-in is a participation pledge, not a return contract. No financial + guarantees are implied by this form. +

+
+ +
+

Campaign Rules (Supporter Cycle)

+
+
+

Eligibility

+

+ 1) Anyone can complete opt-in profile (holder or non-holder). 2) Complete one + qualified participation action. 3) Submit proof block with valid tx hash and + certificate URL. +

+
+
+

Verification Standard

+

+ Proof must be publicly checkable. Duplicate artifacts or unverifiable references + are not eligible. +

+
+
+

Conduct and Safety

+

+ No price promises, no financial advice, no private-key requests. Use only official links. +

+
+
+

Dispute Handling

+

+ Ops team logs disputes daily. Technical lead verifies proof disputes against public records. +

+
+
+
+ + + + + + + + + + + + + + + + + +
Submission BlockFormat
#supporter-optinsupporter_id, participation_mode, offset_path, telegram/contact_email, wallet_reference(optional), horizon_months, monthly_regen_usd, ai_prompt_cadence, local_ecosystem, stewardship_focus
#proofsupporter_id, retirement_id, tx_hash, certificate_url, note
+
+
+
+ + + + + diff --git a/package.json b/package.json index 0922368..13dfbbc 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", + "gtm:preflight": "node docs/listing-gtm/scripts/preflight-check.mjs", + "gtm:preflight:strict": "node docs/listing-gtm/scripts/preflight-check.mjs --strict-urls", + "gtm:bundle": "node docs/listing-gtm/scripts/create-release-bundle.mjs", "prepublishOnly": "npm run build" }, "keywords": [