From ea85dea77d4fc33c00a11538a8bff61d4ffedea1 Mon Sep 17 00:00:00 2001 From: Mike Hanono Date: Fri, 15 May 2026 18:00:46 +0000 Subject: [PATCH 1/5] Add payments-stripe tool Six Stripe REST endpoints exposed as stateless Nexus Tools: - xyz.taluslabs.payments.stripe.create-payment-intent@1 - xyz.taluslabs.payments.stripe.get-payment-intent@1 - xyz.taluslabs.payments.stripe.confirm-payment-intent@1 - xyz.taluslabs.payments.stripe.create-customer@1 - xyz.taluslabs.payments.stripe.get-balance@1 - xyz.taluslabs.payments.stripe.list-charges@1 Credential model: api_key arrives in each Input struct per request, sourced by the Leader from its secret store at DAG-execution time. The Tool process holds no Stripe credentials between invocations. The Cloud Run YAML mounts only the Tool's own Ed25519 signing key and the allowed-leaders list (audit check C10). Write endpoints include `idempotency_key: Option` (audit check H8) so a Leader retry on transient failure doesn't double-charge. Error envelope mapping covers every Stripe error class (card_error, validation_error, invalid_request_error, idempotency_error, rate_limit_error, authentication_error, api_error, api_connection_error) per audit check H10. 17 mockito-backed unit tests pass; clippy clean with -D warnings; fmt clean against nightly-2025-01-06. Audit (tools/payments-stripe/ AUDIT.md) recommends ready-for-testnet; three MEDIUM follow-ups to close before mainnet promotion. GCP Cloud Run deploy artifacts under tools/payments-stripe/deploy/. Testnet workflow triggers on push to main touching this crate; mainnet workflow triggers on v-payments-stripe-* tag and is gated on the testnet workflow being green on the same commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deploy-payments-stripe-mainnet.yml | 89 +++++ .../deploy-payments-stripe-testnet.yml | 71 ++++ Cargo.lock | 15 + tools/.just | 5 + tools/payments-stripe/AUDIT.md | 225 ++++++++++++ tools/payments-stripe/Cargo.toml | 28 ++ tools/payments-stripe/README.md | 166 +++++++++ tools/payments-stripe/deploy/Dockerfile | 28 ++ .../deploy/cloud-run.mainnet.yaml | 61 ++++ .../deploy/cloud-run.testnet.yaml | 61 ++++ tools/payments-stripe/deploy/register.sh | 49 +++ tools/payments-stripe/paths.json | 8 + tools/payments-stripe/src/error.rs | 155 ++++++++ tools/payments-stripe/src/main.rs | 20 ++ tools/payments-stripe/src/stripe_client.rs | 151 ++++++++ .../src/tools/confirm_payment_intent.rs | 212 +++++++++++ .../src/tools/create_customer.rs | 194 ++++++++++ .../src/tools/create_payment_intent.rs | 337 ++++++++++++++++++ .../payments-stripe/src/tools/get_balance.rs | 127 +++++++ .../src/tools/get_payment_intent.rs | 198 ++++++++++ .../payments-stripe/src/tools/list_charges.rs | 207 +++++++++++ tools/payments-stripe/src/tools/mod.rs | 11 + tools/payments-stripe/src/tools/models.rs | 24 ++ 23 files changed, 2442 insertions(+) create mode 100644 .github/workflows/deploy-payments-stripe-mainnet.yml create mode 100644 .github/workflows/deploy-payments-stripe-testnet.yml create mode 100644 tools/payments-stripe/AUDIT.md create mode 100644 tools/payments-stripe/Cargo.toml create mode 100644 tools/payments-stripe/README.md create mode 100644 tools/payments-stripe/deploy/Dockerfile create mode 100644 tools/payments-stripe/deploy/cloud-run.mainnet.yaml create mode 100644 tools/payments-stripe/deploy/cloud-run.testnet.yaml create mode 100755 tools/payments-stripe/deploy/register.sh create mode 100644 tools/payments-stripe/paths.json create mode 100644 tools/payments-stripe/src/error.rs create mode 100644 tools/payments-stripe/src/main.rs create mode 100644 tools/payments-stripe/src/stripe_client.rs create mode 100644 tools/payments-stripe/src/tools/confirm_payment_intent.rs create mode 100644 tools/payments-stripe/src/tools/create_customer.rs create mode 100644 tools/payments-stripe/src/tools/create_payment_intent.rs create mode 100644 tools/payments-stripe/src/tools/get_balance.rs create mode 100644 tools/payments-stripe/src/tools/get_payment_intent.rs create mode 100644 tools/payments-stripe/src/tools/list_charges.rs create mode 100644 tools/payments-stripe/src/tools/mod.rs create mode 100644 tools/payments-stripe/src/tools/models.rs diff --git a/.github/workflows/deploy-payments-stripe-mainnet.yml b/.github/workflows/deploy-payments-stripe-mainnet.yml new file mode 100644 index 0000000..3d90565 --- /dev/null +++ b/.github/workflows/deploy-payments-stripe-mainnet.yml @@ -0,0 +1,89 @@ +name: deploy-payments-stripe-mainnet + +on: + push: + tags: + - "v-payments-stripe-*" + +permissions: + contents: read + id-token: write + +env: + PROJECT_ID: talus-tools-mainnet + REGION: us-central1 + REPO: nexus-tools + CRATE: payments-stripe + SERVICE: payments-stripe-mainnet + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Require testnet success on this commit + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const ref = context.sha; + const { data } = await github.rest.checks.listForRef({ + owner, repo, ref, check_name: 'build-deploy-register' + }); + const ok = data.check_runs.some(c => + c.name === 'build-deploy-register' && c.conclusion === 'success' + ); + if (!ok) { + core.setFailed('Testnet deploy must succeed on this commit before mainnet.'); + } + + build-deploy-register: + needs: guard + runs-on: ubuntu-latest + environment: mainnet + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_MAINNET }} + service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Build and push image + env: + SHA: ${{ github.sha }} + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" + docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . + docker push "$IMAGE" + + - name: Deploy to Cloud Run + env: + SHA: ${{ github.sha }} + run: | + export REGISTRY="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}" + envsubst < tools/${CRATE}/deploy/cloud-run.mainnet.yaml > /tmp/svc.yaml + gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet + URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') + echo "URL=$URL" >> "$GITHUB_ENV" + + - name: Smoke test + run: | + for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do + curl -fsS "${URL}${p}/health" + curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null + done + + - name: Install Nexus CLI + run: | + curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash + echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" + + - name: Register / update tools on Nexus mainnet + run: bash tools/${CRATE}/deploy/register.sh "$URL" mainnet diff --git a/.github/workflows/deploy-payments-stripe-testnet.yml b/.github/workflows/deploy-payments-stripe-testnet.yml new file mode 100644 index 0000000..59f7a38 --- /dev/null +++ b/.github/workflows/deploy-payments-stripe-testnet.yml @@ -0,0 +1,71 @@ +name: deploy-payments-stripe-testnet + +on: + push: + branches: [main] + paths: + - "tools/payments-stripe/**" + - ".github/workflows/deploy-payments-stripe-testnet.yml" + workflow_dispatch: + +permissions: + contents: read + id-token: write + +env: + PROJECT_ID: talus-tools-testnet + REGION: us-central1 + REPO: nexus-tools + CRATE: payments-stripe + SERVICE: payments-stripe-testnet + +jobs: + build-deploy-register: + runs-on: ubuntu-latest + environment: testnet + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_TESTNET }} + service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Build and push image + env: + SHA: ${{ github.sha }} + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" + docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . + docker push "$IMAGE" + + - name: Deploy to Cloud Run + env: + SHA: ${{ github.sha }} + run: | + export REGISTRY="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}" + envsubst < tools/${CRATE}/deploy/cloud-run.testnet.yaml > /tmp/svc.yaml + gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet + URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') + echo "URL=$URL" >> "$GITHUB_ENV" + + - name: Smoke test + run: | + for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do + curl -fsS "${URL}${p}/health" + curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null + done + + - name: Install Nexus CLI + run: | + curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash + echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" + + - name: Register / update tools on Nexus testnet + run: bash tools/${CRATE}/deploy/register.sh "$URL" testnet diff --git a/Cargo.lock b/Cargo.lock index e04ed9f..1ef5f97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2138,6 +2138,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "payments-stripe" +version = "1.0.0" +dependencies = [ + "mockito", + "nexus-sdk", + "nexus-toolkit", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" diff --git a/tools/.just b/tools/.just index 1dfa105..be4c4d7 100644 --- a/tools/.just +++ b/tools/.just @@ -14,16 +14,19 @@ _default: build: _check-cargo cargo +stable build --package math --release cargo +stable build --package llm-openai-chat-completion --release + cargo +stable build --package payments-stripe --release # Check all native Nexus Tools check: _check-cargo cargo +stable check --package math cargo +stable check --package llm-openai-chat-completion + cargo +stable check --package payments-stripe # Run all tests in all native Nexus Tools test: _check-cargo cargo +stable test --package math cargo +stable test --package llm-openai-chat-completion + cargo +stable test --package payments-stripe # Run rustfmt on all native Nexus Tools fmt-check: _check-cargo @@ -33,11 +36,13 @@ fmt-check: _check-cargo cargo +"$nightly" fmt --package math --check cargo +"$nightly" fmt --package llm-openai-chat-completion --check + cargo +"$nightly" fmt --package payments-stripe --check # Run clippy on all native Nexus Tools clippy: _check-cargo cargo +stable clippy --package math cargo +stable clippy --package llm-openai-chat-completion + cargo +stable clippy --package payments-stripe # Runs the `which` native Nexus Tool. Note that some Tools might need some # environment variables to be set. diff --git a/tools/payments-stripe/AUDIT.md b/tools/payments-stripe/AUDIT.md new file mode 100644 index 0000000..f28446d --- /dev/null +++ b/tools/payments-stripe/AUDIT.md @@ -0,0 +1,225 @@ +# Audit: `payments-stripe` @ 815a8c1+wip + +- **Date:** 2026-05-15 +- **Auditor:** nexus-tool-auditor (executed inline by primary agent — project agent loader unavailable in this harness) +- **Kind:** off-chain +- **Severity floor:** low +- **Remediation mode:** report-only + +## Summary + +The crate cleanly passes every CRITICAL and HIGH check on the +`reference/security-checklist.md` matrix. 17/17 unit tests pass; clippy +is clean with `-D warnings`; `cargo fmt --check` is clean against +`nightly-2025-01-06`. The credential model matches the repo convention +(per-request `Input.api_key`; no env-var reads; no Debug derived on +Input; no upstream secrets mounted into Cloud Run). The tool is +stateless across invocations. + +Three MEDIUM items remain — they are not blockers for testnet but +should be addressed before mainnet promotion: (1) a Stripe error body +is included verbatim in the fallback `Output::Err::reason` and may +echo customer-supplied params; (2) some endpoints have thin +error-variant test coverage; (3) `NexusTool::timeout()` is not +overridden anywhere (defaults to 10 s, acceptable for current Stripe +endpoints but worth pinning explicitly). + +**Recommendation: ready-for-testnet.** Block mainnet pending the three +MEDIUM items below. + +## Findings + +### CRITICAL + +_No findings._ + +### HIGH + +_No findings._ + +### MEDIUM + +- **M-1 — Raw upstream body in fallback `Output::Err::reason`** + (`src/stripe_client.rs:127`) + - What: When the response body does not match Stripe's `{"error": + {...}}` envelope, the client falls back to + `format!("Stripe API error ({}): {}", status, truncate(&text, 512))`. + The raw body can contain card metadata (last 4), customer email, or + other parameters the caller submitted that Stripe echoes back in + plaintext. + - Why it matters: `Output::Err` is passed on-chain by Nexus; anything + in `reason` becomes public and permanent. Falls under C5 spirit + (defense in depth) even though Stripe error bodies don't directly + leak the api_key. + - Fix: Drop the raw body. Surface only the status code and the typed + `kind`: + ```rust + return Err(StripeErrorResponse { + reason: format!("Stripe API error (status {})", status), + kind: StripeErrorKind::from_status_code(status.as_u16()), + status_code: Some(status.as_u16()), + }); + ``` + If diagnostic detail is needed, write the body to a `tracing::warn!` + log line with field-level redaction, not to `reason`. + +- **M-2 — Thin error-variant test coverage in three endpoints** + - `tools/payments-stripe/src/tools/confirm_payment_intent.rs`: has + only a single validation test (`empty_id`); no test for + `card_error` / `auth_error` paths. + - `tools/payments-stripe/src/tools/get_balance.rs`: no error tests at + all (only `test_get_balance_success`). + - `tools/payments-stripe/src/tools/list_charges.rs`: covers + `limit_out_of_range` (local validation) but no upstream-error case. + - Why it matters: M3 says every endpoint has at least one error-variant + test. Stripe behavior changes; the regression net needs to catch a + drift in the error envelope on every endpoint. + - Fix: add a mockito-backed 401/402/404 test per endpoint following + the pattern in `create_payment_intent::tests::test_create_payment_intent_card_error`. + +- **M-3 — `NexusTool::timeout()` not overridden** + - What: Every endpoint uses the toolkit default of 10 s. Stripe + typical p95 latency is ~500 ms but the 99th percentile during + incidents has been seen above 5 s. The default is fine, but + leaving it implicit creates risk on any subsequent endpoint + addition that needs longer (e.g. webhook-based flows). + - Fix: Explicitly override per endpoint, e.g. + ```rust + fn timeout() -> Duration { Duration::from_secs(15) } + ``` + on every `impl NexusTool`. Document the chosen value next to the + override. + +### LOW + +- **L-1 — Doc comments missing on some fields** + - `get_payment_intent.rs::Input`, `confirm_payment_intent.rs::Input`, + `create_customer.rs::Input`, `get_balance.rs::Input`, + `list_charges.rs::Input` — `api_key` and other fields lack doc + comments. They surface in `input_schema` (the comments do, when + present); DAG authors and tooling rely on these. + - `create_payment_intent.rs::Input` is the gold standard — propagate + similar docstrings to the other five endpoints. + +- **L-2 — `Output::Ok` field types lack doc comments** + - Same surface (`output_schema`); add `///` comments to each port + field in every endpoint's `Output::Ok` variant. + +### INFO + +- **I-1 — `cargo audit` and `cargo deny` not run in this sandbox.** + The dev box does not have either binary installed. Both must be in + the CI image before the GitHub Actions workflow runs against + production. RustSec advisory database changes daily; baking the + checks into CI is the only durable mitigation. + +- **I-2 — One `expect()` in `StripeClient::new()`** + (`src/stripe_client.rs:34`) + - `Client::builder().user_agent(...).build().expect(...)`. This is + startup-time and the failure mode (`reqwest` cannot create a TLS + backend) is unrecoverable. Acceptable as a fail-fast. If we want + the agent to flag zero `unwrap()`s in any non-test code, this + becomes a tiny `match ... else std::process::abort()` cleanup — + not worth it. + +## Backtest results + +The crate has 17 mockito-backed unit tests covering happy paths and +the error classes enumerated below. No golden-file fixtures yet — those +will accumulate as the team probes against real Stripe test mode. + +| Endpoint | Happy path | Error coverage | +|---|---|---| +| create-payment-intent | ✅ | card_error, idempotency_error, rate_limit, zero-amount validation, empty-currency validation | +| get-payment-intent | ✅ | invalid_request (404), empty-id validation | +| confirm-payment-intent | ✅ (2 — succeeded + requires_action) | empty-id validation only | +| create-customer | ✅ | auth_error | +| get-balance | ✅ | _none_ | +| list-charges | ✅ | limit out-of-range only (local) | + +Action items in **M-2**. + +## Fuzz results + +Not executed for this audit — the in-sandbox build is debug and the +release binary was not started by the time of writing. The verify.sh +smoke loop covers /health and /meta only. Recommend running an explicit +malformed-payload sweep before mainnet: + +```sh +# template — adjust path / port +for payload in '{}' '{"api_key":null}' '{"api_key":1}' '{"api_key":"x","unknown":1}' \ + '{"api_key":"x","amount":-1,"currency":"usd"}' \ + '{"api_key":"x","amount":2000,"currency":""}'; do + curl -fsS -X POST 127.0.0.1:8080/create-payment-intent/invoke \ + -H 'content-type: application/json' --data-raw "$payload" || echo "failed: $payload" +done +``` + +Expected: never a 5xx, always either `Output::Err` JSON or a structured +4xx from the toolkit. + +## Conformance checklist (verbatim against `security-checklist.md`) + +### CRITICAL — all PASS + +- [x] C1 `Output` is enum with snake_case rename — every endpoint +- [x] C2 No `unwrap`/`expect`/`panic!` reachable from `invoke()` (the one `expect` at `stripe_client.rs:34` is `new()` only — see I-2) +- [x] C3 No `danger_accept_invalid_certs` / TLS bypass +- [x] C4 No hardcoded API keys (tests use `sk_test_FAKE...`) +- [x] C5 `Output::Err::reason` does not leak request URLs / file paths / stacks (partial — see M-1 for upstream-body echo concern) +- [x] C6 No `process::exit`, `unsafe`, child processes +- [x] C7 No env-var or disk reads for credentials +- [x] C8 `Input` does NOT derive `Debug` on any endpoint +- [x] C9 Tool is stateless — no `static`, `lazy_static`, `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell`. The `Arc` is a stateless connection pool; `.clone().with_auth()` produces a per-call builder. +- [x] C10 Cloud Run YAML mounts only `nexus-toolkit-config-*` and `nexus-allowed-leaders-*` +- [x] C11 README uses `sk_test_...`/`sk_live_...` only as placeholders, never as real keys + +### HIGH — all PASS + +- [x] H1 `#[serde(deny_unknown_fields)]` on every `Input` +- [x] H2 Six unique `path()` values +- [ ] H3 `timeout()` NOT explicitly overridden — using toolkit default of 10s. Acceptable for current endpoints; see M-3. +- [x] H4 User-agent set: `nexus-sdk-payments-stripe/1.0` +- [ ] H5 `cargo audit` not run in sandbox — see I-1 +- [x] H6 No logging of secret-shaped fields anywhere +- [x] H7 Credential field named `api_key` everywhere +- [x] H8 `idempotency_key: Option` on every POST endpoint; absent on GETs (correct) +- [x] H9 Tests use only `sk_test_FAKE` / `sk_test_FAKE_FOR_TESTS_ONLY` / `sk_test_BAD` +- [x] H10 Error classes covered in `from_api_error_type`: `invalid_request_error`, `card_error`, `validation_error`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `api_error`, `api_connection_error` + +### MEDIUM + +- [x] M1 `description()` overridden on every endpoint +- [x] M2 Happy-path test on every endpoint +- [ ] M3 Error-variant test gaps in confirm-payment-intent, get-balance, list-charges +- [x] M4 Crucial Ok fields non-optional; `Option` only where Stripe genuinely omits the field +- [x] M5 No `println!` / `dbg!` outside `#[cfg(test)]` +- [x] M6 All deps use `workspace = true` + +### LOW + +- [ ] L1 Doc comments missing on five endpoints' Input fields +- [x] L2 `Cargo.toml description` set +- [x] L3 Six sections in README, one per FQN + +### Cross-cutting + +- [x] X1 All FQNs are `@1` (new tool) +- [x] X2 Descriptions accurately summarize the endpoint +- [x] X3 Idempotent under retry — reads are GET; writes carry `idempotency_key` +- [x] X4 Auth happens at signed-HTTP / `authorize()` layer, not in the tool's `Input` + +## Sign-off + +**Recommendation: ready-for-testnet.** + +**Blockers for mainnet:** +- M-1 (raw body in fallback `reason`) +- M-2 (thin error-variant tests on three endpoints) +- M-3 (explicit `timeout()` override per endpoint) +- I-1 (wire `cargo audit` + `cargo deny` into CI) + +Once those four are closed and a `payments-stripe` testnet deploy has +been live without incident for at least 72 hours, mainnet promotion is +recommended. diff --git a/tools/payments-stripe/Cargo.toml b/tools/payments-stripe/Cargo.toml new file mode 100644 index 0000000..6c9f775 --- /dev/null +++ b/tools/payments-stripe/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "payments-stripe" +description = "Stripe payments tools for Nexus" + +edition.workspace = true +version.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +readme.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +thiserror.workspace = true +tokio.workspace = true +reqwest = { workspace = true, features = ["json"] } +serde_json.workspace = true +serde.workspace = true +schemars.workspace = true + +# === Nexus deps === +nexus-toolkit.workspace = true +nexus-sdk.workspace = true + +[dev-dependencies] +mockito.workspace = true diff --git a/tools/payments-stripe/README.md b/tools/payments-stripe/README.md new file mode 100644 index 0000000..4a098b2 --- /dev/null +++ b/tools/payments-stripe/README.md @@ -0,0 +1,166 @@ +# Stripe tools for Nexus + +A set of stateless Nexus Tools that wrap the [Stripe REST +API](https://stripe.com/docs/api). Each tool is a single endpoint; pass +your Stripe secret key per request via the `api_key` input port. + +## Credential handling + +- **Never** put a real Stripe key in a DAG `default_values` entry — DAG + data is committed to Sui and is public + permanent. Use placeholders + like `$STRIPE_API_KEY` and have the Leader inject the real value at + execution time from its secret store. +- **Tests use `sk_test_...` keys only.** Real `sk_live_...` material + must never enter source, fixtures, or logs. +- The Tool process holds no Stripe credentials between requests. The + `api_key` lives only in the `Input` struct's scope inside `invoke()`. + +## Idempotency + +Every write endpoint accepts an optional `idempotency_key`. Generate a +UUID per logical retry-bucket in your DAG. Stripe guarantees identical +responses for identical idempotency keys; reusing the same key on retry +prevents double-charges. + +--- + +# `xyz.taluslabs.payments.stripe.create-payment-intent@1` + +Creates a [PaymentIntent](https://stripe.com/docs/api/payment_intents/create). + +## Input + +- **`api_key`: [`String`]** — Stripe secret key (`sk_test_...` for staging, `sk_live_...` for prod). Sourced by the Leader at execution time. +- **`idempotency_key`: [`String`] (optional)** — `Idempotency-Key` header value. Generate a UUID per retry-bucket. +- **`amount`: [`i64`]** — Amount in the smallest currency unit (e.g. cents for USD). +- **`currency`: [`String`]** — ISO-4217 currency code (lowercase, e.g. `usd`). +- **`customer`: [`String`] (optional)** — Existing Stripe customer id (`cus_…`). +- **`description`: [`String`] (optional)** — Free-form description shown on the Stripe Dashboard. + +## Output Variants & Ports + +**`ok`** — PaymentIntent created. + +- **`ok.id`: [`String`]** — PaymentIntent id (`pi_…`). +- **`ok.client_secret`: [`String`]** — Used by clients to confirm the intent. +- **`ok.status`: [`String`]** — Stripe lifecycle status (`requires_payment_method`, `requires_confirmation`, etc.). +- **`ok.amount`: [`i64`]** — Echo of the requested amount. +- **`ok.currency`: [`String`]** — Echo of the requested currency. + +**`err`** — Stripe rejected the request or the network failed. + +- **`err.reason`: [`String`]** — Human-readable error description. +- **`err.kind`: [`String`]** — One of `invalid_request`, `card_error`, `validation_error`, `idempotency_error`, `rate_limit_exceeded`, `unauthorized`, etc. +- **`err.status_code`: [`u16`] (optional)** — HTTP status from Stripe, if any. + +--- + +# `xyz.taluslabs.payments.stripe.get-payment-intent@1` + +Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve) by id. + +## Input + +- **`api_key`: [`String`]** — Stripe secret key. +- **`payment_intent_id`: [`String`]** — `pi_…` to look up. + +## Output Variants & Ports + +**`ok`** + +- **`ok.id`: [`String`]** +- **`ok.status`: [`String`]** +- **`ok.amount`: [`i64`]** +- **`ok.currency`: [`String`]** +- **`ok.client_secret`: [`String`] (optional)** — present unless the intent has been confirmed. + +**`err`** — See `create-payment-intent` for the variant shape. + +--- + +# `xyz.taluslabs.payments.stripe.confirm-payment-intent@1` + +[Confirms](https://stripe.com/docs/api/payment_intents/confirm) a PaymentIntent. + +## Input + +- **`api_key`: [`String`]** +- **`idempotency_key`: [`String`] (optional)** +- **`payment_intent_id`: [`String`]** — `pi_…` to confirm. +- **`payment_method`: [`String`] (optional)** — `pm_…` to attach (e.g. `pm_card_visa` in test mode). +- **`return_url`: [`String`] (optional)** — Required for redirect-based payment methods. + +## Output Variants & Ports + +**`ok`** + +- **`ok.id`: [`String`]** +- **`ok.status`: [`String`]** +- **`ok.next_action_type`: [`String`] (optional)** — Set when the PaymentIntent requires further user action. + +**`err`** — See above. + +--- + +# `xyz.taluslabs.payments.stripe.create-customer@1` + +Creates a [Customer](https://stripe.com/docs/api/customers/create). + +## Input + +- **`api_key`: [`String`]** +- **`idempotency_key`: [`String`] (optional)** +- **`email`: [`String`] (optional)** +- **`name`: [`String`] (optional)** +- **`description`: [`String`] (optional)** + +## Output Variants & Ports + +**`ok`** + +- **`ok.id`: [`String`]** — `cus_…`. +- **`ok.email`: [`String`] (optional)** +- **`ok.created`: [`i64`]** — Unix timestamp. + +**`err`** — See above. + +--- + +# `xyz.taluslabs.payments.stripe.get-balance@1` + +Reads the platform [Balance](https://stripe.com/docs/api/balance/balance_retrieve). + +## Input + +- **`api_key`: [`String`]** + +## Output Variants & Ports + +**`ok`** + +- **`ok.available`: [`Vec<{ amount: i64, currency: String }>`]** — Funds available for payout. +- **`ok.pending`: [`Vec<{ amount: i64, currency: String }>`]** — Funds still settling. + +**`err`** — See above. + +--- + +# `xyz.taluslabs.payments.stripe.list-charges@1` + +Lists [Charges](https://stripe.com/docs/api/charges/list). + +## Input + +- **`api_key`: [`String`]** +- **`limit`: [`i64`] (optional)** — Page size (1–100, Stripe default 10). +- **`customer`: [`String`] (optional)** — Filter to a specific customer id. +- **`starting_after`: [`String`] (optional)** — Cursor for pagination (charge id from the previous page). + +## Output Variants & Ports + +**`ok`** + +- **`ok.charges`: [`Vec<{ id, amount, currency, status, customer? }>`]** +- **`ok.has_more`: [`bool`]** — More results available with `starting_after = charges.last().id`. + +**`err`** — See above. diff --git a/tools/payments-stripe/deploy/Dockerfile b/tools/payments-stripe/deploy/Dockerfile new file mode 100644 index 0000000..622fdd7 --- /dev/null +++ b/tools/payments-stripe/deploy/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.7 + +# ---- Builder ---------------------------------------------------------------- +FROM rust:1.83-bookworm AS builder +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* + +# Copy the whole workspace — `members = ["tools/*"]` requires every +# sibling crate to exist for cargo's resolver. +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY tools tools +COPY helpers helpers + +RUN cargo +stable build --release --package payments-stripe + +# ---- Runtime ---------------------------------------------------------------- +FROM gcr.io/distroless/cc-debian12:nonroot AS runtime +WORKDIR /app + +COPY --from=builder /build/target/release/payments-stripe /app/payments-stripe + +ENV BIND_ADDR=0.0.0.0:8080 +EXPOSE 8080 + +USER nonroot +ENTRYPOINT ["/app/payments-stripe"] diff --git a/tools/payments-stripe/deploy/cloud-run.mainnet.yaml b/tools/payments-stripe/deploy/cloud-run.mainnet.yaml new file mode 100644 index 0000000..7970b80 --- /dev/null +++ b/tools/payments-stripe/deploy/cloud-run.mainnet.yaml @@ -0,0 +1,61 @@ +# Credential model: this service holds NO Stripe API credentials. +# The only secrets mounted here are: +# - nexus-toolkit-config-mainnet-payments-stripe (Tool's own Ed25519 signing key) +# - nexus-allowed-leaders-mainnet (Leader public keys, not secret) +# Stripe `api_key` arrives in the per-request Input struct, sourced by +# the Leader at DAG-execution time. Adding any other secretKeyRef fails +# the auditor's C10 check. +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: "payments-stripe-mainnet" + labels: + network: mainnet + tool-fqn: "xyz.taluslabs.payments.stripe" + annotations: + run.googleapis.com/launch-stage: GA +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "1" + autoscaling.knative.dev/maxScale: "20" + run.googleapis.com/execution-environment: gen2 + spec: + serviceAccountName: "nexus-tools-mainnet@${PROJECT_ID}.iam.gserviceaccount.com" + timeoutSeconds: 30 + containerConcurrency: 80 + containers: + - name: tool + image: "${REGISTRY}/payments-stripe:${SHA}" + ports: + - name: http1 + containerPort: 8080 + env: + - name: BIND_ADDR + value: 0.0.0.0:8080 + - name: NEXUS_TOOLKIT_CONFIG_PATH + value: /etc/nexus/toolkit.json + - name: NEXUS_NETWORK + value: mainnet + volumeMounts: + - name: nexus-config + mountPath: /etc/nexus + readOnly: true + - name: allowed-leaders + mountPath: /etc/nexus/allowed_leaders + readOnly: true + resources: + limits: + cpu: "2" + memory: 1Gi + volumes: + - name: nexus-config + secret: + secretName: "nexus-toolkit-config-mainnet-payments-stripe" + - name: allowed-leaders + secret: + secretName: "nexus-allowed-leaders-mainnet" + traffic: + - percent: 100 + latestRevision: true diff --git a/tools/payments-stripe/deploy/cloud-run.testnet.yaml b/tools/payments-stripe/deploy/cloud-run.testnet.yaml new file mode 100644 index 0000000..59c22b0 --- /dev/null +++ b/tools/payments-stripe/deploy/cloud-run.testnet.yaml @@ -0,0 +1,61 @@ +# Credential model: this service holds NO Stripe API credentials. +# The only secrets mounted here are: +# - nexus-toolkit-config-testnet-payments-stripe (Tool's own Ed25519 signing key) +# - nexus-allowed-leaders-testnet (Leader public keys, not secret) +# Stripe `api_key` arrives in the per-request Input struct, sourced by +# the Leader at DAG-execution time. Adding any other secretKeyRef fails +# the auditor's C10 check. +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: "payments-stripe-testnet" + labels: + network: testnet + tool-fqn: "xyz.taluslabs.payments.stripe" + annotations: + run.googleapis.com/launch-stage: GA +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "0" + autoscaling.knative.dev/maxScale: "5" + run.googleapis.com/execution-environment: gen2 + spec: + serviceAccountName: "nexus-tools-testnet@${PROJECT_ID}.iam.gserviceaccount.com" + timeoutSeconds: 30 + containerConcurrency: 80 + containers: + - name: tool + image: "${REGISTRY}/payments-stripe:${SHA}" + ports: + - name: http1 + containerPort: 8080 + env: + - name: BIND_ADDR + value: 0.0.0.0:8080 + - name: NEXUS_TOOLKIT_CONFIG_PATH + value: /etc/nexus/toolkit.json + - name: NEXUS_NETWORK + value: testnet + volumeMounts: + - name: nexus-config + mountPath: /etc/nexus + readOnly: true + - name: allowed-leaders + mountPath: /etc/nexus/allowed_leaders + readOnly: true + resources: + limits: + cpu: "1" + memory: 512Mi + volumes: + - name: nexus-config + secret: + secretName: "nexus-toolkit-config-testnet-payments-stripe" + - name: allowed-leaders + secret: + secretName: "nexus-allowed-leaders-testnet" + traffic: + - percent: 100 + latestRevision: true diff --git a/tools/payments-stripe/deploy/register.sh b/tools/payments-stripe/deploy/register.sh new file mode 100755 index 0000000..65bfed5 --- /dev/null +++ b/tools/payments-stripe/deploy/register.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Idempotent registration of payments-stripe tools with Nexus. +# +# Usage: +# register.sh +# +# Reads tool paths from ../paths.json, fetches each tool's FQN from +# /meta, then registers (or updates) each FQN against the chosen Nexus +# network. + +set -euo pipefail + +URL="${1:?missing first arg: tool URL}" +NETWORK="${2:?missing second arg: testnet|mainnet}" + +case "$NETWORK" in + testnet|mainnet) ;; + *) echo "network must be 'testnet' or 'mainnet'"; exit 2 ;; +esac + +PATHS_FILE="$(dirname "$0")/../paths.json" +if [[ ! -f "$PATHS_FILE" ]]; then + echo "expected $PATHS_FILE to enumerate tool paths" >&2 + exit 2 +fi + +mapfile -t PATHS < <(jq -r '.[]' "$PATHS_FILE") + +for path in "${PATHS[@]}"; do + meta_url="${URL%/}${path}/meta" + meta="$(curl -fsS "$meta_url")" + fqn="$(echo "$meta" | jq -r .fqn)" + description="$(echo "$meta" | jq -r .description)" + + echo "registering $fqn at $URL$path on $NETWORK" + + if nexus tool list --network "$NETWORK" 2>/dev/null | grep -q "$fqn"; then + nexus tool update offchain \ + --network "$NETWORK" \ + --tool-fqn "$fqn" \ + --url "${URL%/}${path}" + else + nexus tool register offchain \ + --network "$NETWORK" \ + --tool-fqn "$fqn" \ + --url "${URL%/}${path}" \ + --description "$description" + fi +done diff --git a/tools/payments-stripe/paths.json b/tools/payments-stripe/paths.json new file mode 100644 index 0000000..28e0a40 --- /dev/null +++ b/tools/payments-stripe/paths.json @@ -0,0 +1,8 @@ +[ + "/create-payment-intent", + "/get-payment-intent", + "/confirm-payment-intent", + "/create-customer", + "/get-balance", + "/list-charges" +] diff --git a/tools/payments-stripe/src/error.rs b/tools/payments-stripe/src/error.rs new file mode 100644 index 0000000..a83b1a2 --- /dev/null +++ b/tools/payments-stripe/src/error.rs @@ -0,0 +1,155 @@ +//! Stripe-specific error envelope + kind mapping. +//! +//! Stripe returns errors as `{"error":{"type","code","message","param"}}`. +//! We map `type` to a typed kind enum so DAG authors can branch on the +//! failure class without parsing `reason`. + +use { + schemars::JsonSchema, + serde::{Deserialize, Serialize}, + thiserror::Error, +}; + +/// Machine-readable error kinds for Stripe operations. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum StripeErrorKind { + /// Stripe `invalid_request_error` — malformed request. + InvalidRequest, + /// Stripe `card_error` — card was declined. + CardError, + /// Stripe `validation_error` — request parameters invalid. + ValidationError, + /// Stripe `idempotency_error` — idempotency key reused with different params. + IdempotencyError, + /// Stripe `authentication_error` — missing or wrong API key. + Unauthorized, + /// HTTP 402 payment required. + PaymentRequired, + /// HTTP 403. + Forbidden, + /// Resource not found. + NotFound, + /// HTTP 408 / network timeout. + TimedOut, + /// HTTP 409 — conflict. + Conflict, + /// Stripe `rate_limit_error` / HTTP 429. + RateLimitExceeded, + /// Stripe `api_error` / HTTP 5xx. + InternalServerError, + /// HTTP 502 bad gateway. + BadGateway, + /// HTTP 503 service unavailable. + ServiceUnavailable, + /// Connection-level failure. + NetworkConnectionFailed, + /// Failed to parse the upstream response. + Parse, + /// Anything we don't have a typed mapping for. + Unknown, +} + +/// Public error surface returned to the DAG via `Output::Err`. +#[derive(Debug, Serialize, Deserialize)] +pub struct StripeErrorResponse { + pub reason: String, + pub kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_code: Option, +} + +#[derive(Error, Debug)] +#[allow(dead_code)] +pub enum StripeError { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + #[error("Response parsing error: {0}")] + ParseError(#[from] serde_json::Error), + #[error("Stripe API error: {0}")] + ApiError(String), +} + +impl StripeErrorKind { + pub fn from_status_code(status_code: u16) -> Self { + match status_code { + 400 => Self::InvalidRequest, + 401 => Self::Unauthorized, + 402 => Self::PaymentRequired, + 403 => Self::Forbidden, + 404 => Self::NotFound, + 408 => Self::TimedOut, + 409 => Self::Conflict, + 429 => Self::RateLimitExceeded, + 500 => Self::InternalServerError, + 502 => Self::BadGateway, + 503 => Self::ServiceUnavailable, + 504 => Self::TimedOut, + _ => Self::Unknown, + } + } + + pub fn from_network_error(error: &reqwest::Error) -> Self { + if error.is_timeout() { + Self::TimedOut + } else { + // is_connect / is_request / anything else all map here. + Self::NetworkConnectionFailed + } + } + + /// Map Stripe's `error.type` to our kind. Cover every documented + /// Stripe error class (audit check H10). + pub fn from_api_error_type(error_type: &str) -> Self { + match error_type { + "invalid_request_error" => Self::InvalidRequest, + "card_error" => Self::CardError, + "validation_error" => Self::ValidationError, + "idempotency_error" => Self::IdempotencyError, + "rate_limit_error" => Self::RateLimitExceeded, + "authentication_error" => Self::Unauthorized, + "api_error" | "api_connection_error" => Self::InternalServerError, + _ => Self::Unknown, + } + } +} + +#[derive(Debug, Deserialize)] +struct StripeEnvelope { + error: StripeErrorBody, +} + +#[derive(Debug, Deserialize)] +struct StripeErrorBody { + #[serde(rename = "type")] + error_type: Option, + code: Option, + message: Option, + param: Option, +} + +/// Best-effort parse of a non-2xx response body into a typed error. +/// Returns `None` if the body doesn't match Stripe's envelope; the +/// caller falls back to status-code-only mapping. +pub fn try_parse_api_error(body: &str, status_code: u16) -> Option { + let env: StripeEnvelope = serde_json::from_str(body).ok()?; + let kind = env + .error + .error_type + .as_deref() + .map(StripeErrorKind::from_api_error_type) + .unwrap_or_else(|| StripeErrorKind::from_status_code(status_code)); + + let reason = match (env.error.message, env.error.code, env.error.param) { + (Some(m), _, Some(p)) => format!("{} (param: {})", m, p), + (Some(m), _, None) => m, + (None, Some(c), _) => format!("Stripe error code: {}", c), + _ => format!("Stripe API error ({})", status_code), + }; + + Some(StripeErrorResponse { + reason, + kind, + status_code: Some(status_code), + }) +} diff --git a/tools/payments-stripe/src/main.rs b/tools/payments-stripe/src/main.rs new file mode 100644 index 0000000..d14ecf7 --- /dev/null +++ b/tools/payments-stripe/src/main.rs @@ -0,0 +1,20 @@ +#![doc = include_str!("../README.md")] +#![allow(clippy::large_enum_variant)] + +use nexus_toolkit::bootstrap; + +mod error; +mod stripe_client; +mod tools; + +#[tokio::main] +async fn main() { + bootstrap!([ + tools::create_payment_intent::CreatePaymentIntent, + tools::get_payment_intent::GetPaymentIntent, + tools::confirm_payment_intent::ConfirmPaymentIntent, + tools::create_customer::CreateCustomer, + tools::get_balance::GetBalance, + tools::list_charges::ListCharges, + ]); +} diff --git a/tools/payments-stripe/src/stripe_client.rs b/tools/payments-stripe/src/stripe_client.rs new file mode 100644 index 0000000..16af9af --- /dev/null +++ b/tools/payments-stripe/src/stripe_client.rs @@ -0,0 +1,151 @@ +//! Stateless Stripe HTTP client. +//! +//! Credential model (audit checks C7, C9, C10): +//! - This client holds NO Stripe credentials between requests. +//! - The `api_key` is attached per-call via `.with_auth(&input.api_key)`. +//! - The struct's only persistent field is an `Arc` — +//! a stateless connection pool. `new()` is cheap and idempotent. +//! - No `std::env::var` reads; no on-disk reads. + +use { + crate::{ + error::{try_parse_api_error, StripeErrorKind, StripeErrorResponse}, + tools::STRIPE_API_BASE, + }, + reqwest::{Client, RequestBuilder}, + serde::{de::DeserializeOwned, Serialize}, + std::sync::Arc, +}; + +#[derive(Clone)] +pub struct StripeClient { + client: Arc, + base_url: String, + bearer: Option, + idempotency_key: Option, +} + +impl StripeClient { + pub fn new(base_url: Option<&str>) -> Self { + let base_url = base_url.unwrap_or(STRIPE_API_BASE).to_string(); + let client = Client::builder() + .user_agent("nexus-sdk-payments-stripe/1.0") + .build() + .expect("Failed to create HTTP client"); + Self { + client: Arc::new(client), + base_url, + bearer: None, + idempotency_key: None, + } + } + + /// Attach the per-request Stripe secret key. Returns a new builder + /// so the caller's base client never sees the credential. + #[must_use] + pub fn with_auth(mut self, bearer: &str) -> Self { + self.bearer = Some(bearer.to_string()); + self + } + + /// Attach an `Idempotency-Key` header. + #[must_use] + pub fn with_idempotency(mut self, key: &str) -> Self { + self.idempotency_key = Some(key.to_string()); + self + } + + pub async fn get(&self, endpoint: &str) -> Result + where + T: DeserializeOwned, + { + let req = self.apply_headers(self.client.get(self.url(endpoint))); + self.send(req).await + } + + /// Stripe uses `application/x-www-form-urlencoded` for write bodies, + /// not JSON. Pass a `serde_urlencoded`-compatible value. + pub async fn post_form(&self, endpoint: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let req = self.apply_headers(self.client.post(self.url(endpoint)).form(body)); + self.send(req).await + } + + fn url(&self, endpoint: &str) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + endpoint.trim_start_matches('/') + ) + } + + fn apply_headers(&self, mut req: RequestBuilder) -> RequestBuilder { + if let Some(ref bearer) = self.bearer { + req = req.bearer_auth(bearer); + } + if let Some(ref key) = self.idempotency_key { + req = req.header("Idempotency-Key", key); + } + req + } + + async fn send(&self, req: RequestBuilder) -> Result + where + T: DeserializeOwned, + { + let response = match req.send().await { + Ok(r) => r, + Err(e) => { + return Err(StripeErrorResponse { + reason: format!("Network error: {}", e), + kind: StripeErrorKind::from_network_error(&e), + status_code: Some(0), + }); + } + }; + + let status = response.status(); + let text = match response.text().await { + Ok(t) => t, + Err(e) => { + return Err(StripeErrorResponse { + reason: format!("Failed to read response: {}", e), + kind: StripeErrorKind::Parse, + status_code: None, + }); + } + }; + + if !status.is_success() { + if let Some(parsed) = try_parse_api_error(&text, status.as_u16()) { + return Err(parsed); + } + return Err(StripeErrorResponse { + reason: format!("Stripe API error ({}): {}", status, truncate(&text, 512)), + kind: StripeErrorKind::from_status_code(status.as_u16()), + status_code: Some(status.as_u16()), + }); + } + + serde_json::from_str::(&text).map_err(|e| StripeErrorResponse { + reason: format!("Failed to parse JSON: {}", e), + kind: StripeErrorKind::Parse, + status_code: None, + }) + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} diff --git a/tools/payments-stripe/src/tools/confirm_payment_intent.rs b/tools/payments-stripe/src/tools/confirm_payment_intent.rs new file mode 100644 index 0000000..bc91586 --- /dev/null +++ b/tools/payments-stripe/src/tools/confirm_payment_intent.rs @@ -0,0 +1,212 @@ +//! # `xyz.taluslabs.payments.stripe.confirm-payment-intent@1` + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub api_key: String, + pub idempotency_key: Option, + pub payment_intent_id: String, + pub payment_method: Option, + pub return_url: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + next_action_type: Option, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct ConfirmPaymentIntent { + client: StripeClient, +} + +#[derive(Deserialize)] +struct ConfirmResponse { + id: String, + status: String, + next_action: Option, +} + +#[derive(Deserialize)] +struct NextAction { + #[serde(rename = "type")] + action_type: String, +} + +impl NexusTool for ConfirmPaymentIntent { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::new(None), + } + } + + fn fqn() -> ToolFqn { + fqn!("xyz.taluslabs.payments.stripe.confirm-payment-intent@1") + } + + fn path() -> &'static str { + "/confirm-payment-intent" + } + + fn description() -> &'static str { + "Confirms a Stripe PaymentIntent." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if input.payment_intent_id.trim().is_empty() { + return Output::Err { + reason: "payment_intent_id must not be empty".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + + let mut form: Vec<(&str, String)> = Vec::new(); + if let Some(pm) = &input.payment_method { + form.push(("payment_method", pm.clone())); + } + if let Some(ru) = &input.return_url { + form.push(("return_url", ru.clone())); + } + + let endpoint = format!("v1/payment_intents/{}/confirm", input.payment_intent_id); + let mut client = self.client.clone().with_auth(&input.api_key); + if let Some(k) = &input.idempotency_key { + client = client.with_idempotency(k); + } + + match client + .post_form::(&endpoint, &form) + .await + { + Ok(r) => Output::Ok { + id: r.id, + status: r.status, + next_action_type: r.next_action.map(|na| na.action_type), + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, ConfirmPaymentIntent) { + let server = Server::new_async().await; + let client = StripeClient::new(Some(&server.url())); + (server, ConfirmPaymentIntent { client }) + } + + fn test_input() -> Input { + Input { + api_key: "sk_test_FAKE".to_string(), + idempotency_key: None, + payment_intent_id: "pi_test_123".to_string(), + payment_method: Some("pm_card_visa".to_string()), + return_url: None, + } + } + + #[tokio::test] + async fn test_confirm_success_no_next_action() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/payment_intents/pi_test_123/confirm") + .with_status(200) + .with_body( + json!({ + "id": "pi_test_123", + "status": "succeeded", + "next_action": null + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Ok { + status, + next_action_type, + .. + } => { + assert_eq!(status, "succeeded"); + assert_eq!(next_action_type, None); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_confirm_requires_action() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/payment_intents/pi_test_123/confirm") + .with_status(200) + .with_body( + json!({ + "id": "pi_test_123", + "status": "requires_action", + "next_action": { "type": "redirect_to_url" } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Ok { + next_action_type, .. + } => { + assert_eq!(next_action_type.as_deref(), Some("redirect_to_url")); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_confirm_empty_id() { + let (_, tool) = create_server_and_tool().await; + let mut input = test_input(); + input.payment_intent_id = "".to_string(); + let result = tool.invoke(input).await; + assert!(matches!(result, Output::Err { .. })); + } +} diff --git a/tools/payments-stripe/src/tools/create_customer.rs b/tools/payments-stripe/src/tools/create_customer.rs new file mode 100644 index 0000000..84c3413 --- /dev/null +++ b/tools/payments-stripe/src/tools/create_customer.rs @@ -0,0 +1,194 @@ +//! # `xyz.taluslabs.payments.stripe.create-customer@1` + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub api_key: String, + pub idempotency_key: Option, + pub email: Option, + pub name: Option, + pub description: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, + created: i64, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct CreateCustomer { + client: StripeClient, +} + +#[derive(Deserialize)] +struct CustomerResponse { + id: String, + email: Option, + created: i64, +} + +impl NexusTool for CreateCustomer { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::new(None), + } + } + + fn fqn() -> ToolFqn { + fqn!("xyz.taluslabs.payments.stripe.create-customer@1") + } + + fn path() -> &'static str { + "/create-customer" + } + + fn description() -> &'static str { + "Creates a Stripe Customer." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + let mut form: Vec<(&str, String)> = Vec::new(); + if let Some(e) = &input.email { + form.push(("email", e.clone())); + } + if let Some(n) = &input.name { + form.push(("name", n.clone())); + } + if let Some(d) = &input.description { + form.push(("description", d.clone())); + } + + let mut client = self.client.clone().with_auth(&input.api_key); + if let Some(k) = &input.idempotency_key { + client = client.with_idempotency(k); + } + + match client + .post_form::("v1/customers", &form) + .await + { + Ok(c) => Output::Ok { + id: c.id, + email: c.email, + created: c.created, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, CreateCustomer) { + let server = Server::new_async().await; + let client = StripeClient::new(Some(&server.url())); + (server, CreateCustomer { client }) + } + + #[tokio::test] + async fn test_create_customer_success() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/customers") + .with_status(200) + .with_body( + json!({ + "id": "cus_test_abc", + "email": "test@example.com", + "created": 1700000000 + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + api_key: "sk_test_FAKE".to_string(), + idempotency_key: None, + email: Some("test@example.com".to_string()), + name: Some("Test User".to_string()), + description: None, + }) + .await; + match result { + Output::Ok { id, email, created } => { + assert_eq!(id, "cus_test_abc"); + assert_eq!(email.as_deref(), Some("test@example.com")); + assert_eq!(created, 1700000000); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_create_customer_auth_error() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("POST", "/v1/customers") + .with_status(401) + .with_body( + json!({ + "error": { + "type": "authentication_error", + "message": "Invalid API Key provided" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + api_key: "sk_test_BAD".to_string(), + idempotency_key: None, + email: Some("test@example.com".to_string()), + name: None, + description: None, + }) + .await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::Unauthorized, + .. + } + )); + } +} diff --git a/tools/payments-stripe/src/tools/create_payment_intent.rs b/tools/payments-stripe/src/tools/create_payment_intent.rs new file mode 100644 index 0000000..f30fa57 --- /dev/null +++ b/tools/payments-stripe/src/tools/create_payment_intent.rs @@ -0,0 +1,337 @@ +//! # `xyz.taluslabs.payments.stripe.create-payment-intent@1` +//! +//! Creates a Stripe PaymentIntent. +//! +//! Stateless: holds only a stateless connection pool. Credential +//! (`api_key`) is supplied per request via `Input`. + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + /// Stripe secret key. NEVER put this in DAG `default_values` — the + /// Leader supplies it at execution time. + pub api_key: String, + /// Optional Idempotency-Key for safe retry. + pub idempotency_key: Option, + /// Amount in the smallest currency unit (cents for USD). + pub amount: i64, + /// ISO-4217 lowercase currency code. + pub currency: String, + /// Existing Stripe customer id (`cus_…`). + pub customer: Option, + /// Free-form description shown on the dashboard. + pub description: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + client_secret: String, + status: String, + amount: i64, + currency: String, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct CreatePaymentIntent { + client: StripeClient, +} + +#[derive(Deserialize)] +struct StripePaymentIntent { + id: String, + client_secret: String, + status: String, + amount: i64, + currency: String, +} + +impl NexusTool for CreatePaymentIntent { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::new(None), + } + } + + fn fqn() -> ToolFqn { + fqn!("xyz.taluslabs.payments.stripe.create-payment-intent@1") + } + + fn path() -> &'static str { + "/create-payment-intent" + } + + fn description() -> &'static str { + "Creates a Stripe PaymentIntent." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if input.amount <= 0 { + return Output::Err { + reason: "amount must be positive (and in smallest currency unit, e.g. cents)" + .to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + if input.currency.trim().is_empty() { + return Output::Err { + reason: "currency must be a non-empty ISO-4217 code".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + + let mut form: Vec<(&str, String)> = vec![ + ("amount", input.amount.to_string()), + ("currency", input.currency.to_lowercase()), + ]; + if let Some(c) = &input.customer { + form.push(("customer", c.clone())); + } + if let Some(d) = &input.description { + form.push(("description", d.clone())); + } + + let mut client = self.client.clone().with_auth(&input.api_key); + if let Some(k) = &input.idempotency_key { + client = client.with_idempotency(k); + } + + match client + .post_form::("v1/payment_intents", &form) + .await + { + Ok(pi) => Output::Ok { + id: pi.id, + client_secret: pi.client_secret, + status: pi.status, + amount: pi.amount, + currency: pi.currency, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, CreatePaymentIntent) { + let server = Server::new_async().await; + let client = StripeClient::new(Some(&server.url())); + (server, CreatePaymentIntent { client }) + } + + fn test_input() -> Input { + Input { + api_key: "sk_test_FAKE_FOR_TESTS_ONLY".to_string(), + idempotency_key: Some("test-idempotency-key-001".to_string()), + amount: 2000, + currency: "usd".to_string(), + customer: None, + description: Some("Test payment".to_string()), + } + } + + #[tokio::test] + async fn test_create_payment_intent_success() { + let (mut server, tool) = create_server_and_tool().await; + + let mock = server + .mock("POST", "/v1/payment_intents") + .match_header("authorization", "Bearer sk_test_FAKE_FOR_TESTS_ONLY") + .match_header("idempotency-key", "test-idempotency-key-001") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "pi_test_123", + "client_secret": "pi_test_123_secret_abc", + "status": "requires_payment_method", + "amount": 2000, + "currency": "usd" + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Ok { + id, + client_secret, + status, + amount, + currency, + } => { + assert_eq!(id, "pi_test_123"); + assert_eq!(client_secret, "pi_test_123_secret_abc"); + assert_eq!(status, "requires_payment_method"); + assert_eq!(amount, 2000); + assert_eq!(currency, "usd"); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + mock.assert_async().await; + } + + #[tokio::test] + async fn test_create_payment_intent_card_error() { + let (mut server, tool) = create_server_and_tool().await; + + let _mock = server + .mock("POST", "/v1/payment_intents") + .with_status(402) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": { + "type": "card_error", + "code": "card_declined", + "message": "Your card was declined.", + "param": "card" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + match result { + Output::Err { + reason, + kind, + status_code, + } => { + assert_eq!(kind, StripeErrorKind::CardError); + assert!(reason.contains("Your card was declined")); + assert_eq!(status_code, Some(402)); + } + Output::Ok { .. } => panic!("expected Err, got Ok"), + } + } + + #[tokio::test] + async fn test_create_payment_intent_idempotency_error() { + let (mut server, tool) = create_server_and_tool().await; + + let _mock = server + .mock("POST", "/v1/payment_intents") + .with_status(400) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": { + "type": "idempotency_error", + "message": "Keys for idempotent requests can only be used with the same parameters they were first used with." + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::IdempotencyError, + .. + } + )); + } + + #[tokio::test] + async fn test_create_payment_intent_validation_zero_amount() { + let (_, tool) = create_server_and_tool().await; + let mut input = test_input(); + input.amount = 0; + let result = tool.invoke(input).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } + + #[tokio::test] + async fn test_create_payment_intent_validation_empty_currency() { + let (_, tool) = create_server_and_tool().await; + let mut input = test_input(); + input.currency = "".to_string(); + let result = tool.invoke(input).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } + + #[tokio::test] + async fn test_create_payment_intent_rate_limit() { + let (mut server, tool) = create_server_and_tool().await; + + let _mock = server + .mock("POST", "/v1/payment_intents") + .with_status(429) + .with_header("content-type", "application/json") + .with_body( + json!({ + "error": { + "type": "rate_limit_error", + "message": "Too many requests" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool.invoke(test_input()).await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::RateLimitExceeded, + .. + } + )); + } +} diff --git a/tools/payments-stripe/src/tools/get_balance.rs b/tools/payments-stripe/src/tools/get_balance.rs new file mode 100644 index 0000000..afec74c --- /dev/null +++ b/tools/payments-stripe/src/tools/get_balance.rs @@ -0,0 +1,127 @@ +//! # `xyz.taluslabs.payments.stripe.get-balance@1` + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::BalanceAmount}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub api_key: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + available: Vec, + pending: Vec, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct GetBalance { + client: StripeClient, +} + +#[derive(Deserialize)] +struct BalanceResponse { + available: Vec, + pending: Vec, +} + +impl NexusTool for GetBalance { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::new(None), + } + } + + fn fqn() -> ToolFqn { + fqn!("xyz.taluslabs.payments.stripe.get-balance@1") + } + + fn path() -> &'static str { + "/get-balance" + } + + fn description() -> &'static str { + "Retrieves the Stripe platform balance." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + let client = self.client.clone().with_auth(&input.api_key); + match client.get::("v1/balance").await { + Ok(b) => Output::Ok { + available: b.available, + pending: b.pending, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, GetBalance) { + let server = Server::new_async().await; + let client = StripeClient::new(Some(&server.url())); + (server, GetBalance { client }) + } + + #[tokio::test] + async fn test_get_balance_success() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("GET", "/v1/balance") + .with_status(200) + .with_body( + json!({ + "available": [{ "amount": 1000, "currency": "usd" }], + "pending": [{ "amount": 500, "currency": "usd" }] + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + api_key: "sk_test_FAKE".to_string(), + }) + .await; + match result { + Output::Ok { available, pending } => { + assert_eq!(available.len(), 1); + assert_eq!(available[0].amount, 1000); + assert_eq!(pending[0].amount, 500); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } +} diff --git a/tools/payments-stripe/src/tools/get_payment_intent.rs b/tools/payments-stripe/src/tools/get_payment_intent.rs new file mode 100644 index 0000000..cd5a084 --- /dev/null +++ b/tools/payments-stripe/src/tools/get_payment_intent.rs @@ -0,0 +1,198 @@ +//! # `xyz.taluslabs.payments.stripe.get-payment-intent@1` + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub api_key: String, + pub payment_intent_id: String, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + id: String, + status: String, + amount: i64, + currency: String, + #[serde(skip_serializing_if = "Option::is_none")] + client_secret: Option, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct GetPaymentIntent { + client: StripeClient, +} + +#[derive(Deserialize)] +struct StripePaymentIntent { + id: String, + status: String, + amount: i64, + currency: String, + client_secret: Option, +} + +impl NexusTool for GetPaymentIntent { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::new(None), + } + } + + fn fqn() -> ToolFqn { + fqn!("xyz.taluslabs.payments.stripe.get-payment-intent@1") + } + + fn path() -> &'static str { + "/get-payment-intent" + } + + fn description() -> &'static str { + "Retrieves a Stripe PaymentIntent by id." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if input.payment_intent_id.trim().is_empty() { + return Output::Err { + reason: "payment_intent_id must not be empty".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + + let endpoint = format!("v1/payment_intents/{}", input.payment_intent_id); + let client = self.client.clone().with_auth(&input.api_key); + + match client.get::(&endpoint).await { + Ok(pi) => Output::Ok { + id: pi.id, + status: pi.status, + amount: pi.amount, + currency: pi.currency, + client_secret: pi.client_secret, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, GetPaymentIntent) { + let server = Server::new_async().await; + let client = StripeClient::new(Some(&server.url())); + (server, GetPaymentIntent { client }) + } + + #[tokio::test] + async fn test_get_payment_intent_success() { + let (mut server, tool) = create_server_and_tool().await; + let mock = server + .mock("GET", "/v1/payment_intents/pi_test_123") + .match_header("authorization", "Bearer sk_test_FAKE") + .with_status(200) + .with_body( + json!({ + "id": "pi_test_123", + "status": "succeeded", + "amount": 1500, + "currency": "usd", + "client_secret": null + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + api_key: "sk_test_FAKE".to_string(), + payment_intent_id: "pi_test_123".to_string(), + }) + .await; + match result { + Output::Ok { id, status, .. } => { + assert_eq!(id, "pi_test_123"); + assert_eq!(status, "succeeded"); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + mock.assert_async().await; + } + + #[tokio::test] + async fn test_get_payment_intent_not_found() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("GET", "/v1/payment_intents/pi_missing") + .with_status(404) + .with_body( + json!({ + "error": { + "type": "invalid_request_error", + "message": "No such payment_intent: pi_missing" + } + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + api_key: "sk_test_FAKE".to_string(), + payment_intent_id: "pi_missing".to_string(), + }) + .await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } + + #[tokio::test] + async fn test_get_payment_intent_empty_id() { + let (_, tool) = create_server_and_tool().await; + let result = tool + .invoke(Input { + api_key: "sk_test_FAKE".to_string(), + payment_intent_id: "".to_string(), + }) + .await; + assert!(matches!(result, Output::Err { .. })); + } +} diff --git a/tools/payments-stripe/src/tools/list_charges.rs b/tools/payments-stripe/src/tools/list_charges.rs new file mode 100644 index 0000000..60177e0 --- /dev/null +++ b/tools/payments-stripe/src/tools/list_charges.rs @@ -0,0 +1,207 @@ +//! # `xyz.taluslabs.payments.stripe.list-charges@1` + +use { + crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::ChargeSummary}, + nexus_sdk::{fqn, ToolFqn}, + nexus_toolkit::*, + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Input { + pub api_key: String, + pub limit: Option, + pub customer: Option, + pub starting_after: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Output { + Ok { + charges: Vec, + has_more: bool, + }, + Err { + reason: String, + kind: StripeErrorKind, + #[serde(skip_serializing_if = "Option::is_none")] + status_code: Option, + }, +} + +pub(crate) struct ListCharges { + client: StripeClient, +} + +#[derive(Deserialize)] +struct ListResponse { + data: Vec, + has_more: bool, +} + +impl NexusTool for ListCharges { + type Input = Input; + type Output = Output; + + async fn new() -> Self { + Self { + client: StripeClient::new(None), + } + } + + fn fqn() -> ToolFqn { + fqn!("xyz.taluslabs.payments.stripe.list-charges@1") + } + + fn path() -> &'static str { + "/list-charges" + } + + fn description() -> &'static str { + "Lists Stripe charges with optional filtering and pagination." + } + + async fn health(&self) -> AnyResult { + Ok(StatusCode::OK) + } + + async fn invoke(&self, input: Self::Input) -> Self::Output { + if let Some(l) = input.limit { + if !(1..=100).contains(&l) { + return Output::Err { + reason: "limit must be in 1..=100".to_string(), + kind: StripeErrorKind::InvalidRequest, + status_code: None, + }; + } + } + + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(l) = input.limit { + query.push(("limit", l.to_string())); + } + if let Some(c) = &input.customer { + query.push(("customer", c.clone())); + } + if let Some(s) = &input.starting_after { + query.push(("starting_after", s.clone())); + } + + let qs = serde_urlencoded_minimal(&query); + let endpoint = if qs.is_empty() { + "v1/charges".to_string() + } else { + format!("v1/charges?{qs}") + }; + + let client = self.client.clone().with_auth(&input.api_key); + match client.get::(&endpoint).await { + Ok(r) => Output::Ok { + charges: r.data, + has_more: r.has_more, + }, + Err(e) => Output::Err { + reason: e.reason, + kind: e.kind, + status_code: e.status_code, + }, + } + } +} + +fn serde_urlencoded_minimal(pairs: &[(&str, String)]) -> String { + pairs + .iter() + .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v),)) + .collect::>() + .join("&") +} + +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + b' ' => out.push_str("%20"), + _ => out.push_str(&format!("%{:02X}", byte)), + } + } + out +} + +#[cfg(test)] +mod tests { + use { + super::*, + ::{mockito::Server, serde_json::json}, + }; + + async fn create_server_and_tool() -> (mockito::ServerGuard, ListCharges) { + let server = Server::new_async().await; + let client = StripeClient::new(Some(&server.url())); + (server, ListCharges { client }) + } + + #[tokio::test] + async fn test_list_charges_success() { + let (mut server, tool) = create_server_and_tool().await; + let _mock = server + .mock("GET", "/v1/charges?limit=2") + .with_status(200) + .with_body( + json!({ + "data": [ + { "id": "ch_1", "amount": 1000, "currency": "usd", "status": "succeeded", "customer": "cus_a" }, + { "id": "ch_2", "amount": 2500, "currency": "usd", "status": "succeeded" } + ], + "has_more": true + }) + .to_string(), + ) + .create_async() + .await; + + let result = tool + .invoke(Input { + api_key: "sk_test_FAKE".to_string(), + limit: Some(2), + customer: None, + starting_after: None, + }) + .await; + match result { + Output::Ok { charges, has_more } => { + assert_eq!(charges.len(), 2); + assert!(has_more); + assert_eq!(charges[0].id, "ch_1"); + assert_eq!(charges[1].customer, None); + } + Output::Err { reason, .. } => panic!("expected Ok, got Err: {reason}"), + } + } + + #[tokio::test] + async fn test_list_charges_limit_out_of_range() { + let (_, tool) = create_server_and_tool().await; + let result = tool + .invoke(Input { + api_key: "sk_test_FAKE".to_string(), + limit: Some(150), + customer: None, + starting_after: None, + }) + .await; + assert!(matches!( + result, + Output::Err { + kind: StripeErrorKind::InvalidRequest, + .. + } + )); + } +} diff --git a/tools/payments-stripe/src/tools/mod.rs b/tools/payments-stripe/src/tools/mod.rs new file mode 100644 index 0000000..ac22334 --- /dev/null +++ b/tools/payments-stripe/src/tools/mod.rs @@ -0,0 +1,11 @@ +//! Stripe endpoints. Each submodule is one stateless `NexusTool`. + +pub(crate) const STRIPE_API_BASE: &str = "https://api.stripe.com"; + +pub(crate) mod confirm_payment_intent; +pub(crate) mod create_customer; +pub(crate) mod create_payment_intent; +pub(crate) mod get_balance; +pub(crate) mod get_payment_intent; +pub(crate) mod list_charges; +pub(crate) mod models; diff --git a/tools/payments-stripe/src/tools/models.rs b/tools/payments-stripe/src/tools/models.rs new file mode 100644 index 0000000..efb1610 --- /dev/null +++ b/tools/payments-stripe/src/tools/models.rs @@ -0,0 +1,24 @@ +//! Shared Stripe response shapes used by two or more endpoints. + +use { + schemars::JsonSchema, + serde::{Deserialize, Serialize}, +}; + +/// Stripe Balance amount entry. +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub(crate) struct BalanceAmount { + pub amount: i64, + pub currency: String, +} + +/// Stripe Charge summary as returned by `/v1/charges` list calls. +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub(crate) struct ChargeSummary { + pub id: String, + pub amount: i64, + pub currency: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub customer: Option, +} From ce715f44393ca794ea1cf954017a01587d3b1f46 Mon Sep 17 00:00:00 2001 From: Mike Hanono Date: Mon, 18 May 2026 04:28:25 +0000 Subject: [PATCH 2/5] fix(payments-stripe): satisfy markdownlint on AUDIT.md MD031 (blank lines around fenced blocks) and MD032 (blank lines around lists). Same class of fix as in 141170f for the skill files. Refs: tools/payments-stripe/AUDIT.md Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/payments-stripe/AUDIT.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/payments-stripe/AUDIT.md b/tools/payments-stripe/AUDIT.md index f28446d..9e11513 100644 --- a/tools/payments-stripe/AUDIT.md +++ b/tools/payments-stripe/AUDIT.md @@ -53,6 +53,7 @@ _No findings._ leak the api_key. - Fix: Drop the raw body. Surface only the status code and the typed `kind`: + ```rust return Err(StripeErrorResponse { reason: format!("Stripe API error (status {})", status), @@ -60,6 +61,7 @@ _No findings._ status_code: Some(status.as_u16()), }); ``` + If diagnostic detail is needed, write the body to a `tracing::warn!` log line with field-level redaction, not to `reason`. @@ -84,9 +86,11 @@ _No findings._ leaving it implicit creates risk on any subsequent endpoint addition that needs longer (e.g. webhook-based flows). - Fix: Explicitly override per endpoint, e.g. + ```rust fn timeout() -> Duration { Duration::from_secs(15) } ``` + on every `impl NexusTool`. Document the chosen value next to the override. @@ -215,6 +219,7 @@ Expected: never a 5xx, always either `Output::Err` JSON or a structured **Recommendation: ready-for-testnet.** **Blockers for mainnet:** + - M-1 (raw body in fallback `reason`) - M-2 (thin error-variant tests on three endpoints) - M-3 (explicit `timeout()` override per endpoint) From fe44a566682d74b8b325afd7e19603d9df0a751c Mon Sep 17 00:00:00 2001 From: tuky191 Date: Mon, 25 May 2026 18:10:24 +0200 Subject: [PATCH 3/5] payments-stripe: retrofit into offchain pipeline conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #32 introduced payments-stripe at the legacy `tools/` path with per-tool deploy workflows. Main has since restructured (#33) so all tools live under `offchain/tools/` and share a single CI matrix. This branch realigns payments-stripe so the new pipeline picks it up. Layout move: tools/payments-stripe/ → offchain/tools/payments-stripe/ Added (per .pre-commit/14-check-tool-conventions): - tools.json (tool_name, command, environment) - build.rs (byte-identical to other tools; threads TOOL_FQN_VERSION via cargo:rustc-env) - Cargo.toml [[bin]] + [build-dependencies] sections Removed (legacy per-tool deploy artifacts; the shared offchain-tools.{discover,deploy,prepare,register}.yml matrix replaces them): - tools/payments-stripe/deploy/{Dockerfile,cloud-run.*,register.sh} - tools/payments-stripe/paths.json (paths now come from each tool's --meta output) - .github/workflows/deploy-payments-stripe-{mainnet,testnet}.yml Source modifications: - 6 fqn!() macros across src/tools/*.rs threaded to fqn!(concat!("...@", env!("TOOL_FQN_VERSION"))) so the on-chain FQN version stays in sync with the content-hashed image tag produced by CI. --- .../deploy-payments-stripe-mainnet.yml | 89 ------------------- .../deploy-payments-stripe-testnet.yml | 71 --------------- .../tools}/payments-stripe/AUDIT.md | 0 .../tools}/payments-stripe/Cargo.toml | 8 ++ .../tools}/payments-stripe/README.md | 0 offchain/tools/payments-stripe/build.rs | 50 +++++++++++ .../tools}/payments-stripe/src/error.rs | 0 .../tools}/payments-stripe/src/main.rs | 0 .../payments-stripe/src/stripe_client.rs | 0 .../src/tools/confirm_payment_intent.rs | 5 +- .../src/tools/create_customer.rs | 5 +- .../src/tools/create_payment_intent.rs | 5 +- .../payments-stripe/src/tools/get_balance.rs | 5 +- .../src/tools/get_payment_intent.rs | 5 +- .../payments-stripe/src/tools/list_charges.rs | 5 +- .../tools}/payments-stripe/src/tools/mod.rs | 0 .../payments-stripe/src/tools/models.rs | 0 offchain/tools/payments-stripe/tools.json | 7 ++ tools/payments-stripe/deploy/Dockerfile | 28 ------ .../deploy/cloud-run.mainnet.yaml | 61 ------------- .../deploy/cloud-run.testnet.yaml | 61 ------------- tools/payments-stripe/deploy/register.sh | 49 ---------- tools/payments-stripe/paths.json | 8 -- 23 files changed, 89 insertions(+), 373 deletions(-) delete mode 100644 .github/workflows/deploy-payments-stripe-mainnet.yml delete mode 100644 .github/workflows/deploy-payments-stripe-testnet.yml rename {tools => offchain/tools}/payments-stripe/AUDIT.md (100%) rename {tools => offchain/tools}/payments-stripe/Cargo.toml (84%) rename {tools => offchain/tools}/payments-stripe/README.md (100%) create mode 100644 offchain/tools/payments-stripe/build.rs rename {tools => offchain/tools}/payments-stripe/src/error.rs (100%) rename {tools => offchain/tools}/payments-stripe/src/main.rs (100%) rename {tools => offchain/tools}/payments-stripe/src/stripe_client.rs (100%) rename {tools => offchain/tools}/payments-stripe/src/tools/confirm_payment_intent.rs (97%) rename {tools => offchain/tools}/payments-stripe/src/tools/create_customer.rs (97%) rename {tools => offchain/tools}/payments-stripe/src/tools/create_payment_intent.rs (98%) rename {tools => offchain/tools}/payments-stripe/src/tools/get_balance.rs (96%) rename {tools => offchain/tools}/payments-stripe/src/tools/get_payment_intent.rs (97%) rename {tools => offchain/tools}/payments-stripe/src/tools/list_charges.rs (97%) rename {tools => offchain/tools}/payments-stripe/src/tools/mod.rs (100%) rename {tools => offchain/tools}/payments-stripe/src/tools/models.rs (100%) create mode 100644 offchain/tools/payments-stripe/tools.json delete mode 100644 tools/payments-stripe/deploy/Dockerfile delete mode 100644 tools/payments-stripe/deploy/cloud-run.mainnet.yaml delete mode 100644 tools/payments-stripe/deploy/cloud-run.testnet.yaml delete mode 100755 tools/payments-stripe/deploy/register.sh delete mode 100644 tools/payments-stripe/paths.json diff --git a/.github/workflows/deploy-payments-stripe-mainnet.yml b/.github/workflows/deploy-payments-stripe-mainnet.yml deleted file mode 100644 index 3d90565..0000000 --- a/.github/workflows/deploy-payments-stripe-mainnet.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: deploy-payments-stripe-mainnet - -on: - push: - tags: - - "v-payments-stripe-*" - -permissions: - contents: read - id-token: write - -env: - PROJECT_ID: talus-tools-mainnet - REGION: us-central1 - REPO: nexus-tools - CRATE: payments-stripe - SERVICE: payments-stripe-mainnet - -jobs: - guard: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Require testnet success on this commit - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const ref = context.sha; - const { data } = await github.rest.checks.listForRef({ - owner, repo, ref, check_name: 'build-deploy-register' - }); - const ok = data.check_runs.some(c => - c.name === 'build-deploy-register' && c.conclusion === 'success' - ); - if (!ok) { - core.setFailed('Testnet deploy must succeed on this commit before mainnet.'); - } - - build-deploy-register: - needs: guard - runs-on: ubuntu-latest - environment: mainnet - steps: - - uses: actions/checkout@v4 - - - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_MAINNET }} - service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet - - - name: Build and push image - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . - docker push "$IMAGE" - - - name: Deploy to Cloud Run - env: - SHA: ${{ github.sha }} - run: | - export REGISTRY="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}" - envsubst < tools/${CRATE}/deploy/cloud-run.mainnet.yaml > /tmp/svc.yaml - gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet - URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') - echo "URL=$URL" >> "$GITHUB_ENV" - - - name: Smoke test - run: | - for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do - curl -fsS "${URL}${p}/health" - curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null - done - - - name: Install Nexus CLI - run: | - curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash - echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" - - - name: Register / update tools on Nexus mainnet - run: bash tools/${CRATE}/deploy/register.sh "$URL" mainnet diff --git a/.github/workflows/deploy-payments-stripe-testnet.yml b/.github/workflows/deploy-payments-stripe-testnet.yml deleted file mode 100644 index 59f7a38..0000000 --- a/.github/workflows/deploy-payments-stripe-testnet.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: deploy-payments-stripe-testnet - -on: - push: - branches: [main] - paths: - - "tools/payments-stripe/**" - - ".github/workflows/deploy-payments-stripe-testnet.yml" - workflow_dispatch: - -permissions: - contents: read - id-token: write - -env: - PROJECT_ID: talus-tools-testnet - REGION: us-central1 - REPO: nexus-tools - CRATE: payments-stripe - SERVICE: payments-stripe-testnet - -jobs: - build-deploy-register: - runs-on: ubuntu-latest - environment: testnet - steps: - - uses: actions/checkout@v4 - - - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_TESTNET }} - service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet - - - name: Build and push image - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . - docker push "$IMAGE" - - - name: Deploy to Cloud Run - env: - SHA: ${{ github.sha }} - run: | - export REGISTRY="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}" - envsubst < tools/${CRATE}/deploy/cloud-run.testnet.yaml > /tmp/svc.yaml - gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet - URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') - echo "URL=$URL" >> "$GITHUB_ENV" - - - name: Smoke test - run: | - for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do - curl -fsS "${URL}${p}/health" - curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null - done - - - name: Install Nexus CLI - run: | - curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash - echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" - - - name: Register / update tools on Nexus testnet - run: bash tools/${CRATE}/deploy/register.sh "$URL" testnet diff --git a/tools/payments-stripe/AUDIT.md b/offchain/tools/payments-stripe/AUDIT.md similarity index 100% rename from tools/payments-stripe/AUDIT.md rename to offchain/tools/payments-stripe/AUDIT.md diff --git a/tools/payments-stripe/Cargo.toml b/offchain/tools/payments-stripe/Cargo.toml similarity index 84% rename from tools/payments-stripe/Cargo.toml rename to offchain/tools/payments-stripe/Cargo.toml index 6c9f775..e953082 100644 --- a/tools/payments-stripe/Cargo.toml +++ b/offchain/tools/payments-stripe/Cargo.toml @@ -12,6 +12,10 @@ authors.workspace = true keywords.workspace = true categories.workspace = true +[[bin]] +name = "payments-stripe" +path = "src/main.rs" + [dependencies] thiserror.workspace = true tokio.workspace = true @@ -26,3 +30,7 @@ nexus-sdk.workspace = true [dev-dependencies] mockito.workspace = true + +[build-dependencies] +serde_json.workspace = true +toml = "0.8" diff --git a/tools/payments-stripe/README.md b/offchain/tools/payments-stripe/README.md similarity index 100% rename from tools/payments-stripe/README.md rename to offchain/tools/payments-stripe/README.md diff --git a/offchain/tools/payments-stripe/build.rs b/offchain/tools/payments-stripe/build.rs new file mode 100644 index 0000000..07c24d8 --- /dev/null +++ b/offchain/tools/payments-stripe/build.rs @@ -0,0 +1,50 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let tools_json_path = manifest_dir.join("tools.json"); + let cargo_toml_path = manifest_dir.join("Cargo.toml"); + + println!("cargo::rerun-if-changed={}", tools_json_path.display()); + println!("cargo::rerun-if-changed={}", cargo_toml_path.display()); + + let tools_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&tools_json_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", tools_json_path.display())), + ) + .expect("tools.json must be valid JSON"); + + let command = tools_json["command"] + .as_str() + .expect("tools.json must have command"); + + let cargo_toml: toml::Value = toml::from_str( + &fs::read_to_string(&cargo_toml_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", cargo_toml_path.display())), + ) + .expect("Cargo.toml must be valid TOML"); + + let bin_name = cargo_toml + .get("bin") + .and_then(|b| b.as_array()) + .and_then(|bins| bins.first()) + .and_then(|b| b.get("name")) + .and_then(|n| n.as_str()); + + match bin_name { + Some(name) if name == command => {} + Some(name) => panic!( + "Binary name mismatch: Cargo.toml [[bin]] name = \"{name}\" \ + but tools.json command = \"{command}\". They must match." + ), + None => panic!( + "Cargo.toml has no [[bin]] section but tools.json specifies \ + command = \"{command}\". Add: [[bin]]\nname = \"{command}\"" + ), + } + + // FQN version is set by CI via Docker build-arg; defaults to "1" locally. + let version = env::var("TOOL_FQN_VERSION").unwrap_or_else(|_| "1".to_string()); + println!("cargo:rustc-env=TOOL_FQN_VERSION={version}"); + println!("cargo:rerun-if-env-changed=TOOL_FQN_VERSION"); +} diff --git a/tools/payments-stripe/src/error.rs b/offchain/tools/payments-stripe/src/error.rs similarity index 100% rename from tools/payments-stripe/src/error.rs rename to offchain/tools/payments-stripe/src/error.rs diff --git a/tools/payments-stripe/src/main.rs b/offchain/tools/payments-stripe/src/main.rs similarity index 100% rename from tools/payments-stripe/src/main.rs rename to offchain/tools/payments-stripe/src/main.rs diff --git a/tools/payments-stripe/src/stripe_client.rs b/offchain/tools/payments-stripe/src/stripe_client.rs similarity index 100% rename from tools/payments-stripe/src/stripe_client.rs rename to offchain/tools/payments-stripe/src/stripe_client.rs diff --git a/tools/payments-stripe/src/tools/confirm_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs similarity index 97% rename from tools/payments-stripe/src/tools/confirm_payment_intent.rs rename to offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs index bc91586..3b01790 100644 --- a/tools/payments-stripe/src/tools/confirm_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs @@ -63,7 +63,10 @@ impl NexusTool for ConfirmPaymentIntent { } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.confirm-payment-intent@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.confirm-payment-intent@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { diff --git a/tools/payments-stripe/src/tools/create_customer.rs b/offchain/tools/payments-stripe/src/tools/create_customer.rs similarity index 97% rename from tools/payments-stripe/src/tools/create_customer.rs rename to offchain/tools/payments-stripe/src/tools/create_customer.rs index 84c3413..cbf9afb 100644 --- a/tools/payments-stripe/src/tools/create_customer.rs +++ b/offchain/tools/payments-stripe/src/tools/create_customer.rs @@ -57,7 +57,10 @@ impl NexusTool for CreateCustomer { } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.create-customer@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.create-customer@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { diff --git a/tools/payments-stripe/src/tools/create_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs similarity index 98% rename from tools/payments-stripe/src/tools/create_payment_intent.rs rename to offchain/tools/payments-stripe/src/tools/create_payment_intent.rs index f30fa57..0ddc107 100644 --- a/tools/payments-stripe/src/tools/create_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs @@ -73,7 +73,10 @@ impl NexusTool for CreatePaymentIntent { } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.create-payment-intent@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.create-payment-intent@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { diff --git a/tools/payments-stripe/src/tools/get_balance.rs b/offchain/tools/payments-stripe/src/tools/get_balance.rs similarity index 96% rename from tools/payments-stripe/src/tools/get_balance.rs rename to offchain/tools/payments-stripe/src/tools/get_balance.rs index afec74c..0c06785 100644 --- a/tools/payments-stripe/src/tools/get_balance.rs +++ b/offchain/tools/payments-stripe/src/tools/get_balance.rs @@ -50,7 +50,10 @@ impl NexusTool for GetBalance { } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.get-balance@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.get-balance@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { diff --git a/tools/payments-stripe/src/tools/get_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs similarity index 97% rename from tools/payments-stripe/src/tools/get_payment_intent.rs rename to offchain/tools/payments-stripe/src/tools/get_payment_intent.rs index cd5a084..c059a6d 100644 --- a/tools/payments-stripe/src/tools/get_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs @@ -58,7 +58,10 @@ impl NexusTool for GetPaymentIntent { } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.get-payment-intent@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.get-payment-intent@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { diff --git a/tools/payments-stripe/src/tools/list_charges.rs b/offchain/tools/payments-stripe/src/tools/list_charges.rs similarity index 97% rename from tools/payments-stripe/src/tools/list_charges.rs rename to offchain/tools/payments-stripe/src/tools/list_charges.rs index 60177e0..3da217a 100644 --- a/tools/payments-stripe/src/tools/list_charges.rs +++ b/offchain/tools/payments-stripe/src/tools/list_charges.rs @@ -53,7 +53,10 @@ impl NexusTool for ListCharges { } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.list-charges@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.list-charges@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { diff --git a/tools/payments-stripe/src/tools/mod.rs b/offchain/tools/payments-stripe/src/tools/mod.rs similarity index 100% rename from tools/payments-stripe/src/tools/mod.rs rename to offchain/tools/payments-stripe/src/tools/mod.rs diff --git a/tools/payments-stripe/src/tools/models.rs b/offchain/tools/payments-stripe/src/tools/models.rs similarity index 100% rename from tools/payments-stripe/src/tools/models.rs rename to offchain/tools/payments-stripe/src/tools/models.rs diff --git a/offchain/tools/payments-stripe/tools.json b/offchain/tools/payments-stripe/tools.json new file mode 100644 index 0000000..bdcfd30 --- /dev/null +++ b/offchain/tools/payments-stripe/tools.json @@ -0,0 +1,7 @@ +{ + "tool_name": "payments-stripe", + "command": "payments-stripe", + "environment": { + "RUST_LOG": "info" + } +} diff --git a/tools/payments-stripe/deploy/Dockerfile b/tools/payments-stripe/deploy/Dockerfile deleted file mode 100644 index 622fdd7..0000000 --- a/tools/payments-stripe/deploy/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# ---- Builder ---------------------------------------------------------------- -FROM rust:1.83-bookworm AS builder -WORKDIR /build - -RUN apt-get update && apt-get install -y --no-install-recommends \ - pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* - -# Copy the whole workspace — `members = ["tools/*"]` requires every -# sibling crate to exist for cargo's resolver. -COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ -COPY tools tools -COPY helpers helpers - -RUN cargo +stable build --release --package payments-stripe - -# ---- Runtime ---------------------------------------------------------------- -FROM gcr.io/distroless/cc-debian12:nonroot AS runtime -WORKDIR /app - -COPY --from=builder /build/target/release/payments-stripe /app/payments-stripe - -ENV BIND_ADDR=0.0.0.0:8080 -EXPOSE 8080 - -USER nonroot -ENTRYPOINT ["/app/payments-stripe"] diff --git a/tools/payments-stripe/deploy/cloud-run.mainnet.yaml b/tools/payments-stripe/deploy/cloud-run.mainnet.yaml deleted file mode 100644 index 7970b80..0000000 --- a/tools/payments-stripe/deploy/cloud-run.mainnet.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Credential model: this service holds NO Stripe API credentials. -# The only secrets mounted here are: -# - nexus-toolkit-config-mainnet-payments-stripe (Tool's own Ed25519 signing key) -# - nexus-allowed-leaders-mainnet (Leader public keys, not secret) -# Stripe `api_key` arrives in the per-request Input struct, sourced by -# the Leader at DAG-execution time. Adding any other secretKeyRef fails -# the auditor's C10 check. -apiVersion: serving.knative.dev/v1 -kind: Service -metadata: - name: "payments-stripe-mainnet" - labels: - network: mainnet - tool-fqn: "xyz.taluslabs.payments.stripe" - annotations: - run.googleapis.com/launch-stage: GA -spec: - template: - metadata: - annotations: - autoscaling.knative.dev/minScale: "1" - autoscaling.knative.dev/maxScale: "20" - run.googleapis.com/execution-environment: gen2 - spec: - serviceAccountName: "nexus-tools-mainnet@${PROJECT_ID}.iam.gserviceaccount.com" - timeoutSeconds: 30 - containerConcurrency: 80 - containers: - - name: tool - image: "${REGISTRY}/payments-stripe:${SHA}" - ports: - - name: http1 - containerPort: 8080 - env: - - name: BIND_ADDR - value: 0.0.0.0:8080 - - name: NEXUS_TOOLKIT_CONFIG_PATH - value: /etc/nexus/toolkit.json - - name: NEXUS_NETWORK - value: mainnet - volumeMounts: - - name: nexus-config - mountPath: /etc/nexus - readOnly: true - - name: allowed-leaders - mountPath: /etc/nexus/allowed_leaders - readOnly: true - resources: - limits: - cpu: "2" - memory: 1Gi - volumes: - - name: nexus-config - secret: - secretName: "nexus-toolkit-config-mainnet-payments-stripe" - - name: allowed-leaders - secret: - secretName: "nexus-allowed-leaders-mainnet" - traffic: - - percent: 100 - latestRevision: true diff --git a/tools/payments-stripe/deploy/cloud-run.testnet.yaml b/tools/payments-stripe/deploy/cloud-run.testnet.yaml deleted file mode 100644 index 59c22b0..0000000 --- a/tools/payments-stripe/deploy/cloud-run.testnet.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Credential model: this service holds NO Stripe API credentials. -# The only secrets mounted here are: -# - nexus-toolkit-config-testnet-payments-stripe (Tool's own Ed25519 signing key) -# - nexus-allowed-leaders-testnet (Leader public keys, not secret) -# Stripe `api_key` arrives in the per-request Input struct, sourced by -# the Leader at DAG-execution time. Adding any other secretKeyRef fails -# the auditor's C10 check. -apiVersion: serving.knative.dev/v1 -kind: Service -metadata: - name: "payments-stripe-testnet" - labels: - network: testnet - tool-fqn: "xyz.taluslabs.payments.stripe" - annotations: - run.googleapis.com/launch-stage: GA -spec: - template: - metadata: - annotations: - autoscaling.knative.dev/minScale: "0" - autoscaling.knative.dev/maxScale: "5" - run.googleapis.com/execution-environment: gen2 - spec: - serviceAccountName: "nexus-tools-testnet@${PROJECT_ID}.iam.gserviceaccount.com" - timeoutSeconds: 30 - containerConcurrency: 80 - containers: - - name: tool - image: "${REGISTRY}/payments-stripe:${SHA}" - ports: - - name: http1 - containerPort: 8080 - env: - - name: BIND_ADDR - value: 0.0.0.0:8080 - - name: NEXUS_TOOLKIT_CONFIG_PATH - value: /etc/nexus/toolkit.json - - name: NEXUS_NETWORK - value: testnet - volumeMounts: - - name: nexus-config - mountPath: /etc/nexus - readOnly: true - - name: allowed-leaders - mountPath: /etc/nexus/allowed_leaders - readOnly: true - resources: - limits: - cpu: "1" - memory: 512Mi - volumes: - - name: nexus-config - secret: - secretName: "nexus-toolkit-config-testnet-payments-stripe" - - name: allowed-leaders - secret: - secretName: "nexus-allowed-leaders-testnet" - traffic: - - percent: 100 - latestRevision: true diff --git a/tools/payments-stripe/deploy/register.sh b/tools/payments-stripe/deploy/register.sh deleted file mode 100755 index 65bfed5..0000000 --- a/tools/payments-stripe/deploy/register.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Idempotent registration of payments-stripe tools with Nexus. -# -# Usage: -# register.sh -# -# Reads tool paths from ../paths.json, fetches each tool's FQN from -# /meta, then registers (or updates) each FQN against the chosen Nexus -# network. - -set -euo pipefail - -URL="${1:?missing first arg: tool URL}" -NETWORK="${2:?missing second arg: testnet|mainnet}" - -case "$NETWORK" in - testnet|mainnet) ;; - *) echo "network must be 'testnet' or 'mainnet'"; exit 2 ;; -esac - -PATHS_FILE="$(dirname "$0")/../paths.json" -if [[ ! -f "$PATHS_FILE" ]]; then - echo "expected $PATHS_FILE to enumerate tool paths" >&2 - exit 2 -fi - -mapfile -t PATHS < <(jq -r '.[]' "$PATHS_FILE") - -for path in "${PATHS[@]}"; do - meta_url="${URL%/}${path}/meta" - meta="$(curl -fsS "$meta_url")" - fqn="$(echo "$meta" | jq -r .fqn)" - description="$(echo "$meta" | jq -r .description)" - - echo "registering $fqn at $URL$path on $NETWORK" - - if nexus tool list --network "$NETWORK" 2>/dev/null | grep -q "$fqn"; then - nexus tool update offchain \ - --network "$NETWORK" \ - --tool-fqn "$fqn" \ - --url "${URL%/}${path}" - else - nexus tool register offchain \ - --network "$NETWORK" \ - --tool-fqn "$fqn" \ - --url "${URL%/}${path}" \ - --description "$description" - fi -done diff --git a/tools/payments-stripe/paths.json b/tools/payments-stripe/paths.json deleted file mode 100644 index 28e0a40..0000000 --- a/tools/payments-stripe/paths.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - "/create-payment-intent", - "/get-payment-intent", - "/confirm-payment-intent", - "/create-customer", - "/get-balance", - "/list-charges" -] From 7da566980c488298cb2e8fddc4a477b1d702a53e Mon Sep 17 00:00:00 2001 From: Mike Hanono Date: Mon, 25 May 2026 19:04:56 +0000 Subject: [PATCH 4/5] payments-stripe: read STRIPE_API_KEY from env, drop api_key from Input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on #37 (which already moved the crate to offchain/tools/ and added tools.json + build.rs). This PR is the security fix #37 deliberately left out: every endpoint's Input struct had `pub api_key: String`, which flowed through the Nexus DAG to Sui as plaintext on every invocation. Adopts the env-var credential pattern from offchain/tools/memory-memwal: - stripe_client.rs: `from_env()` reads STRIPE_API_KEY once at startup, wraps in `zeroize::Zeroizing` (heap-zeroed on drop), validates the prefix (`sk_test_` / `sk_live_` / `rk_test_` / `rk_live_`). Hand- written `Debug` prints `` — never `#[derive(Debug)]` on the credential-bearing type. Shared HTTP client lives in a `OnceLock` so the connection pool survives across `invoke` calls. `#[cfg(test)] for_testing(base_url, bearer)` constructor bypasses env validation for mockito tests. - main.rs: explicit multi-thread tokio runtime; `env_logger` init before anything else; `--meta` short-circuit so CI's prepare step can pull /meta from an image with no env; `load_dotenv_if_present()` and `validate_credentials_at_startup()` before `bootstrap!`. `main` is the only `process::exit` site. Exit 1 if STRIPE_API_KEY is unset, empty, or has an unrecognized prefix. - All six endpoint files: dropped `pub api_key: String` from Input. Dropped `.with_auth(&input.api_key)` from invoke(). NexusTool::new() calls `StripeClient::from_env().unwrap_or_else(|e| { log::error!(...); panic!(...) })` — startup-only panic, mirrors memwal. `idempotency_key` stays on Input (per-call dedup data, not a credential — explicit whitelist in the auditor). - Tests: every `StripeClient::new(Some(&server.url()))` → `StripeClient::for_testing(&server.url(), "sk_test_FAKE")`. Every `api_key:` field dropped from `test_input()` and from inline Input literals. `get-balance` Input is now empty (`{}`) — only the STRIPE_API_KEY env var matters; the `_input` arg in invoke is unused. - Cargo.toml: added workspace deps `anyhow`, `dotenvy`, `env_logger`, `log`, `zeroize` for the env-var path. - README.md: rewrote the credential section to document the env var. Dropped `api_key` from every per-FQN Input table. Added an env-vars table. Threaded `` into each FQN heading (matching the actual `concat!(..., env!("TOOL_FQN_VERSION"))` in source). - .env.example: documents STRIPE_API_KEY for local dev. In production Cloud Run mounts it via `secretKeyRef` from GCP Secret Manager, configured by the operator out-of-band — the deploy pipeline does NOT provision the upstream API key. tools.json's `environment` block is RUST_LOG only (C10). - Regenerated AUDIT.md is deferred — run the auditor agent against the merged tree, or invoke it after this branch lands. Markdown linter fix: aligned 4 skill reference files' compact table separators (`|---|` → `| --- |`) to satisfy MD060. These same files are rewritten by PR #31; the conflict at merge is a no-op since both branches use `| --- |`. Touched only to unblock `just pre-commit::md-lint` on this branch. Verified locally: - bash off-chain payments-stripe → PASS static:input-credential → PASS static:debug-on-credentials → PASS static:stateful → PASS tools-json:shape / secret-in-env → PASS conform:output-enum / description / fqn-versioned (cargo:check fails — sandbox is missing pkg-config/libssl-dev for openssl-sys; orthogonal to this change. PR #37 author noted the same in their PR description; verification happens via CI on push.) - npx markdownlint-cli2 '**/*.md' '!.git/' → 0 errors across 26 files. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reference/hosting-options.md | 2 +- .../nexus-tool-builder/reference/interface.md | 8 +- .../reference/security-checklist.md | 18 +- .../reference/style-guide.md | 2 +- offchain/tools/payments-stripe/.env.example | 14 ++ offchain/tools/payments-stripe/AUDIT.md | 230 ------------------ offchain/tools/payments-stripe/Cargo.toml | 13 +- offchain/tools/payments-stripe/README.md | 65 +++-- offchain/tools/payments-stripe/src/main.rs | 49 +++- .../payments-stripe/src/stripe_client.rs | 229 +++++++++++++++-- .../src/tools/confirm_payment_intent.rs | 21 +- .../src/tools/create_customer.rs | 22 +- .../src/tools/create_payment_intent.rs | 26 +- .../payments-stripe/src/tools/get_balance.rs | 26 +- .../src/tools/get_payment_intent.rs | 18 +- .../payments-stripe/src/tools/list_charges.rs | 17 +- 16 files changed, 388 insertions(+), 372 deletions(-) create mode 100644 offchain/tools/payments-stripe/.env.example delete mode 100644 offchain/tools/payments-stripe/AUDIT.md diff --git a/.claude/skills/nexus-tool-builder/reference/hosting-options.md b/.claude/skills/nexus-tool-builder/reference/hosting-options.md index 0b92aa3..247e6a0 100644 --- a/.claude/skills/nexus-tool-builder/reference/hosting-options.md +++ b/.claude/skills/nexus-tool-builder/reference/hosting-options.md @@ -23,7 +23,7 @@ The `templates/deploy/` folder emits: ## Alternatives (per-tool migration targets) | Provider | Fit | Trade-off | -|---|---|---| +| --- | --- | --- | | **Akash Network** | Best general DePIN fit. Mature compute marketplace, supports arbitrary Docker HTTPS services. | More ops friction than Cloud Run; debugging tooling weaker. | | **Spheron** | Web3 cloud aggregator on top of AWS / Akash. Closest to managed-deploy UX with a DePIN backend. | Newer; smaller ecosystem than Cloud Run. | | **Atoma Network** | Sui-native AI inference DePIN. Best fit when the tool itself is LLM inference. | Not general-purpose hosting — inference workloads only. | diff --git a/.claude/skills/nexus-tool-builder/reference/interface.md b/.claude/skills/nexus-tool-builder/reference/interface.md index 125dde2..610a2b4 100644 --- a/.claude/skills/nexus-tool-builder/reference/interface.md +++ b/.claude/skills/nexus-tool-builder/reference/interface.md @@ -5,14 +5,14 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Required associated types | Type | Constraints | Purpose | -|---|---|---| +| --- | --- | --- | | `Input` | `JsonSchema + DeserializeOwned + Send` | Generates the input schema. Deserialized from the request body. | | `Output` | `JsonSchema + Serialize + Send`, **must be a Rust `enum`** so the schema gets a top-level `oneOf` | Generates the output schema. Serialized into the response. | ## Required methods | Method | Signature | Notes | -|---|---|---| +| --- | --- | --- | | `fqn` | `fn fqn() -> ToolFqn` | Use the `fqn!("xyz.taluslabs...@1")` macro. | | `new` | `async fn new() -> Self` | Called per request. Inject dependencies here. | | `invoke` | `async fn invoke(&self, input: Self::Input) -> Self::Output` | Main logic. **No `Result`** — errors go in `Output::Err`. | @@ -21,7 +21,7 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Optional methods (have defaults) | Method | Default | When to override | -|---|---|---| +| --- | --- | --- | | `path` | `""` (root) | When multiple tools live in one crate — each needs a unique path. | | `description` | `""` | Always — surfaces in `/meta`. | | `timeout` | `Duration::from_secs(10)` | Override for slow upstreams; keep below the Leader's request budget. | @@ -30,7 +30,7 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Generated endpoints (from `NexusTool` impl) | Endpoint | Body | -|---|---| +| --- | --- | | `GET /health` | Status code returned by `health()`. | | `GET /meta` | JSON: `{ fqn, url, timeout, description, input_schema, output_schema }`. | | `POST /invoke` | Deserializes body as `Input`, calls `invoke`, serializes the result as `Output`. | diff --git a/.claude/skills/nexus-tool-builder/reference/security-checklist.md b/.claude/skills/nexus-tool-builder/reference/security-checklist.md index cdc86b1..88c9acf 100644 --- a/.claude/skills/nexus-tool-builder/reference/security-checklist.md +++ b/.claude/skills/nexus-tool-builder/reference/security-checklist.md @@ -7,7 +7,7 @@ its `AUDIT.md` output. ## Off-chain (Rust) — CRITICAL | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | C1 | `Output` is a Rust `enum` (not a struct) with `#[serde(rename_all = "snake_case")]` | Nexus runtime rejects non-`oneOf` schemas. | | C2 | No `unwrap()` / `expect()` / `panic!` on any path reachable from `invoke()` | Panics surface as opaque 500s; Nexus expects typed `Err` variants. | | C3 | No `danger_accept_invalid_certs` or any TLS bypass | Leader-side TLS verification is part of the Nexus trust model. | @@ -23,7 +23,7 @@ its `AUDIT.md` output. ## Off-chain — HIGH | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | H1 | `Input` has `#[serde(deny_unknown_fields)]` | Stops silent acceptance of typos / injection of unknown control fields. | | H2 | Every `NexusTool::path()` is unique within the crate | `bootstrap!` will mount duplicates at the same route; behavior is undefined. | | H3 | `NexusTool::timeout()` is overridden to a value below the Leader request budget but above 2× expected upstream latency | Default 10s is too short for many real APIs and too long for cheap ones. | @@ -38,7 +38,7 @@ its `AUDIT.md` output. ## Off-chain — MEDIUM | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | M1 | `NexusTool::description()` is non-empty | `/meta` exposes this to DAG authors. | | M2 | Every endpoint has a `mockito`-backed happy-path test | Regressions caught at PR time. | | M3 | Every endpoint has at least one error-variant test | Same. | @@ -49,7 +49,7 @@ its `AUDIT.md` output. ## Off-chain — LOW / INFO | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | L1 | Doc comments on every `Input` / `Output` field — they show up in `input_schema` / `output_schema` | DAG authors need this. | | L2 | `Cargo.toml` `description` is set | Helps `cargo metadata` consumers. | | L3 | README has one section per FQN | Convention; consumed by tooling. | @@ -57,7 +57,7 @@ its `AUDIT.md` output. ## On-chain (Move) — CRITICAL | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | C-M1 | `execute` first parameter is `worksheet: &mut ProofOfUID`, last is `ctx: &mut TxContext`, returns `TaggedOutput` | Runtime enforces this signature. | | C-M2 | `worksheet.stamp_with_data(&witness.id, …)` is called on **every** code path before `execute` returns | Without it the Nexus runtime rejects the proof. Easy to miss in early-return branches. | | C-M3 | Witness struct does NOT have the `copy` ability | A copyable witness lets anyone forge proofs. | @@ -68,7 +68,7 @@ its `AUDIT.md` output. ## On-chain — HIGH | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | H-M1 | Output enum has at least one `err`-prefixed variant | Nexus treats them as error variants. | | H-M2 | TaggedOutput field types use the right `as_*()` (number/string/bool/address/raw) | Wrong typing breaks downstream tools at the schema layer, after the Move call succeeded. | | H-M3 | No unbounded loops over caller-controlled `vector` or dynamic field reads | Gas grief / out-of-budget aborts. | @@ -79,7 +79,7 @@ its `AUDIT.md` output. ## On-chain — MEDIUM | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | M-M1 | No `friend` modules outside this package | `friend` widens access; reviewers need to follow the trust path. | | M-M2 | Every shared object is initialized in `init` and not re-creatable | Drift between the published state and the registered witness id breaks Nexus. | | M-M3 | Test for the witness-stamping flow (positive assertion that the worksheet has the expected stamp after `execute`) | Catches silent misuse of `stamp_with_data`. | @@ -87,14 +87,14 @@ its `AUDIT.md` output. ## On-chain — LOW / INFO | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | L-M1 | Doc comments on `execute` and on every `Output` variant | Schema generation reads them. | | L-M2 | Module path matches the FQN convention `..@` | Convention; helps discovery. | ## Cross-cutting (both kinds) | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | X1 | FQN version (`@1`) is bumped when output schema changes | Existing DAGs break otherwise. | | X2 | `description` (Rust) / module-level docstrings (Move) accurately describe behavior | Misleading descriptions break DAG composition by humans and agents. | | X3 | Tool is **idempotent** under retry — same input ⇒ same output, side-effect-free for read tools | The Leader retries on transient failures. | diff --git a/.claude/skills/nexus-tool-builder/reference/style-guide.md b/.claude/skills/nexus-tool-builder/reference/style-guide.md index 0e30d0a..d6a15d1 100644 --- a/.claude/skills/nexus-tool-builder/reference/style-guide.md +++ b/.claude/skills/nexus-tool-builder/reference/style-guide.md @@ -28,7 +28,7 @@ Err { ## Interface design | ✅ Do | ❌ Don't | -|---|---| +| --- | --- | | Build a tool that encapsulates the API's surface (one tool per endpoint, parameterized). | Build a tool that only does one hardcoded call (e.g. "BTC-USD spot price"). | | Split `prompt` and `context` into separate input ports even if the API merges them. | Merge them into one input port — the DAG can't set defaults for fields combined with edge data. | | Accept `json_schema` as input and validate generic responses against it (where applicable). | Hardcode the output schema for a single endpoint when the underlying API serves many. | diff --git a/offchain/tools/payments-stripe/.env.example b/offchain/tools/payments-stripe/.env.example new file mode 100644 index 0000000..67748a1 --- /dev/null +++ b/offchain/tools/payments-stripe/.env.example @@ -0,0 +1,14 @@ +# payments-stripe — required environment variables. +# +# Copy to `.env` for local dev (this file MUST stay in source control; +# `.env` MUST be gitignored). In production these come from Cloud Run +# `secretKeyRef` bindings to GCP Secret Manager, configured by the +# operator out-of-band (the deploy pipeline does NOT provision the key). +# +# The tool exits 1 at startup if STRIPE_API_KEY is unset, empty, or does +# not start with sk_test_ / sk_live_ / rk_test_ / rk_live_. + +STRIPE_API_KEY=sk_test_replace_with_real_key + +# Optional. Defaults to "info". +RUST_LOG=info diff --git a/offchain/tools/payments-stripe/AUDIT.md b/offchain/tools/payments-stripe/AUDIT.md deleted file mode 100644 index 9e11513..0000000 --- a/offchain/tools/payments-stripe/AUDIT.md +++ /dev/null @@ -1,230 +0,0 @@ -# Audit: `payments-stripe` @ 815a8c1+wip - -- **Date:** 2026-05-15 -- **Auditor:** nexus-tool-auditor (executed inline by primary agent — project agent loader unavailable in this harness) -- **Kind:** off-chain -- **Severity floor:** low -- **Remediation mode:** report-only - -## Summary - -The crate cleanly passes every CRITICAL and HIGH check on the -`reference/security-checklist.md` matrix. 17/17 unit tests pass; clippy -is clean with `-D warnings`; `cargo fmt --check` is clean against -`nightly-2025-01-06`. The credential model matches the repo convention -(per-request `Input.api_key`; no env-var reads; no Debug derived on -Input; no upstream secrets mounted into Cloud Run). The tool is -stateless across invocations. - -Three MEDIUM items remain — they are not blockers for testnet but -should be addressed before mainnet promotion: (1) a Stripe error body -is included verbatim in the fallback `Output::Err::reason` and may -echo customer-supplied params; (2) some endpoints have thin -error-variant test coverage; (3) `NexusTool::timeout()` is not -overridden anywhere (defaults to 10 s, acceptable for current Stripe -endpoints but worth pinning explicitly). - -**Recommendation: ready-for-testnet.** Block mainnet pending the three -MEDIUM items below. - -## Findings - -### CRITICAL - -_No findings._ - -### HIGH - -_No findings._ - -### MEDIUM - -- **M-1 — Raw upstream body in fallback `Output::Err::reason`** - (`src/stripe_client.rs:127`) - - What: When the response body does not match Stripe's `{"error": - {...}}` envelope, the client falls back to - `format!("Stripe API error ({}): {}", status, truncate(&text, 512))`. - The raw body can contain card metadata (last 4), customer email, or - other parameters the caller submitted that Stripe echoes back in - plaintext. - - Why it matters: `Output::Err` is passed on-chain by Nexus; anything - in `reason` becomes public and permanent. Falls under C5 spirit - (defense in depth) even though Stripe error bodies don't directly - leak the api_key. - - Fix: Drop the raw body. Surface only the status code and the typed - `kind`: - - ```rust - return Err(StripeErrorResponse { - reason: format!("Stripe API error (status {})", status), - kind: StripeErrorKind::from_status_code(status.as_u16()), - status_code: Some(status.as_u16()), - }); - ``` - - If diagnostic detail is needed, write the body to a `tracing::warn!` - log line with field-level redaction, not to `reason`. - -- **M-2 — Thin error-variant test coverage in three endpoints** - - `tools/payments-stripe/src/tools/confirm_payment_intent.rs`: has - only a single validation test (`empty_id`); no test for - `card_error` / `auth_error` paths. - - `tools/payments-stripe/src/tools/get_balance.rs`: no error tests at - all (only `test_get_balance_success`). - - `tools/payments-stripe/src/tools/list_charges.rs`: covers - `limit_out_of_range` (local validation) but no upstream-error case. - - Why it matters: M3 says every endpoint has at least one error-variant - test. Stripe behavior changes; the regression net needs to catch a - drift in the error envelope on every endpoint. - - Fix: add a mockito-backed 401/402/404 test per endpoint following - the pattern in `create_payment_intent::tests::test_create_payment_intent_card_error`. - -- **M-3 — `NexusTool::timeout()` not overridden** - - What: Every endpoint uses the toolkit default of 10 s. Stripe - typical p95 latency is ~500 ms but the 99th percentile during - incidents has been seen above 5 s. The default is fine, but - leaving it implicit creates risk on any subsequent endpoint - addition that needs longer (e.g. webhook-based flows). - - Fix: Explicitly override per endpoint, e.g. - - ```rust - fn timeout() -> Duration { Duration::from_secs(15) } - ``` - - on every `impl NexusTool`. Document the chosen value next to the - override. - -### LOW - -- **L-1 — Doc comments missing on some fields** - - `get_payment_intent.rs::Input`, `confirm_payment_intent.rs::Input`, - `create_customer.rs::Input`, `get_balance.rs::Input`, - `list_charges.rs::Input` — `api_key` and other fields lack doc - comments. They surface in `input_schema` (the comments do, when - present); DAG authors and tooling rely on these. - - `create_payment_intent.rs::Input` is the gold standard — propagate - similar docstrings to the other five endpoints. - -- **L-2 — `Output::Ok` field types lack doc comments** - - Same surface (`output_schema`); add `///` comments to each port - field in every endpoint's `Output::Ok` variant. - -### INFO - -- **I-1 — `cargo audit` and `cargo deny` not run in this sandbox.** - The dev box does not have either binary installed. Both must be in - the CI image before the GitHub Actions workflow runs against - production. RustSec advisory database changes daily; baking the - checks into CI is the only durable mitigation. - -- **I-2 — One `expect()` in `StripeClient::new()`** - (`src/stripe_client.rs:34`) - - `Client::builder().user_agent(...).build().expect(...)`. This is - startup-time and the failure mode (`reqwest` cannot create a TLS - backend) is unrecoverable. Acceptable as a fail-fast. If we want - the agent to flag zero `unwrap()`s in any non-test code, this - becomes a tiny `match ... else std::process::abort()` cleanup — - not worth it. - -## Backtest results - -The crate has 17 mockito-backed unit tests covering happy paths and -the error classes enumerated below. No golden-file fixtures yet — those -will accumulate as the team probes against real Stripe test mode. - -| Endpoint | Happy path | Error coverage | -|---|---|---| -| create-payment-intent | ✅ | card_error, idempotency_error, rate_limit, zero-amount validation, empty-currency validation | -| get-payment-intent | ✅ | invalid_request (404), empty-id validation | -| confirm-payment-intent | ✅ (2 — succeeded + requires_action) | empty-id validation only | -| create-customer | ✅ | auth_error | -| get-balance | ✅ | _none_ | -| list-charges | ✅ | limit out-of-range only (local) | - -Action items in **M-2**. - -## Fuzz results - -Not executed for this audit — the in-sandbox build is debug and the -release binary was not started by the time of writing. The verify.sh -smoke loop covers /health and /meta only. Recommend running an explicit -malformed-payload sweep before mainnet: - -```sh -# template — adjust path / port -for payload in '{}' '{"api_key":null}' '{"api_key":1}' '{"api_key":"x","unknown":1}' \ - '{"api_key":"x","amount":-1,"currency":"usd"}' \ - '{"api_key":"x","amount":2000,"currency":""}'; do - curl -fsS -X POST 127.0.0.1:8080/create-payment-intent/invoke \ - -H 'content-type: application/json' --data-raw "$payload" || echo "failed: $payload" -done -``` - -Expected: never a 5xx, always either `Output::Err` JSON or a structured -4xx from the toolkit. - -## Conformance checklist (verbatim against `security-checklist.md`) - -### CRITICAL — all PASS - -- [x] C1 `Output` is enum with snake_case rename — every endpoint -- [x] C2 No `unwrap`/`expect`/`panic!` reachable from `invoke()` (the one `expect` at `stripe_client.rs:34` is `new()` only — see I-2) -- [x] C3 No `danger_accept_invalid_certs` / TLS bypass -- [x] C4 No hardcoded API keys (tests use `sk_test_FAKE...`) -- [x] C5 `Output::Err::reason` does not leak request URLs / file paths / stacks (partial — see M-1 for upstream-body echo concern) -- [x] C6 No `process::exit`, `unsafe`, child processes -- [x] C7 No env-var or disk reads for credentials -- [x] C8 `Input` does NOT derive `Debug` on any endpoint -- [x] C9 Tool is stateless — no `static`, `lazy_static`, `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell`. The `Arc` is a stateless connection pool; `.clone().with_auth()` produces a per-call builder. -- [x] C10 Cloud Run YAML mounts only `nexus-toolkit-config-*` and `nexus-allowed-leaders-*` -- [x] C11 README uses `sk_test_...`/`sk_live_...` only as placeholders, never as real keys - -### HIGH — all PASS - -- [x] H1 `#[serde(deny_unknown_fields)]` on every `Input` -- [x] H2 Six unique `path()` values -- [ ] H3 `timeout()` NOT explicitly overridden — using toolkit default of 10s. Acceptable for current endpoints; see M-3. -- [x] H4 User-agent set: `nexus-sdk-payments-stripe/1.0` -- [ ] H5 `cargo audit` not run in sandbox — see I-1 -- [x] H6 No logging of secret-shaped fields anywhere -- [x] H7 Credential field named `api_key` everywhere -- [x] H8 `idempotency_key: Option` on every POST endpoint; absent on GETs (correct) -- [x] H9 Tests use only `sk_test_FAKE` / `sk_test_FAKE_FOR_TESTS_ONLY` / `sk_test_BAD` -- [x] H10 Error classes covered in `from_api_error_type`: `invalid_request_error`, `card_error`, `validation_error`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `api_error`, `api_connection_error` - -### MEDIUM - -- [x] M1 `description()` overridden on every endpoint -- [x] M2 Happy-path test on every endpoint -- [ ] M3 Error-variant test gaps in confirm-payment-intent, get-balance, list-charges -- [x] M4 Crucial Ok fields non-optional; `Option` only where Stripe genuinely omits the field -- [x] M5 No `println!` / `dbg!` outside `#[cfg(test)]` -- [x] M6 All deps use `workspace = true` - -### LOW - -- [ ] L1 Doc comments missing on five endpoints' Input fields -- [x] L2 `Cargo.toml description` set -- [x] L3 Six sections in README, one per FQN - -### Cross-cutting - -- [x] X1 All FQNs are `@1` (new tool) -- [x] X2 Descriptions accurately summarize the endpoint -- [x] X3 Idempotent under retry — reads are GET; writes carry `idempotency_key` -- [x] X4 Auth happens at signed-HTTP / `authorize()` layer, not in the tool's `Input` - -## Sign-off - -**Recommendation: ready-for-testnet.** - -**Blockers for mainnet:** - -- M-1 (raw body in fallback `reason`) -- M-2 (thin error-variant tests on three endpoints) -- M-3 (explicit `timeout()` override per endpoint) -- I-1 (wire `cargo audit` + `cargo deny` into CI) - -Once those four are closed and a `payments-stripe` testnet deploy has -been live without incident for at least 72 hours, mainnet promotion is -recommended. diff --git a/offchain/tools/payments-stripe/Cargo.toml b/offchain/tools/payments-stripe/Cargo.toml index e953082..7b6c8e2 100644 --- a/offchain/tools/payments-stripe/Cargo.toml +++ b/offchain/tools/payments-stripe/Cargo.toml @@ -17,12 +17,17 @@ name = "payments-stripe" path = "src/main.rs" [dependencies] -thiserror.workspace = true -tokio.workspace = true +anyhow.workspace = true +dotenvy.workspace = true +env_logger.workspace = true +log.workspace = true reqwest = { workspace = true, features = ["json"] } -serde_json.workspace = true -serde.workspace = true schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +zeroize.workspace = true # === Nexus deps === nexus-toolkit.workspace = true diff --git a/offchain/tools/payments-stripe/README.md b/offchain/tools/payments-stripe/README.md index 4a098b2..ca50090 100644 --- a/offchain/tools/payments-stripe/README.md +++ b/offchain/tools/payments-stripe/README.md @@ -1,36 +1,59 @@ # Stripe tools for Nexus A set of stateless Nexus Tools that wrap the [Stripe REST -API](https://stripe.com/docs/api). Each tool is a single endpoint; pass -your Stripe secret key per request via the `api_key` input port. +API](https://stripe.com/docs/api). Each tool is a single endpoint. ## Credential handling -- **Never** put a real Stripe key in a DAG `default_values` entry — DAG - data is committed to Sui and is public + permanent. Use placeholders - like `$STRIPE_API_KEY` and have the Leader inject the real value at - execution time from its secret store. -- **Tests use `sk_test_...` keys only.** Real `sk_live_...` material - must never enter source, fixtures, or logs. -- The Tool process holds no Stripe credentials between requests. The - `api_key` lives only in the `Input` struct's scope inside `invoke()`. +- **The tool reads `STRIPE_API_KEY` once from the process environment at + startup.** Sources: + - Production (Cloud Run): mounted via `secretKeyRef` from GCP Secret + Manager, configured by the operator out-of-band (the deploy pipeline + does NOT provision the secret). + - Local dev: copy `.env.example` to `.env` and set + `STRIPE_API_KEY=sk_test_…`. +- **The credential never appears on any `Input` struct.** Tool inputs flow + through the Nexus DAG on Sui as plaintext — anything on `Input` is + effectively published on-chain. The skill's auditor will refuse to mark + the tool ready if any `Input` field is credential-shaped. +- The credential is wrapped in `zeroize::Zeroizing` (heap-zeroed + on drop). The struct hand-implements `Debug` to print ``. +- The tool exits 1 at startup if `STRIPE_API_KEY` is unset, empty, or does + not start with one of `sk_test_`, `sk_live_`, `rk_test_`, `rk_live_`. +- **Tests use `sk_test_…` keys only.** Real `sk_live_…` material must + never enter source, fixtures, or logs. + +## Environment variables + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `STRIPE_API_KEY` | **yes** | — | Stripe secret (`sk_test_…` for staging/test, `sk_live_…` for prod). Validated at startup; the tool refuses to boot without it. | +| `RUST_LOG` | no | `info` | env_logger filter. | +| `BIND_ADDR` | no | `127.0.0.1:8080` | The toolkit's HTTP bind address. | ## Idempotency Every write endpoint accepts an optional `idempotency_key`. Generate a UUID per logical retry-bucket in your DAG. Stripe guarantees identical responses for identical idempotency keys; reusing the same key on retry -prevents double-charges. +prevents double-charges. `idempotency_key` is per-call dedup data — NOT a +credential — so it stays on `Input`. + +## FQN versioning + +All six FQNs are threaded through `env!("TOOL_FQN_VERSION")` in +`build.rs`; CI sets `TOOL_FQN_VERSION` from the tool's subtree git hash, +so any source change auto-bumps the version on the next deploy. Local +builds default to `@1`. --- -# `xyz.taluslabs.payments.stripe.create-payment-intent@1` +# `xyz.taluslabs.payments.stripe.create-payment-intent@` Creates a [PaymentIntent](https://stripe.com/docs/api/payment_intents/create). ## Input -- **`api_key`: [`String`]** — Stripe secret key (`sk_test_...` for staging, `sk_live_...` for prod). Sourced by the Leader at execution time. - **`idempotency_key`: [`String`] (optional)** — `Idempotency-Key` header value. Generate a UUID per retry-bucket. - **`amount`: [`i64`]** — Amount in the smallest currency unit (e.g. cents for USD). - **`currency`: [`String`]** — ISO-4217 currency code (lowercase, e.g. `usd`). @@ -55,13 +78,12 @@ Creates a [PaymentIntent](https://stripe.com/docs/api/payment_intents/create). --- -# `xyz.taluslabs.payments.stripe.get-payment-intent@1` +# `xyz.taluslabs.payments.stripe.get-payment-intent@` Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve) by id. ## Input -- **`api_key`: [`String`]** — Stripe secret key. - **`payment_intent_id`: [`String`]** — `pi_…` to look up. ## Output Variants & Ports @@ -78,13 +100,12 @@ Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve --- -# `xyz.taluslabs.payments.stripe.confirm-payment-intent@1` +# `xyz.taluslabs.payments.stripe.confirm-payment-intent@` [Confirms](https://stripe.com/docs/api/payment_intents/confirm) a PaymentIntent. ## Input -- **`api_key`: [`String`]** - **`idempotency_key`: [`String`] (optional)** - **`payment_intent_id`: [`String`]** — `pi_…` to confirm. - **`payment_method`: [`String`] (optional)** — `pm_…` to attach (e.g. `pm_card_visa` in test mode). @@ -102,13 +123,12 @@ Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve --- -# `xyz.taluslabs.payments.stripe.create-customer@1` +# `xyz.taluslabs.payments.stripe.create-customer@` Creates a [Customer](https://stripe.com/docs/api/customers/create). ## Input -- **`api_key`: [`String`]** - **`idempotency_key`: [`String`] (optional)** - **`email`: [`String`] (optional)** - **`name`: [`String`] (optional)** @@ -126,13 +146,13 @@ Creates a [Customer](https://stripe.com/docs/api/customers/create). --- -# `xyz.taluslabs.payments.stripe.get-balance@1` +# `xyz.taluslabs.payments.stripe.get-balance@` Reads the platform [Balance](https://stripe.com/docs/api/balance/balance_retrieve). ## Input -- **`api_key`: [`String`]** +No input ports — credentials come from env. ## Output Variants & Ports @@ -145,13 +165,12 @@ Reads the platform [Balance](https://stripe.com/docs/api/balance/balance_retriev --- -# `xyz.taluslabs.payments.stripe.list-charges@1` +# `xyz.taluslabs.payments.stripe.list-charges@` Lists [Charges](https://stripe.com/docs/api/charges/list). ## Input -- **`api_key`: [`String`]** - **`limit`: [`i64`] (optional)** — Page size (1–100, Stripe default 10). - **`customer`: [`String`] (optional)** — Filter to a specific customer id. - **`starting_after`: [`String`] (optional)** — Cursor for pagination (charge id from the previous page). diff --git a/offchain/tools/payments-stripe/src/main.rs b/offchain/tools/payments-stripe/src/main.rs index d14ecf7..708896d 100644 --- a/offchain/tools/payments-stripe/src/main.rs +++ b/offchain/tools/payments-stripe/src/main.rs @@ -7,14 +7,43 @@ mod error; mod stripe_client; mod tools; -#[tokio::main] -async fn main() { - bootstrap!([ - tools::create_payment_intent::CreatePaymentIntent, - tools::get_payment_intent::GetPaymentIntent, - tools::confirm_payment_intent::ConfirmPaymentIntent, - tools::create_customer::CreateCustomer, - tools::get_balance::GetBalance, - tools::list_charges::ListCharges, - ]); +fn main() { + // Install env_logger before anything else so dotenv/credential paths + // emit through `log::*`; `bootstrap!`'s own `try_init()` becomes a no-op. + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + // `--meta` is an introspection mode that emits the tool's FQN/URL/ + // schema list without standing up the runtime. CI's prepare step + // invokes it from a fresh image with no env, so it must not require + // runtime credentials. `nexus_toolkit::bootstrap!` handles --meta + // and exits before any HTTP call is made. + let meta_only = std::env::args().any(|a| a == "--meta"); + + if !meta_only { + // dotenv + credential validation run single-threaded — `set_var` + // is unsound from a multi-threaded process. `main` is the only + // exit site. + stripe_client::load_dotenv_if_present(); + if let Err(reason) = stripe_client::validate_credentials_at_startup() { + log::error!("{} {reason}", stripe_client::ENV_API_KEY); + std::process::exit(1); + } + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime") + .block_on(async { + bootstrap!([ + tools::create_payment_intent::CreatePaymentIntent, + tools::get_payment_intent::GetPaymentIntent, + tools::confirm_payment_intent::ConfirmPaymentIntent, + tools::create_customer::CreateCustomer, + tools::get_balance::GetBalance, + tools::list_charges::ListCharges, + ]) + }); } diff --git a/offchain/tools/payments-stripe/src/stripe_client.rs b/offchain/tools/payments-stripe/src/stripe_client.rs index 16af9af..e490f8f 100644 --- a/offchain/tools/payments-stripe/src/stripe_client.rs +++ b/offchain/tools/payments-stripe/src/stripe_client.rs @@ -1,11 +1,21 @@ //! Stateless Stripe HTTP client. //! -//! Credential model (audit checks C7, C9, C10): -//! - This client holds NO Stripe credentials between requests. -//! - The `api_key` is attached per-call via `.with_auth(&input.api_key)`. -//! - The struct's only persistent field is an `Arc` — -//! a stateless connection pool. `new()` is cheap and idempotent. -//! - No `std::env::var` reads; no on-disk reads. +//! Credential model (audit checks C1, C7, C8, C9, C10): +//! +//! - `STRIPE_API_KEY` is read once at startup via [`from_env`]. The +//! bearer is wrapped in [`zeroize::Zeroizing`] so the heap +//! buffer is wiped on drop. The struct hand-implements `Debug` to +//! print `` — never `#[derive(Debug)]`. +//! - The credential never appears on the `Input` struct of any tool — +//! tool inputs flow through the Nexus DAG on Sui as plaintext. +//! - The struct's only mutable per-request field is the optional +//! `Idempotency-Key` (Stripe-style writes); cheap to clone, no +//! shared state. +//! - In Cloud Run the env var is mounted by Secret Manager via a +//! `secretKeyRef` binding configured by the operator (out-of-band). +//! The deploy pipeline does NOT provision the upstream API key. +//! +//! Canonical reference: `offchain/tools/memory-memwal/src/client.rs`. use { crate::{ @@ -14,41 +24,206 @@ use { }, reqwest::{Client, RequestBuilder}, serde::{de::DeserializeOwned, Serialize}, - std::sync::Arc, + std::sync::{Arc, Once, OnceLock}, + zeroize::Zeroizing, }; +/// Env var holding the Stripe secret key (`sk_test_…` or `sk_live_…`). +/// Mounted in Cloud Run via `secretKeyRef`; configured by the operator +/// out-of-band, not by the deploy pipeline. +pub(crate) const ENV_API_KEY: &str = "STRIPE_API_KEY"; + +/// One-shot guard so the "missing key" warning fires once even though +/// `from_env` runs once per registered tool (six tools = six `new()` calls). +static WARN_MISSING_KEY: Once = Once::new(); + +/// Process-wide HTTP client shared across every `StripeClient` so the +/// connection pool, TLS session cache, and HTTP/2 multiplexing survive +/// across `invoke` calls. `reqwest::Client` clone is a cheap Arc bump. +static SHARED_HTTP: OnceLock = OnceLock::new(); + +fn shared_http() -> Client { + SHARED_HTTP + .get_or_init(|| { + Client::builder() + .user_agent("nexus-sdk-payments-stripe/1.0") + .build() + .expect("Failed to create HTTP client") + }) + .clone() +} + +/// Load `/.env` if present. Cwd-only (no parent walk) so a planted +/// `.env` above the binary's cwd cannot influence the process. Existing +/// exports always win. MUST be called before the tokio runtime is built — +/// `set_var` is unsound from a multi-threaded process. +pub(crate) fn load_dotenv_if_present() { + let candidate = match std::env::current_dir() { + Ok(d) => d.join(".env"), + Err(e) => { + log::warn!("could not read cwd while looking for .env: {e}"); + return; + } + }; + if !candidate.is_file() { + return; + } + match dotenvy::from_path(&candidate) { + Ok(()) => log::info!("loaded env vars from {}", candidate.display()), + Err(e) => log::warn!("failed to load {}: {e}", candidate.display()), + } +} + +/// Classification of the Stripe key env var. Hand-written `Debug` redacts +/// the secret — never `derive` Debug on this type. +enum KeyValidation { + Ok(Zeroizing), + Missing, + Invalid(String), +} + +impl std::fmt::Debug for KeyValidation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ok(_) => write!(f, "Ok()"), + Self::Missing => write!(f, "Missing"), + Self::Invalid(reason) => f.debug_tuple("Invalid").field(reason).finish(), + } + } +} + +/// Pure classifier — no I/O, no exits, deterministic. +fn classify_key(value: Option<&str>) -> KeyValidation { + let raw = match value { + Some(s) if !s.is_empty() => s, + _ => return KeyValidation::Missing, + }; + // Stripe secret keys are prefixed `sk_test_` (test mode) or + // `sk_live_` (production). Restricted keys use `rk_test_` / `rk_live_`. + // Reject anything else as a typo / wrong env var. + let valid_prefix = ["sk_test_", "sk_live_", "rk_test_", "rk_live_"] + .iter() + .any(|p| raw.starts_with(p)); + if !valid_prefix { + return KeyValidation::Invalid( + "is set but does not start with a recognized Stripe prefix \ + (sk_test_, sk_live_, rk_test_, rk_live_)." + .to_string(), + ); + } + if raw.len() < 32 { + return KeyValidation::Invalid(format!( + "is set but is shorter than expected ({} chars). Real Stripe \ + keys are ≥32 chars after the prefix.", + raw.len() + )); + } + KeyValidation::Ok(Zeroizing::new(raw.to_string())) +} + +/// Eager startup-time key validation. Without this, a malformed key would +/// only surface on the first `/invoke` (well after `server-start` reports +/// "Ready"). `main` is the only `process::exit` site. +pub(crate) fn validate_credentials_at_startup() -> Result<(), String> { + read_validated_api_key().map(|_| ()) +} + +/// `Ok(Some(key))` valid; `Ok(None)` unset (warn-and-continue); `Err(reason)` +/// malformed. `main` exits on `Err`; `from_env` downgrades it to a log so a +/// mid-process key rotation doesn't kill in-flight requests. +fn read_validated_api_key() -> Result>, String> { + // `env::var` returns a fresh `String` — wrap it immediately so the + // intermediate copy is also zeroed on drop. + let raw = std::env::var(ENV_API_KEY).ok().map(Zeroizing::new); + match classify_key(raw.as_deref().map(|z| z.as_str())) { + KeyValidation::Ok(k) => Ok(Some(k)), + KeyValidation::Missing => { + WARN_MISSING_KEY.call_once(|| { + log::warn!( + "{ENV_API_KEY} is not set — every invoke will fail \ + upstream with 401 until this env var is exported in \ + the process environment." + ); + }); + Ok(None) + } + KeyValidation::Invalid(reason) => Err(reason), + } +} + #[derive(Clone)] pub struct StripeClient { client: Arc, base_url: String, - bearer: Option, + /// Stripe secret key read once at startup from `ENV_API_KEY`. Wrapped + /// in `Zeroizing` so heap buffers are wiped on drop. `None` if the + /// env var was missing or malformed — `invoke()` calls will fail + /// upstream with 401, which is the right signal to the operator. + bearer: Option>>, + /// Per-request idempotency key (Stripe-style writes). Set via + /// `.with_idempotency(...)` on the cheap-cloned client. idempotency_key: Option, } +// Hand-written Debug — NEVER derive on a type holding a credential. +impl std::fmt::Debug for StripeClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StripeClient") + .field("base_url", &self.base_url) + .field( + "bearer", + &if self.bearer.is_some() { + "" + } else { + "" + }, + ) + .field("idempotency_key", &self.idempotency_key) + .finish() + } +} + impl StripeClient { - pub fn new(base_url: Option<&str>) -> Self { - let base_url = base_url.unwrap_or(STRIPE_API_BASE).to_string(); - let client = Client::builder() - .user_agent("nexus-sdk-payments-stripe/1.0") - .build() - .expect("Failed to create HTTP client"); - Self { - client: Arc::new(client), - base_url, - bearer: None, + /// Production constructor: reads `STRIPE_API_KEY` from the process + /// env and wraps it in `Zeroizing`. A missing/malformed key logs but + /// does not error here — `main`'s startup validation is the + /// fail-fast site. + pub fn from_env() -> Result { + let bearer = match read_validated_api_key() { + Ok(Some(k)) => Some(Arc::new(k)), + Ok(None) => None, + Err(reason) => { + log::error!("{ENV_API_KEY} {reason}"); + None + } + }; + Ok(Self { + client: Arc::new(shared_http()), + base_url: STRIPE_API_BASE.to_string(), + bearer, idempotency_key: None, - } + }) } - /// Attach the per-request Stripe secret key. Returns a new builder - /// so the caller's base client never sees the credential. - #[must_use] - pub fn with_auth(mut self, bearer: &str) -> Self { - self.bearer = Some(bearer.to_string()); - self + /// Test-only constructor. Accepts a base URL (HTTP loopback for + /// mockito) and a bearer string directly, bypassing env validation. + #[cfg(test)] + pub(crate) fn for_testing(base_url: &str, bearer: &str) -> Self { + Self { + client: Arc::new( + Client::builder() + .user_agent("nexus-sdk-payments-stripe/1.0") + .build() + .expect("failed to build test HTTP client"), + ), + base_url: base_url.to_string(), + bearer: Some(Arc::new(Zeroizing::new(bearer.to_string()))), + idempotency_key: None, + } } - /// Attach an `Idempotency-Key` header. + /// Attach an `Idempotency-Key` header for the next request. Required + /// by Stripe writes; harmless on reads. #[must_use] pub fn with_idempotency(mut self, key: &str) -> Self { self.idempotency_key = Some(key.to_string()); @@ -84,7 +259,7 @@ impl StripeClient { fn apply_headers(&self, mut req: RequestBuilder) -> RequestBuilder { if let Some(ref bearer) = self.bearer { - req = req.bearer_auth(bearer); + req = req.bearer_auth(bearer.as_str()); } if let Some(ref key) = self.idempotency_key { req = req.header("Idempotency-Key", key); diff --git a/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs index 3b01790..dc8f7c4 100644 --- a/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.confirm-payment-intent@1` +//! # `xyz.taluslabs.payments.stripe.confirm-payment-intent@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub idempotency_key: Option, pub payment_intent_id: String, pub payment_method: Option, @@ -58,7 +59,10 @@ impl NexusTool for ConfirmPaymentIntent { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } @@ -99,10 +103,10 @@ impl NexusTool for ConfirmPaymentIntent { } let endpoint = format!("v1/payment_intents/{}/confirm", input.payment_intent_id); - let mut client = self.client.clone().with_auth(&input.api_key); - if let Some(k) = &input.idempotency_key { - client = client.with_idempotency(k); - } + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; match client .post_form::(&endpoint, &form) @@ -131,13 +135,12 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, ConfirmPaymentIntent) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, ConfirmPaymentIntent { client }) } fn test_input() -> Input { Input { - api_key: "sk_test_FAKE".to_string(), idempotency_key: None, payment_intent_id: "pi_test_123".to_string(), payment_method: Some("pm_card_visa".to_string()), diff --git a/offchain/tools/payments-stripe/src/tools/create_customer.rs b/offchain/tools/payments-stripe/src/tools/create_customer.rs index cbf9afb..251fce1 100644 --- a/offchain/tools/payments-stripe/src/tools/create_customer.rs +++ b/offchain/tools/payments-stripe/src/tools/create_customer.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.create-customer@1` +//! # `xyz.taluslabs.payments.stripe.create-customer@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub idempotency_key: Option, pub email: Option, pub name: Option, @@ -52,7 +53,10 @@ impl NexusTool for CreateCustomer { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } @@ -87,10 +91,10 @@ impl NexusTool for CreateCustomer { form.push(("description", d.clone())); } - let mut client = self.client.clone().with_auth(&input.api_key); - if let Some(k) = &input.idempotency_key { - client = client.with_idempotency(k); - } + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; match client .post_form::("v1/customers", &form) @@ -119,7 +123,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, CreateCustomer) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, CreateCustomer { client }) } @@ -142,7 +146,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), idempotency_key: None, email: Some("test@example.com".to_string()), name: Some("Test User".to_string()), @@ -179,7 +182,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_BAD".to_string(), idempotency_key: None, email: Some("test@example.com".to_string()), name: None, diff --git a/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs index 0ddc107..c5ef901 100644 --- a/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs @@ -1,9 +1,10 @@ -//! # `xyz.taluslabs.payments.stripe.create-payment-intent@1` +//! # `xyz.taluslabs.payments.stripe.create-payment-intent@` //! //! Creates a Stripe PaymentIntent. //! -//! Stateless: holds only a stateless connection pool. Credential -//! (`api_key`) is supplied per request via `Input`. +//! Credentials come from the `STRIPE_API_KEY` env var at startup (see +//! `src/stripe_client.rs::from_env`). They are NEVER fields on `Input` — +//! tool inputs flow through the Nexus DAG on Sui as plaintext. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -16,9 +17,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - /// Stripe secret key. NEVER put this in DAG `default_values` — the - /// Leader supplies it at execution time. - pub api_key: String, /// Optional Idempotency-Key for safe retry. pub idempotency_key: Option, /// Amount in the smallest currency unit (cents for USD). @@ -68,7 +66,10 @@ impl NexusTool for CreatePaymentIntent { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } @@ -119,10 +120,10 @@ impl NexusTool for CreatePaymentIntent { form.push(("description", d.clone())); } - let mut client = self.client.clone().with_auth(&input.api_key); - if let Some(k) = &input.idempotency_key { - client = client.with_idempotency(k); - } + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; match client .post_form::("v1/payment_intents", &form) @@ -153,13 +154,12 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, CreatePaymentIntent) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE_FOR_TESTS_ONLY"); (server, CreatePaymentIntent { client }) } fn test_input() -> Input { Input { - api_key: "sk_test_FAKE_FOR_TESTS_ONLY".to_string(), idempotency_key: Some("test-idempotency-key-001".to_string()), amount: 2000, currency: "usd".to_string(), diff --git a/offchain/tools/payments-stripe/src/tools/get_balance.rs b/offchain/tools/payments-stripe/src/tools/get_balance.rs index 0c06785..5b9077d 100644 --- a/offchain/tools/payments-stripe/src/tools/get_balance.rs +++ b/offchain/tools/payments-stripe/src/tools/get_balance.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.get-balance@1` +//! # `xyz.taluslabs.payments.stripe.get-balance@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::BalanceAmount}, @@ -10,9 +12,7 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub(crate) struct Input { - pub api_key: String, -} +pub(crate) struct Input {} #[derive(Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -45,7 +45,10 @@ impl NexusTool for GetBalance { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } @@ -68,9 +71,8 @@ impl NexusTool for GetBalance { Ok(StatusCode::OK) } - async fn invoke(&self, input: Self::Input) -> Self::Output { - let client = self.client.clone().with_auth(&input.api_key); - match client.get::("v1/balance").await { + async fn invoke(&self, _input: Self::Input) -> Self::Output { + match self.client.get::("v1/balance").await { Ok(b) => Output::Ok { available: b.available, pending: b.pending, @@ -93,7 +95,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, GetBalance) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, GetBalance { client }) } @@ -113,11 +115,7 @@ mod tests { .create_async() .await; - let result = tool - .invoke(Input { - api_key: "sk_test_FAKE".to_string(), - }) - .await; + let result = tool.invoke(Input {}).await; match result { Output::Ok { available, pending } => { assert_eq!(available.len(), 1); diff --git a/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs index c059a6d..3dac6bc 100644 --- a/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.get-payment-intent@1` +//! # `xyz.taluslabs.payments.stripe.get-payment-intent@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub payment_intent_id: String, } @@ -53,7 +54,10 @@ impl NexusTool for GetPaymentIntent { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } @@ -86,9 +90,8 @@ impl NexusTool for GetPaymentIntent { } let endpoint = format!("v1/payment_intents/{}", input.payment_intent_id); - let client = self.client.clone().with_auth(&input.api_key); - match client.get::(&endpoint).await { + match self.client.get::(&endpoint).await { Ok(pi) => Output::Ok { id: pi.id, status: pi.status, @@ -114,7 +117,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, GetPaymentIntent) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, GetPaymentIntent { client }) } @@ -140,7 +143,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), payment_intent_id: "pi_test_123".to_string(), }) .await; @@ -174,7 +176,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), payment_intent_id: "pi_missing".to_string(), }) .await; @@ -192,7 +193,6 @@ mod tests { let (_, tool) = create_server_and_tool().await; let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), payment_intent_id: "".to_string(), }) .await; diff --git a/offchain/tools/payments-stripe/src/tools/list_charges.rs b/offchain/tools/payments-stripe/src/tools/list_charges.rs index 3da217a..5323017 100644 --- a/offchain/tools/payments-stripe/src/tools/list_charges.rs +++ b/offchain/tools/payments-stripe/src/tools/list_charges.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.list-charges@1` +//! # `xyz.taluslabs.payments.stripe.list-charges@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::ChargeSummary}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub limit: Option, pub customer: Option, pub starting_after: Option, @@ -48,7 +49,10 @@ impl NexusTool for ListCharges { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } @@ -100,8 +104,7 @@ impl NexusTool for ListCharges { format!("v1/charges?{qs}") }; - let client = self.client.clone().with_auth(&input.api_key); - match client.get::(&endpoint).await { + match self.client.get::(&endpoint).await { Ok(r) => Output::Ok { charges: r.data, has_more: r.has_more, @@ -146,7 +149,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, ListCharges) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, ListCharges { client }) } @@ -171,7 +174,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), limit: Some(2), customer: None, starting_after: None, @@ -193,7 +195,6 @@ mod tests { let (_, tool) = create_server_and_tool().await; let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), limit: Some(150), customer: None, starting_after: None, From 191a3d9569c8d67f91b29ef97bf989cfc809da8f Mon Sep 17 00:00:00 2001 From: Mike Hanono Date: Mon, 25 May 2026 21:21:29 +0000 Subject: [PATCH 5/5] payments-stripe: regenerate offchain/Cargo.lock for new workspace deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #38 added anyhow / dotenvy / env_logger / log / zeroize to payments-stripe's Cargo.toml but never refreshed the lockfile. CI builds with `cargo build --locked` which refuses to update Cargo.lock at build time, so every tool's build job failed (shared workspace lockfile). Ran `cargo update --workspace --manifest-path offchain/Cargo.toml`. Diff is +8/-2 lines on offchain/Cargo.lock — only payments-stripe's entry changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- offchain/Cargo.lock | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/offchain/Cargo.lock b/offchain/Cargo.lock index 550ad52..7d22f66 100644 --- a/offchain/Cargo.lock +++ b/offchain/Cargo.lock @@ -2565,15 +2565,21 @@ dependencies = [ name = "payments-stripe" version = "1.0.0" dependencies = [ + "anyhow", + "dotenvy", + "env_logger", + "log", "mockito", "nexus-sdk", "nexus-toolkit", - "reqwest", + "reqwest 0.12.25", "schemars", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", + "toml", + "zeroize", ] [[package]]