Skip to content

feat(connector): add Payhound crypto connector (Authorize, PSync, Incoming Webhooks) - #2173

Open
shuklatushar226 wants to merge 132 commits into
mainfrom
feat/grace-Payhound
Open

feat(connector): add Payhound crypto connector (Authorize, PSync, Incoming Webhooks)#2173
shuklatushar226 wants to merge 132 commits into
mainfrom
feat/grace-Payhound

Conversation

@shuklatushar226

Copy link
Copy Markdown
Contributor

Summary

Adds Payhound as a new UCS connector. Payhound is a hosted crypto invoice gateway (Malta,
MFSA / MiCAR licensed), integrated in the Cryptopay style: UCS creates an invoice, Payhound returns a
hosted payment page, the buyer pays on-chain there, and settlement is reported back by callback and
by polling.

Generated and validated by GRACE (automated connector integration pipeline).


1. Scope

  • New connector. Payhound did not exist in this repo before.
  • Crypto payment method ONLYPaymentMethodData::Crypto / CryptoData. No cards, no
    wallets.
    Payhound's API has no such concept, so any non-crypto payment method is rejected in the
    transformer (unit-tested: non_crypto_payment_method_is_rejected).

2. Flows implemented

Flow Endpoint Notes
Authorize POST /api/v1/invoices Creates the invoice, returns the hosted redirect
PSync GET /api/v1/invoices/{id} Polls invoice status
Incoming webhooks callback HMAC-SHA512 source verification, status + captured-amount extraction

3. Flows deliberately NOT implemented — and why

The Payhound API documents no refund, void, capture or refund-sync endpoints anywhere across its
25 documentation pages
(see data/integration-source-links.json for the full doc index committed
in this PR). These are therefore declared not_supported rather than stubbed with something that
would silently misbehave:

  • Capture — Payhound settles on-chain automatically. MANUAL capture is rejected up front
    with CaptureMethodNotSupported rather than emitting an authorization that could never be
    settled.
  • Void — there is no cancel endpoint. Customer abandonment surfaces naturally as invoice status
    aborted.
  • Refund / RSync — there is no refund API. Crypto invoice payments are irreversible; merchants
    refund out-of-band from their Payhound balance.
  • Mandates / disputes / 3DS — no corresponding API surface exists.

4. Cross-repo note

This is the UCS half of a two-repo change. The Hyperswitch-router half is a separate PR against
juspay/hyperswitch
, which adds the connector enum variant, the ucs_only_connectors routing
entry, a stub connector and the config. The two must land together for the end-to-end path to
work; this PR alone gives you the UCS-side gRPC surface only.

5. Proto and core domain_types changes (declared explicitly)

This PR necessarily touches proto/ and core domain_types. Stating it plainly rather than burying
it in the diff:

crates/types-traits/grpc-api-types/proto/payment.proto

  • PAYHOUND = 140; added to enum Connector (next free number; verified no collision on main).
  • New message:
    message PayhoundConfig {
      SecretString api_key = 1;
      SecretString api_secret = 2;
      optional string base_url = 50;
    }
  • PayhoundConfig payhound = 150; added to the ConnectorSpecificConfig oneof (next free field
    number).

domain_types

  • ConnectorEnum::Payhound
  • ConnectorSpecificConfig::Payhound { api_key, api_secret, base_url }
  • The ConnectorAuthType -> ConnectorSpecificConfig conversion. It accepts SignatureKey
    (key1 unused — Payhound has no third credential) and BodyKey.

All other touched files are the standard additive registration points: connectors.rs,
default_implementations.rs, types.rs, field-probe/src/auth.rs, sdk/rust/smoke-test, and the
four config/*.toml files. Every hunk in this PR is additive; there is no unrelated churn.

6. Notable implementation details for reviewers

  • Amounts are decimal major-unit strings. price is "266.45", not minor units.
    StringMajorUnit converter. Verified live: minor_amount: 26645 -> "266.45".

  • Auth is HMAC-SHA512 with X-MB-Key / X-MB-Nonce / X-MB-Signature and
    Content-Type: application/vnd.api+json:

    signature = lowercase_hex(HMAC_SHA512(api_secret, uri_path ++ nonce ++ hex(SHA256(request_data))))
    

    The three worked signing vectors published in Payhound's own authentication docs are committed as
    unit tests (sign_post_vector, sign_get_query_vector, callback_signature_vector). Those
    constants are documentation example values, not live credentials.

  • The nonce must be unique AND strictly increasing forever, per API key. Implemented as a
    process-global AtomicU64 computing max(now_micros, prev + 1) under a SeqCst CAS. Two unit
    tests cover it: 10,000 serial calls strictly increasing, and 8 threads x 2,000 calls all distinct.
    Known bound worth flagging: the counter is process-global, not cluster-global. Separate
    replicas sharing one API key each keep their own counter. This is worth reviewing if a
    multi-replica deployment is planned.

  • invoice_url comes back RELATIVE from the sandbox (/invoices/{id}) but ABSOLUTE in the
    docs
    . The redirect builder handles both (invoice_url_relative_is_resolved,
    invoice_url_absolute_passes_through). The signed uri_path is derived from the parsed request
    URL
    , and base_url is trim_end_matches('/'), so a trailing slash in config cannot corrupt the
    signature.

  • Status mapping, with the two judgement calls justified in code comments:

    Payhound status UCS status Rationale
    pending AuthenticationPending buyer has not paid the hosted invoice yet
    completed Charged
    aborted, timeout Failure
    overpaid Charged The order is fully covered. The surplus is an account-level deposit, not an attribute of this attempt. Mapping it to Unresolved would leave a fully-paid order permanently unfulfilled.
    underpaid Pending The docs state this is not terminal — it can still become completed / overpaid / timeout. Failure would prematurely kill an order the buyer can still top up.
    #[serde(other)] Unknown Pending + tracing::warn! soft-fail on an unrecognised status rather than erroring the payment

Files

Added

  • crates/integrations/connector-integration/src/connectors/payhound.rs
  • crates/integrations/connector-integration/src/connectors/payhound/transformers.rs
  • crates/internal/integration-tests/src/connector_specs/payhound/specs.json
  • data/field_probe/payhound.json
  • docs-generated/connectors/payhound.md
  • examples/payhound/{payhound.rs,payhound.py,payhound.ts,payhound.kt}

Modified (all additive)

  • config/development.toml, config/production.toml, config/sandbox.toml, config/superposition.toml
  • crates/integrations/connector-integration/src/connectors.rs
  • crates/integrations/connector-integration/src/default_implementations.rs
  • crates/integrations/connector-integration/src/types.rs
  • crates/internal/field-probe/src/auth.rs
  • crates/types-traits/domain_types/src/connector_types.rs
  • crates/types-traits/domain_types/src/router_data.rs
  • crates/types-traits/domain_types/src/types.rs
  • crates/types-traits/grpc-api-types/proto/payment.proto
  • data/integration-source-links.json (carries the Payhound doc URLs)
  • sdk/rust/smoke-test/src/build_auth.rs

Base URLs: production.toml -> https://api.payhound.com (live host);
sandbox.toml / development.toml -> https://sandbox-api.payhound.com.


gRPC test results

Status: PASS (Authorize 201, PSync 200)

grpcurl transcript against the live Payhound sandbox (credentials never inlined)

Server: cargo run --bin grpc-server on localhost:8000.
The x-connector-config header is built from creds.json with jq so the transcript is
reproducible without pasting any credential:

CFG=$(jq -c '{config:{Payhound:{
  api_key:    .payhound.connector_account_details.api_key,
  api_secret: .payhound.connector_account_details.api_secret,
  base_url:   .payhound.metadata.base_url
}}}' creds.json)

hdr=(-H "x-connector: payhound" -H "x-connector-config: ${CFG}")

1. Authorize — Crypto (BTC), EUR 266.45

grpcurl -plaintext "${hdr[@]}" -d '{
  "merchant_transaction_id": "payhound_auth_001",
  "amount": {"minor_amount": 26645, "currency": "EUR"},
  "payment_method": {"crypto": {"pay_currency": "BTC"}},
  "capture_method": "AUTOMATIC",
  "auth_type": "NO_THREE_DS",
  "enrolled_for_3ds": false,
  "return_url": "https://example.com/return",
  "webhook_url": "https://example.com/webhook",
  "description": "Payhound UCS smoke test invoice",
  "customer": {"email": {"value": "buyer@example.com"}},
  "address": {"billing_address": {
    "first_name": {"value": "John"}, "last_name": {"value": "Doe"},
    "line1": {"value": "123 Test St"}, "city": {"value": "Valletta"},
    "zip_code": {"value": "VLT1111"}, "country_alpha2_code": "MT",
    "email": {"value": "buyer@example.com"}}}
}' localhost:8000 types.PaymentService/Authorize

Body UCS actually signed and sent to POST https://sandbox-api.payhound.com/api/v1/invoices:

{"currency":"EUR","price":"266.45","invoice_currency":"BTC",
 "description":"Payhound UCS smoke test invoice","reference":"payhound_pr_authorize_001",
 "callback_url":"...","success_url":"...","cancel_url":"...","notify_email":"*****@example.com"}

Note price is the decimal major-unit string "266.45" derived from minor_amount: 26645, and
absent optional fields are omitted rather than serialized as null.

Response:

{
  "merchantTransactionId": "TSTINCUSTOMREF",
  "connectorTransactionId": "3424d1dc689a3b42bb00b0b1719588b5",
  "status": "AUTHENTICATION_PENDING",
  "statusCode": 201,
  "redirectionData": {
    "form": {
      "endpoint": "https://pay.payhound.com/invoices/3424d1dc689a3b42bb00b0b1719588b5",
      "method": "HTTP_METHOD_GET"
    }
  },
  "connectorReferenceId": "TSTINCUSTOMREF"
}

The sandbox returned the relative invoice_url /invoices/3424d1dc...; UCS resolved it to the
absolute hosted-invoice URL shown above. The crypto address is Secret-wrapped and masks as
*** alloc::string::String *** in the typed connector response.

2. PSync — GET /api/v1/invoices/{id}

grpcurl -plaintext "${hdr[@]}" -d '{
  "merchant_transaction_id": "payhound_auth_001",
  "connector_transaction_id": "3424d1dc689a3b42bb00b0b1719588b5",
  "amount": {"minor_amount": 26645, "currency": "EUR"},
  "capture_method": "AUTOMATIC"
}' localhost:8000 types.PaymentService/Get

Response: statusCode 200, status AUTHENTICATION_PENDING, amount 26645 EUR, redirect endpoint
rebuilt correctly.


Not proven

These are the honest limits of the evidence above. Please read them as written.

  • PSync is NOT proven to retrieve the requested invoice. The Payhound sandbox is a canned stub:
    every response echoes name "Test Invoice", reference "TSTINCUSTOMREF", merchant_amount
    "20.00" and a freshly invented id, whatever id is requested. UCS signed and sent the correct
    URL and got a 200 describing a different invoice. This proves signing, headers, content type,
    URL construction, deserialization, status mapping and relative-URL resolution — not id-specific
    retrieval.
  • Terminal statuses (completed / overpaid / underpaid / aborted / timeout) are
    doc-mapped and fixture-tested but never observed live; the sandbox only ever returns pending.
  • Webhooks are unproven end-to-end. The sandbox never delivered a callback. The signature scheme
    rests on a unit vector built from the documented signing string, not a captured live callback.
  • Captured-amount extraction has never run against a real charged invoice.
  • Only BTC/EUR was exercised live; the other 18 crypto assets rest on the closed enum and its
    tests.
  • No testnet invoice was actually paid, so a settled end-to-end payment is not claimed.

Validation checklist

  • cargo build full workspace clean, zero errors
  • cargo clippy — zero payhound hits
  • cargo fmt clean
  • cargo test -p connector-integration payhound19 passed, 0 failed
  • grpcurl Authorize returned success status (201, AUTHENTICATION_PENDING)
  • grpcurl PSync returned 200
  • No credentials in committed source code (diff scanned against creds.json values — no match)
  • Only payhound-scoped files modified; every hunk additive
  • config/production.toml points at the live host https://api.payhound.com

…hooks)

Add Payhound, a hosted crypto invoice gateway (Malta MFSA/MiCAR licensed),
integrated as a Cryptopay-style hosted redirect.

Scope: Crypto payment method only (PaymentMethodData::Crypto). Payhound's API
has no card or wallet concept.

Flows:
- Authorize: POST /api/v1/invoices, returns a hosted redirect
- PSync:     GET  /api/v1/invoices/{id}
- Incoming webhooks: HMAC-SHA512 source verification, status and
  captured-amount extraction

Not implemented (Payhound documents no such endpoints; declared not_supported
rather than stubbed): capture, void, refund, rsync, mandates, disputes, 3DS.
MANUAL capture is rejected up front with CaptureMethodNotSupported.

Notable details:
- Amounts are decimal major-unit strings ("266.45"), via StringMajorUnit
- Auth is HMAC-SHA512 over uri_path ++ nonce ++ hex(SHA256(body)), sent as
  X-MB-Key / X-MB-Nonce / X-MB-Signature with Content-Type application/vnd.api+json
- Nonce is a process-global AtomicU64, max(now_micros, prev+1) under SeqCst CAS
- invoice_url may come back relative or absolute; both are handled

Proto/core: adds Connector::PAYHOUND = 140, message PayhoundConfig, the
ConnectorSpecificConfig oneof field, ConnectorEnum::Payhound and the
ConnectorAuthType -> config conversion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3mj9nTAduEyXEKMs3Ms8W
let signature = payhound_request_signature(
&Secret::new(VECTOR_SECRET_POST.to_owned()),
"/api/v1/test",
123,
let signature = payhound_request_signature(
&Secret::new(VECTOR_SECRET_GET.to_owned()),
"/api/v1/info",
4711,
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
hyperswitch-bot Bot and others added 30 commits August 25, 2026 07:21
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
Auto-applied by CI:
- cargo +nightly fmt --all
- make -C sdk generate (if applicable)
- make docs (if applicable)

This commit was automatically generated by GitHub Actions.
… stable

The payhound probe artifact changed on every regeneration, so CI's auto-fix job
committed it, which re-triggered CI, which regenerated it again — 125 identical
"chore: auto-fix formatting and generated code" commits on this branch in ~17
hours, netting +15/-4.

Payhound's X-MB-Nonce is a microsecond-resolution epoch, so it is 16 digits wide.
normalize_content cannot catch it: replace_timestamps rewrites runs of exactly 13
or 14 digits and deliberately leaves 16-digit runs alone so card numbers are never
touched. Pin it by header name in normalize_header_value instead, alongside the
existing salt/idempotency-key/timestamp entries, which leaves the card-number
guard intact.

Also corrects the replace_timestamps doc comment, which claimed "13+ consecutive
digits" while the code is exactly-13-or-14 — the mismatch is what made the nonce
look like it should already have been covered.

The regression test asserts both halves: two different nonces normalize equal, and
a bare 16-digit run in a body is still copied verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3mj9nTAduEyXEKMs3Ms8W
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants