Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
43 changes: 43 additions & 0 deletions packages/payments/bitnob/README.md
Original file line number Diff line number Diff line change
@@ -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
```

<!-- Generated by scripts/gen-module-readmes.mjs -->
37 changes: 37 additions & 0 deletions packages/payments/bitnob/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
126 changes: 126 additions & 0 deletions packages/payments/bitnob/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) => ({
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');
});
});
159 changes: 159 additions & 0 deletions packages/payments/bitnob/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<Config>({
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<Webhook> {
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<Config>({
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 };
5 changes: 5 additions & 0 deletions packages/payments/bitnob/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src/**/*"]
}
Loading
Loading