From 537a1ea12623b5d4a10a6055fd0c1828df708546 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 15:40:28 +0000 Subject: [PATCH] payments: Bitnob adapter, and the KYB reality in both setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @profullstack/sh1pt-payment-bitnob — African payouts over the rail the recipient actually uses: NIP in Nigeria, M-Pesa in Kenya and Tanzania, MTN MoMo in Ghana and Uganda, PayShap or EFT in South Africa. The reason to have both this and transfi is onboarding time, and the setup steps now say so instead of leaving it to be discovered. Bitnob's KYB review is typically 24-48 hours; TransFi's is 1-14 business days with production keys issued within 24h of verification. So Bitnob is the fast route to one African corridor in production, and TransFi is the breadth partner for the other 36. TransFi's steps also now name compliance@transfi.com, what UBOs must provide, and that the webhook secret arrives before KYB clears so the integration can be proven while verification runs. The guard that matters here is `requireEnvironment`. Bitnob serves sandbox and production from ONE base URL — the key alone decides which world you are in — so unlike every other adapter there is no host to eyeball and nothing in a request or response that reveals a sandbox key has been deployed to production. It would quote invented prices to real customers and look entirely healthy doing it. So the environment is required, never defaulted, checked before credentials are even read, and the error explains the consequence rather than just stating the rule, because a guard nobody understands gets deleted by the next person in a hurry. Sandbox keys are issued instantly and are for testing only. Production access needs KYB at both vendors; there is no shortcut at either. payout() validates and then refuses, as with transfi: the request shape is unverified and a fabricated transfer id would report money as sent when nothing left the account. 17 tests on this adapter, 111 across packages/payments. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KcjnyR2gpRBzWYGobUvuHT --- packages/cli/src/adapter-registry.ts | 2 +- packages/payments/bitnob/README.md | 43 ++++++ packages/payments/bitnob/package.json | 37 +++++ packages/payments/bitnob/src/index.test.ts | 126 ++++++++++++++++ packages/payments/bitnob/src/index.ts | 159 +++++++++++++++++++++ packages/payments/bitnob/tsconfig.json | 5 + packages/payments/transfi/src/index.ts | 11 ++ pnpm-lock.yaml | 6 + 8 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 packages/payments/bitnob/README.md create mode 100644 packages/payments/bitnob/package.json create mode 100644 packages/payments/bitnob/src/index.test.ts create mode 100644 packages/payments/bitnob/src/index.ts create mode 100644 packages/payments/bitnob/tsconfig.json diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 0fa5652d..122696fa 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -138,7 +138,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [ id: 'payments', pkgPrefix: '@profullstack/sh1pt-payment', description: 'Payment providers — CoinPay default, Stripe/PayPal, TransFi/WorldRemit payouts', - adapters: ['coinpay', 'paypal', 'stripe', 'transfi', 'worldremit'], + adapters: ['bitnob', 'coinpay', 'paypal', 'stripe', 'transfi', 'worldremit'], }, { id: 'promo', diff --git a/packages/payments/bitnob/README.md b/packages/payments/bitnob/README.md new file mode 100644 index 00000000..e0fb9866 --- /dev/null +++ b/packages/payments/bitnob/README.md @@ -0,0 +1,43 @@ +# Bitnob (African payouts) + +Provides the Bitnob (African payouts) payment adapter for sh1pt monetization workflows. + +## What it does + +- Connects payment provider credentials and account settings. +- Supports payment, checkout, or billing workflows where implemented. +- Includes a connection flow for account or credential setup. +- Includes setup guidance for required credentials or provider configuration. + +## Package + +- Name: `@profullstack/sh1pt-payment-bitnob` +- Path: `packages/payments/bitnob` +- Adapter ID: `payment-bitnob` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-payment-bitnob +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-payment-bitnob typecheck +``` + +Run tests from the repository root when this module includes a test file: + +```bash +pnpm vitest run packages/payments/bitnob/src/index.test.ts +``` + + diff --git a/packages/payments/bitnob/package.json b/packages/payments/bitnob/package.json new file mode 100644 index 00000000..c20ebf32 --- /dev/null +++ b/packages/payments/bitnob/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-payment-bitnob", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/payments/bitnob" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } + } +} diff --git a/packages/payments/bitnob/src/index.test.ts b/packages/payments/bitnob/src/index.test.ts new file mode 100644 index 00000000..2e2c0bd9 --- /dev/null +++ b/packages/payments/bitnob/src/index.test.ts @@ -0,0 +1,126 @@ +import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import adapter, { signRequest, requireEnvironment, BITNOB_API_URL } from './index.js'; + +// bitnob is a payouts adapter, not a checkout provider — supports[] is +// intentionally empty, same as transfi and worldremit. +smokeTest(adapter, { idPrefix: 'payment', requireSupports: false }); + +describe('signRequest', () => { + it('signs the documented canonical string', () => { + // CLIENT_ID:TIMESTAMP:NONCE:PAYLOAD, HMAC-SHA256, hex. Pinned to exact + // bytes: a subtly wrong canonical string fails as a 401, which reads as a + // bad credential rather than as our bug. + const headers = signRequest('client-1', 'secret-1', '{"a":1}', 1_719_236_465, 'deadbeef'); + + expect(headers['X-Auth-Signature']).toBe( + createHmac('sha256', 'secret-1').update('client-1:1719236465:deadbeef:{"a":1}').digest('hex') + ); + expect(headers['X-Auth-Client']).toBe('client-1'); + expect(headers['X-Auth-Timestamp']).toBe('1719236465'); + expect(headers['X-Auth-Nonce']).toBe('deadbeef'); + }); + + it('signs an empty payload as the empty string, not "undefined"', () => { + expect(signRequest('c', 's', '', 1_000, 'n')['X-Auth-Signature']).toBe( + createHmac('sha256', 's').update('c:1000:n:').digest('hex') + ); + }); + + it('changes when only the nonce changes', () => { + const a = signRequest('c', 's', '{}', 1_000, 'one'); + const b = signRequest('c', 's', '{}', 1_000, 'two'); + + expect(a['X-Auth-Signature']).not.toBe(b['X-Auth-Signature']); + }); +}); + +describe('requireEnvironment', () => { + // The guard that matters. Bitnob serves sandbox and production from ONE base + // URL, so nothing in a request, a response or a hostname reveals that a + // sandbox key reached production — it would quote invented prices to real + // customers and look entirely healthy doing it. + it('serves both environments from a single URL', () => { + expect(BITNOB_API_URL).toBe('https://api.bitnob.com'); + expect(BITNOB_API_URL).not.toContain('sandbox'); + }); + + it('accepts an explicit environment', () => { + expect(requireEnvironment({ environment: 'sandbox' })).toBe('sandbox'); + expect(requireEnvironment({ environment: 'production' })).toBe('production'); + }); + + it('refuses to guess when the environment is absent', () => { + // Never defaults. The assumption that costs money is "probably production" + // on a sandbox key. + expect(() => requireEnvironment({})).toThrow(/must be set/); + }); + + it('refuses a value that is neither', () => { + expect(() => requireEnvironment({ environment: 'prod' as never })).toThrow(/must be set/); + expect(() => requireEnvironment({ environment: '' as never })).toThrow(/must be set/); + }); + + it('explains the consequence rather than just the rule', () => { + // A guard nobody understands gets deleted by the next person in a hurry. + expect(() => requireEnvironment({})).toThrow(/one URL/); + }); +}); + +describe('payment-bitnob connect', () => { + const ctx = (secrets: Record) => ({ + secret: (k: string) => secrets[k], + log: () => {}, + }); + + it('demands the environment before it even looks at credentials', async () => { + await expect( + adapter.connect(ctx({ BITNOB_CLIENT_ID: 'c', BITNOB_CLIENT_SECRET: 's' }), {}) + ).rejects.toThrow(/must be set/); + }); + + it('names the missing half of the credential pair', async () => { + const config = { environment: 'sandbox' as const }; + + await expect(adapter.connect(ctx({}), config)).rejects.toThrow('BITNOB_CLIENT_ID not in vault'); + await expect(adapter.connect(ctx({ BITNOB_CLIENT_ID: 'c' }), config)).rejects.toThrow( + 'BITNOB_CLIENT_SECRET not in vault' + ); + }); + + it('connects with both halves and an explicit environment', async () => { + const result = await adapter.connect( + ctx({ BITNOB_CLIENT_ID: 'c', BITNOB_CLIENT_SECRET: 's' }), + { environment: 'production' } + ); + + expect(result.accountId).toBe('bitnob'); + }); +}); + +describe('payment-bitnob payout', () => { + const config = { environment: 'sandbox' as const }; + + it('demands the environment first', async () => { + await expect(adapter.payout!('r', 100, 'NGN', {})).rejects.toThrow(/must be set/); + }); + + it('rejects malformed requests', async () => { + await expect(adapter.payout!(' ', 100, 'NGN', config)).rejects.toThrow('recipient accountId is required'); + await expect(adapter.payout!('r', 0, 'NGN', config)).rejects.toThrow('positive finite number'); + await expect(adapter.payout!('r', 100, 'NG', config)).rejects.toThrow('3-letter ISO code'); + }); + + it('refuses rather than fabricating a transfer id', async () => { + await expect(adapter.payout!('r', 100, 'NGN', config)).rejects.toThrow('not implemented yet'); + }); +}); + +describe('payment-bitnob checkout', () => { + it('refuses buyer-facing checkout', async () => { + await expect( + adapter.createCheckout({ secret: () => undefined, log: () => {} }, {} as never, {}) + ).rejects.toThrow('does not support buyer-facing checkout'); + }); +}); diff --git a/packages/payments/bitnob/src/index.ts b/packages/payments/bitnob/src/index.ts new file mode 100644 index 00000000..e2df4686 --- /dev/null +++ b/packages/payments/bitnob/src/index.ts @@ -0,0 +1,159 @@ +import { createHmac } from 'node:crypto'; +import { definePayment, tokenSetup, type Webhook } from '@profullstack/sh1pt-core'; + +// Bitnob — African payouts, funded from stablecoin or fiat, delivered over the +// rail the recipient actually uses: NIP in Nigeria, M-Pesa in Kenya and +// Tanzania, MTN MoMo in Ghana and Uganda, PayShap or EFT in South Africa. A +// sending rail like transfi and worldremit, not a buyer-facing checkout. +// +// Worth knowing when choosing between this and transfi: Bitnob's KYB review is +// typically 24–48 hours, against TransFi's 1–14 business days. For getting a +// single African corridor genuinely live, this is the faster route to a +// PRODUCTION key. TransFi is the breadth partner for everywhere else. +// +// VERIFIED against Bitnob's published docs: the request signing scheme +// (`CLIENT_ID:TIMESTAMP:NONCE:PAYLOAD`, HMAC-SHA256 keyed with the client +// secret, hex, across four `X-Auth-*` headers) and that one base URL serves +// both environments. +// +// NOT VERIFIED: the payout request and response shapes. payout() validates and +// then refuses rather than inventing a transfer id. + +interface Config { + /** + * Which Bitnob environment these credentials belong to. + * + * Required, and deliberately not defaulted. Bitnob serves sandbox and + * production from the SAME base URL — the key alone decides which world you + * are in — so unlike every other adapter here there is no host to eyeball and + * nothing in a request or response that reveals a sandbox key has been + * deployed to production. It would quote invented prices to real customers + * and look entirely healthy doing it. + * + * Making this explicit is the only available guard. + */ + environment?: 'sandbox' | 'production'; +} + +// One URL for both environments. This is not an oversight; see Config above. +const BITNOB_API_URL = 'https://api.bitnob.com'; +const QUOTE_PATH = '/api/payouts/quotes'; + +/** + * Bitnob's four `X-Auth-*` headers. + * + * The canonical string is `CLIENT_ID:TIMESTAMP:NONCE:PAYLOAD` joined with + * colons, signed HMAC-SHA256 with the client secret, hex-encoded. Exported so a + * test can assert the exact bytes: a subtly wrong canonical string fails as a + * 401, which reads as a bad credential rather than as our bug. + */ +export function signRequest( + clientId: string, + clientSecret: string, + payload: string, + nowSeconds: number, + nonce: string +): Record { + const message = `${clientId}:${nowSeconds}:${nonce}:${payload}`; + + return { + 'X-Auth-Client': clientId, + 'X-Auth-Timestamp': String(nowSeconds), + 'X-Auth-Nonce': nonce, + 'X-Auth-Signature': createHmac('sha256', clientSecret).update(message).digest('hex'), + }; +} + +/** + * Refuse to run against production without saying so out loud. + * + * Throws rather than assuming, because the assumption that costs money is + * "probably production" on a sandbox key. + */ +export function requireEnvironment(config: Config): 'sandbox' | 'production' { + if (config.environment !== 'sandbox' && config.environment !== 'production') { + throw new Error( + "Bitnob environment must be set to 'sandbox' or 'production'. Bitnob serves both from one URL, so a sandbox key deployed to production quotes invented prices and looks healthy doing it." + ); + } + return config.environment; +} + +export default definePayment({ + id: 'payment-bitnob', + label: 'Bitnob (African payouts)', + supports: [], // sending rail, not a buyer-facing checkout + + async connect(ctx, config) { + const environment = requireEnvironment(config); + const clientId = ctx.secret('BITNOB_CLIENT_ID'); + const clientSecret = ctx.secret('BITNOB_CLIENT_SECRET'); + if (!clientId) throw new Error('BITNOB_CLIENT_ID not in vault'); + if (!clientSecret) throw new Error('BITNOB_CLIENT_SECRET not in vault'); + + ctx.log(`bitnob connected · ${environment}`); + return { accountId: 'bitnob' }; + }, + + async createCheckout() { + throw new Error('payment-bitnob does not support buyer-facing checkout — use payout()'); + }, + + async verifyWebhook(_ctx, rawBody): Promise { + return { type: 'unknown', payload: JSON.parse(rawBody) }; + }, + + async payout(accountId, amount, currency, config) { + requireEnvironment(config); + + const recipient = accountId.trim(); + if (!recipient) throw new Error('Bitnob payout recipient accountId is required'); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Bitnob payout amount must be a positive finite number'); + } + if (!/^[a-z]{3}$/i.test(currency)) { + throw new Error('Bitnob payout currency must be a 3-letter ISO code'); + } + + // Not implemented against a guessed request shape: a fabricated transfer id + // would report money as sent when nothing left the account. + throw new Error( + 'Bitnob payout is not implemented yet — the request shape is unverified. Run a sandbox payout first, then wire it here.' + ); + }, + + setup: tokenSetup({ + secretKey: 'BITNOB_CLIENT_ID', + label: 'Bitnob', + vendorDocUrl: 'https://app.bitnob.com', + steps: [ + 'Bitnob is the FASTEST route to a production African payout key:', + 'KYB review is typically 24-48 hours (TransFi is 1-14 business days)', + '', + 'Sign up for a Bitnob Business account at app.bitnob.com', + 'Complete KYB: business identity, ownership and legitimacy', + 'KYB is mandatory before API access — sandbox keys are issued instantly,', + ' but they are for testing only and must never reach production', + 'Once verified, go to Settings -> API Keys for your dedicated keys', + '', + 'Both halves are required: the client ID and the client secret.', + 'Requests are HMAC-signed, so an ID without its secret cannot authenticate.', + ], + fields: [ + { + key: 'BITNOB_CLIENT_SECRET', + message: 'Bitnob client secret (signing key — required)', + secret: true, + required: true, + }, + { + key: 'environment', + message: + "Environment — 'sandbox' or 'production'. Required: Bitnob serves both from one URL, so nothing else can catch a sandbox key in production", + required: true, + }, + ], + }), +}); + +export { BITNOB_API_URL, QUOTE_PATH }; diff --git a/packages/payments/bitnob/tsconfig.json b/packages/payments/bitnob/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/payments/bitnob/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/packages/payments/transfi/src/index.ts b/packages/payments/transfi/src/index.ts index 04a8d840..f8841ecf 100644 --- a/packages/payments/transfi/src/index.ts +++ b/packages/payments/transfi/src/index.ts @@ -228,8 +228,19 @@ export default definePayment({ // Opens the signup page. Self-serve: no sales call, unlike WorldRemit. vendorDocUrl: 'https://www.transfi.com/signup', steps: [ + 'TransFi is the BREADTH partner: one key serves 41 corridors.', + 'Budget for it: KYB review is 1-14 business days, then production', + ' keys within 24h of verification. Bitnob is 24-48h if you need a', + ' single African corridor live sooner.', + '', 'Sign up for a TransFi business account (self-serve — no sales call)', 'Complete KYB: company details, regulatory info, beneficial ownership', + 'Send the documents to compliance@transfi.com to start verification', + 'UBOs need a government-issued ID and proof of address', + '', + 'The webhook secret is available BEFORE KYB clears — set that up first', + ' so the integration is provable while verification runs', + '', 'Once approved, sign in to displai.transfi.com', 'Go to Settings → API Credentials', 'Sandbox and production have SEPARATE credential pairs — copy the one you want', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41a710eb..e6183227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1229,6 +1229,12 @@ importers: specifier: workspace:* version: link:../../core + packages/payments/bitnob: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/payments/coinpay: dependencies: '@profullstack/sh1pt-core':