Skip to content

payments: TransFi adapter, with setup you can actually automate - #1012

Merged
ralyodio merged 2 commits into
masterfrom
worktree-transfi-adapter
Sep 6, 2026
Merged

payments: TransFi adapter, with setup you can actually automate#1012
ralyodio merged 2 commits into
masterfrom
worktree-transfi-adapter

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Adds @profullstack/sh1pt-payment-transfi — cross-border payouts into local banks and wallets in ~100 countries, funded from stablecoin or fiat. A sending rail like worldremit, not a buyer-facing checkout.

Why this one is worth automating

WorldRemit issues credentials only after a conversation with their business team, so sh1pt config payments can do no more than print instructions and hope.

TransFi onboarding is self-serve: sign up, clear KYB, and displai.transfi.com issues sandbox and production credential pairs. That is automatable end to end — so this adapter opens the signup page, walks the operator through the steps, prompts for both halves of the credential pair, and stores them in the vault.

connect() is a real check, not a presence check

It calls GET /v3/balance and surfaces a 401 as bad credentials, with a note that sandbox and production pairs are separate. Credentials that are present but wrong are indistinguishable from correct ones right up until something tries to move money, which is far too late to find out.

A bug this surfaced elsewhere

TransFi authenticates with HTTP Basic over username:password — verified from their published docs and pinned by a test here.

The sibling implementation of this same API in coinpayportal sends Authorization: Bearer and would have failed every request the moment a real credential was configured. The resulting 401 looks exactly like an expired key, so it is the kind of mistake that survives a long time. That needs fixing separately in coinpayportal; this adapter has a test asserting the header is Basic and explicitly not Bearer, so this one cannot drift the same way.

payout() refuses rather than pretends

It validates its arguments and then throws, because the request shape is unverified. Returning a fabricated transfer id would report money as sent when nothing left the account — worse than an error. It gets implemented against a real sandbox payout, not against documentation.

Testing

10 new tests; all 75 tests across packages/payments pass; the CLI typechecks.

Two notes for reviewers:

  • packages/core must be built before running these tests. Otherwise module resolution falls through to a stray copy under ~/node_modules and every payments adapter test fails with a confusing Cannot find package 'vitest'. This is pre-existing — worldremit's suite fails identically on a clean worktree — but it cost me a detour, so it is worth knowing.
  • packages/social/ugig/README.md moves in this diff. It is generated output that had drifted from its source label ("Prompts Marketplace" → "AI Gig Marketplace"), and running the repo's own scripts/gen-module-readmes.mjs to create this package's README regenerates it. I kept the generator's output rather than deliberately re-staling it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KcjnyR2gpRBzWYGobUvuHT

Adds @profullstack/sh1pt-payment-transfi — cross-border payouts into local
banks and wallets in ~100 countries, funded from stablecoin or fiat. A sending
rail like worldremit, not a buyer-facing checkout.

The point of it is the setup flow. WorldRemit issues credentials only after a
conversation with their business team, so `sh1pt config payments` can do no
more than print instructions. TransFi onboarding is self-serve: sign up, clear
KYB, and displai.transfi.com issues sandbox and production credential pairs.
That is automatable end to end, so this adapter opens the signup page, walks
the operator through it, prompts for both halves of the pair, and stores them
in the vault.

connect() then verifies them for real. It calls GET /v3/balance and reports a
401 as bad credentials with a note that sandbox and production keys are
separate, rather than only asserting a secret exists. Credentials that are
present but wrong are indistinguishable from correct ones until something tries
to move money, which is late.

Authentication is HTTP Basic over `username:password`, verified from TransFi's
published docs and pinned by a test. Worth stating plainly because a sibling
implementation of this same API in coinpayportal sends `Authorization: Bearer`
and would have failed every request the moment a real credential was set — the
resulting 401 looks exactly like an expired key, so the mistake survives a long
time. That one needs fixing separately.

payout() validates its arguments and then refuses, because the request shape is
unverified. Returning a fabricated transfer id would report money as sent when
nothing left the account, which is worse than an error. It gets implemented
against a sandbox payout, not against documentation.

packages/social/ugig/README.md moves too: it is generated output that had
drifted from its source label, and running the repo's own generator to create
this package's README regenerates it.

Note for anyone running these tests: packages/core must be built first.
Otherwise resolution falls through to a stray copy under ~/node_modules and
every payments adapter test fails with a confusing "Cannot find package
'vitest'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcjnyR2gpRBzWYGobUvuHT
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

49 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 14 | LOW: 34

Severity Rule Location
HIGH js-host-header-trust packages/bots/wechat/src/index.ts:405
MEDIUM redos-nested-quantifier packages/actions-fleet-core/src/action-pack/schema.ts:3
MEDIUM redos-nested-quantifier packages/core/src/setup-helpers.ts:583
MEDIUM redos-nested-quantifier packages/policy/src/rules/bundle-id.ts:3
MEDIUM sql-string-concatenation packages/targets/deploy-wordpress/src/index.ts:154
MEDIUM redos-nested-quantifier packages/targets/desktop-linux/src/index.ts:19
MEDIUM redos-nested-quantifier packages/targets/desktop-mac/src/index.ts:15
MEDIUM redos-nested-quantifier packages/targets/desktop-steamos/src/index.ts:28
MEDIUM redos-nested-quantifier packages/targets/mobile-android/src/index.ts:9
MEDIUM redos-nested-quantifier packages/targets/mobile-ios/src/index.ts:11
MEDIUM redos-nested-quantifier packages/targets/tv-androidtv/src/index.ts:14
MEDIUM redos-nested-quantifier packages/targets/tv-firetv/src/index.ts:13
MEDIUM redos-nested-quantifier packages/targets/tv-tvos/src/index.ts:14
MEDIUM redos-nested-quantifier packages/targets/tv-webos/src/index.ts:26
MEDIUM js-unescaped-html-sink sites/sh1pt.com/app/blog/[slug]/page.tsx:76
LOW secret-generic-credential packages/affiliates/skimlinks/src/index.test.ts:25
LOW secret-generic-credential packages/affiliates/skimlinks/src/index.test.ts:71
LOW secret-generic-api-key packages/affiliates/sovrn/src/index.ts:28
LOW secret-generic-credential packages/agent-providers/opencode/src/__tests__/opencode.test.ts:99
LOW js-nosql-injection packages/ai/amazon-bedrock/src/index.test.ts:121
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:9
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:10
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:11
LOW secret-generic-credential packages/bridges/matrix/src/index.ts:58
LOW secret-generic-credential packages/bridges/matrix/src/index.ts:59
LOW secret-generic-credential packages/bridges/slack/src/index.test.ts:259
LOW secret-generic-credential packages/captcha/captchasolver/src/index.ts:34
LOW secret-generic-credential packages/cli/src/commands/secrets.ts:189
LOW secret-generic-credential packages/cloud/linode/src/index.ts:15
LOW secret-generic-credential packages/observability/sentry/src/index.ts:15
LOW secret-generic-credential packages/outreach/producthunt/src/index.ts:103
LOW secret-generic-credential packages/promo/posthog/src/index.ts:23
LOW secret-generic-credential packages/scanners/threatcrush/test/scan-output.txt:35
LOW secret-generic-credential packages/scanners/threatcrush/test/scan-output.txt:40
LOW secret-database-url packages/scanners/threatcrush/test/scan-output.txt:54
LOW secret-generic-credential packages/security/snyk/src/index.ts:26
LOW secret-generic-credential packages/social/hashnode/src/index.ts:4
LOW secret-generic-credential packages/social/linkedin/src/index.ts:3
LOW secret-generic-credential packages/social/linkedin/src/index.ts:4
LOW secret-generic-credential packages/social/medium/src/index.ts:4
LOW secret-generic-credential packages/social/snapchat/src/index.ts:5
LOW secret-generic-credential packages/social/tiktok/src/index.ts:5
LOW secret-generic-credential packages/targets/plugin-vscode/src/index.test.ts:115
LOW secret-generic-credential packages/targets/registry-ans/src/index.test.ts:79
LOW secret-generic-credential packages/targets/registry-ans/src/index.ts:49
LOW secret-generic-credential packages/targets/sdk-pypi/src/index.test.ts:49
LOW secret-generic-credential packages/vcs/gitlab/src/index.test.ts:96
LOW secret-generic-credential sites/sh1pt.com/supabase/config.toml:303
LOW secret-generic-credential sites/sh1pt.com/supabase/config.toml:335

Snippets are redacted; ThreatCrush never prints matched credential material.

The webhook secret is the one credential TransFi hands over before KYB clears,
so it is the part of this integration that can be built and tested now. Wires
it up end to end.

Signature verification is HMAC-SHA256 over the raw request body, hex, compared
against X-Transfi-Hmac-Hash — verified from TransFi's published docs.

Two deliberate departures from their documented sample. It compares with
timingSafeEqual rather than `===`, because a byte-by-byte string compare leaks
how much of a forged signature was correct. And the header constant is
lowercase: their sample reads req.headers['X-Transfi-Hmac-Hash'], which is
always undefined in Node, since Node lowercases incoming header names.

Payout events are mapped for both the fiat and crypto vocabularies, which name
the same three moments differently. fund_scheduled is pending rather than
succeeded — the order is booked and the recipient has nothing yet. An
unrecognised status becomes pending, never a confident answer: TransFi can add
statuses without asking us, and both confident answers cause harm, one
reporting a payout as delivered and the other telling someone their money
bounced when it did not.

The normalized amount is the withdraw side, what left TransFi toward the
recipient. TransFi's sample payloads label deposit and withdraw in a way that
is easy to read backwards, so the raw payload is preserved intact for anything
that depends on the distinction.

Setup now captures TRANSFI_WEBHOOK_SECRET too, and the steps say plainly that
it is available before KYB — which is what makes this worth doing first.

29 tests on this adapter, 94 across packages/payments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcjnyR2gpRBzWYGobUvuHT
@ralyodio
ralyodio marked this pull request as ready for review September 6, 2026 14:42
@ralyodio
ralyodio merged commit b4921d5 into master Sep 6, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant