Skip to content
Draft
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
77 changes: 77 additions & 0 deletions packages/bitcore-wallet-service/src/externalservices/banxa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as _ from 'lodash';
import * as request from 'request';
import config from '../config';
import { ClientError } from '../lib/errors/clienterror';
import { logger } from '../lib/logger';
import { OnrampWebhookEvent } from '../lib/model/onrampWebhookEvent';
import { checkRequired } from '../lib/server';

export class BanxaService {
Expand Down Expand Up @@ -262,4 +264,79 @@ export class BanxaService {
);
});
}

/**
* Handles incoming Banxa webhook events.
* Banxa signs each webhook with HMAC-SHA256. Header format:
* Authorization: Bearer {API_KEY}:{SIGNATURE}:{NONCE}
* Signature is computed over: POST\n{WEBHOOK_PATH}\n{NONCE}\n{RAW_BODY}
* https://docs.banxa.com/products/hosted-checkout/docs/transaction-lifecycle/webhooks
*
* WEBHOOK_PATH must be the full URI path of this endpoint as registered in the
* Banxa dashboard (including any proxy prefix, e.g. /bws/api/v1/service/banxa/webhook).
* Configure it per env in config.banxa[env].webhookPath.
*
* The environment is determined by which configured key verifies the signature
* (separate URLs/keys per env in the dashboard), not by the request.
*/
banxaHandleWebhook(req): { event: OnrampWebhookEvent } {
if (!config.banxa) throw new Error('Banxa missing credentials');

const secretKeys: { key: string; env: string; webhookPath: string }[] = [
{ key: config.banxa.production?.secretKey, env: 'production', webhookPath: (config.banxa.production as any)?.webhookPath || '/v1/service/banxa/webhook' },
{ key: config.banxa.sandbox?.secretKey, env: 'sandbox', webhookPath: (config.banxa.sandbox as any)?.webhookPath || '/v1/service/banxa/webhook' }
].filter(k => !!k.key);

let env = 'production';
if (secretKeys.length) {
const authHeader = req.headers['authorization'] as string;
if (!authHeader) {
throw new Error('Banxa webhook missing Authorization header');
}
try {
// Strip 'Bearer ' prefix
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader;
const parts = token.split(':');
if (parts.length !== 3) throw new Error('Invalid Banxa Authorization header format');
const [, receivedSig, nonce] = parts;
const rawBody: string = (req as any).rawBody ?? JSON.stringify(req.body);
const given = Buffer.from(receivedSig, 'hex');
const matched = secretKeys.find(({ key, webhookPath }) => {
const signingString = `POST\n${webhookPath}\n${nonce}\n${rawBody}`;
const expected = crypto.createHmac('sha256', key).update(signingString).digest();
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
});
if (!matched) {
throw new Error('Banxa webhook signature mismatch');
}
env = matched.env;
} catch (err) {
const errMsg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err);
logger.warn('Banxa webhook signature error: %s', errMsg);
throw new Error('Banxa webhook signature verification failed');
}
} else {
logger.warn('Banxa webhook: no secretKey configured, skipping signature verification');
}

const body = req.body || {};

const event = OnrampWebhookEvent.create({
partner: 'banxa',
externalId: body.order_id,
status: body.status || '',
eventName: body.order_type,
createdAt: body.created_at || body.status_date,
fiatAmount: body.fiat_amount != null ? Number(body.fiat_amount) : undefined,
fiatCurrency: body.fiat_currency,
cryptoAmount: body.crypto_amount != null ? Number(body.crypto_amount) : undefined,
cryptoCurrency: body.crypto_coin,
paymentMethod: body.payment_method,
userId: body.external_id,
rawPayload: body,
env
});

return { event };
}
}
94 changes: 94 additions & 0 deletions packages/bitcore-wallet-service/src/externalservices/moonpay.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as crypto from 'crypto';
import { BitcoreLib as Bitcore } from '@bitpay-labs/crypto-wallet-core';
import * as request from 'request';
import config from '../config';
import { Utils } from '../lib/common/utils';
import { ClientError } from '../lib/errors/clienterror';
import { logger } from '../lib/logger';
import { OnrampWebhookEvent } from '../lib/model/onrampWebhookEvent';
import { checkRequired } from '../lib/server';

export class MoonpayService {
Expand Down Expand Up @@ -544,4 +547,95 @@ export class MoonpayService {
);
});
}

/**
* Handles incoming MoonPay webhook events.
* MoonPay signs requests with HMAC-SHA256. Header: Moonpay-Signature-V2
* Format: t=<timestamp>,s=<signature>
* Signed string: timestamp + '.' + rawBody
* https://dev.moonpay.com/api-reference/widget/webhooks/signature
*/
moonpayHandleWebhook(req): { event: OnrampWebhookEvent } {
if (!config.moonpay) throw new Error('MoonPay missing credentials');

const secretKeys: { key: string; isEmbedded: boolean }[] = [
{ key: config.moonpay.production?.webhookSecretKey, isEmbedded: false },
{ key: config.moonpay.production?.webhookSecretKeyEmbedded, isEmbedded: true }
].filter(k => !!k.key);

let isEmbedded: boolean | undefined;
if (secretKeys.length) {
const signatureHeader = req.headers['moonpay-signature-v2'] as string;
if (!signatureHeader) {
throw new Error('MoonPay webhook missing Moonpay-Signature-V2 header');
}
try {
// Parse: t=timestamp,s=signature
const parts: Record<string, string> = {};
for (const part of signatureHeader.split(',')) {
const [k, v] = part.split('=');
if (k && v !== undefined) parts[k] = v;
}
if (!parts.t || !parts.s) throw new Error('Invalid Moonpay-Signature-V2 header');

// signed_payload = timestamp + '.' + rawBody
const rawBody: string = (req as any).rawBody ?? JSON.stringify(req.body);
const signedPayload = `${parts.t}.${rawBody}`;
const given = Buffer.from(parts.s, 'hex');
const matched = secretKeys.find(({ key }) => {
const expected = crypto.createHmac('sha256', key).update(signedPayload).digest();
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
});
if (!matched) {
throw new Error('MoonPay webhook signature mismatch');
}
isEmbedded = matched.isEmbedded;
} catch (err) {
const errMsg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err);
logger.warn('MoonPay webhook signature error: %s', errMsg);
throw new Error('MoonPay webhook signature verification failed');
}
} else {
logger.warn('MoonPay webhook: no webhookSecretKey configured, skipping signature verification');
}

const body = req.body || {};
const data = body.data || {};

// MoonPay documents deduplication on type + data.id + data.updatedAt, so a
// delivery missing any of them cannot be stored under a stable key.
if (typeof body.type !== 'string' || !body.type) {
throw new Error('MoonPay webhook missing event type');
}
if (typeof data.id !== 'string' || !data.id) {
throw new Error('MoonPay webhook missing transaction id');
}
if (typeof data.updatedAt !== 'string' || Number.isNaN(Date.parse(data.updatedAt))) {
throw new Error('MoonPay webhook missing valid updatedAt');
}

const event = OnrampWebhookEvent.create({
partner: 'moonpay',
externalId: data.id,
externalTransactionId: data.externalTransactionId,
status: data.status || '',
eventName: body.type,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
deliveryVersion: data.updatedAt,
fiatAmount: data.baseCurrencyAmount != null ? Number(data.baseCurrencyAmount) : undefined,
fiatCurrency: data.baseCurrency?.code?.toUpperCase(),
cryptoAmount: data.quoteCurrencyAmount != null ? Number(data.quoteCurrencyAmount) : undefined,
cryptoCurrency: data.currency?.code?.toUpperCase(),
paymentMethod: data.paymentMethod,
walletAddress: data.walletAddress,
walletAddressTag: data.walletAddressTag,
userId: data.externalCustomerId || body.externalCustomerId,
rawPayload: body,
env: 'production',
isEmbedded
});

return { event };
}
}
92 changes: 92 additions & 0 deletions packages/bitcore-wallet-service/src/externalservices/ramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as request from 'request';
import config from '../config';
import { Utils } from '../lib/common/utils';
import { ClientError } from '../lib/errors/clienterror';
import { logger } from '../lib/logger';
import { OnrampWebhookEvent } from '../lib/model/onrampWebhookEvent';
import { checkRequired } from '../lib/server';

export class RampService {
Expand Down Expand Up @@ -275,4 +277,94 @@ export class RampService {
);
});
}

/**
* Handles incoming Ramp webhook events.
* Ramp sends purchase/sale events via HTTP POST to webhookStatusUrl /
* offrampWebhookV3Url. https://docs.rampnetwork.com/webhooks
*
* Ramp signs every webhook with an ECDSA (secp256k1) key + SHA-256 digest.
* The message is the request body serialized deterministically (keys sorted
* alphabetically, no whitespace - fast-json-stable-stringify), NOT the raw body.
* The X-Body-Signature header is the base64 DER-encoded signature.
* Ramp publishes separate public keys for production and demo (sandbox), so the
* environment is determined by which configured key verifies the signature.
*
* Buy payload: { type: 'CREATED'|'RELEASED'|'RETURNED', purchase: RampPurchase }
* Sell payload: { type: 'CREATED'|'RELEASED'|'EXPIRED', mode: 'OFFRAMP', payload: RampSale }
*/
rampHandleWebhook(req): { event: OnrampWebhookEvent } {
if (!config.ramp) throw new Error('Ramp missing credentials');

const publicKeys: { key: string; env: string }[] = [
{ key: (config.ramp.production as any)?.webhookSigningKey, env: 'production' },
{ key: (config.ramp.sandbox as any)?.webhookSigningKey, env: 'sandbox' }
].filter(k => !!k.key);

const body = req.body || {};

let env = 'production';
if (publicKeys.length) {
const sigHeader = req.headers['x-body-signature'] as string;
if (!sigHeader) {
throw new Error('Ramp webhook missing X-Body-Signature header');
}
try {
// The signed message is the stable-stringified JSON body (sorted keys, no whitespace)
const message = Buffer.from(stableStringify(body), 'utf8');
const sig = Buffer.from(sigHeader, 'base64');
// Signature is DER-encoded (crypto.verify's default dsaEncoding)
const matched = publicKeys.find(({ key }) => crypto.verify('sha256', message, key, sig));
if (!matched) {
throw new Error('Ramp webhook signature mismatch');
}
env = matched.env;
} catch (err) {
const errMsg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err);
logger.warn('Ramp webhook signature error: %s', errMsg);
throw new Error('Ramp webhook signature verification failed');
}
} else {
logger.warn('Ramp webhook: no webhookSigningKey configured, skipping signature verification');
}

// Buy events carry the tx in body.purchase; sell (offramp) events in body.payload
const isSell = body.mode === 'OFFRAMP' || !!body.payload;
const item = (isSell ? body.payload : body.purchase) || {};

const event = OnrampWebhookEvent.create({
partner: 'ramp',
externalId: item.id,
status: item.status || body.type || '',
eventName: body.type,
createdAt: item.createdAt,
fiatAmount: isSell
? (item.fiat?.amount != null ? Number(item.fiat.amount) : undefined)
: (item.fiatValue != null ? Number(item.fiatValue) : undefined),
fiatCurrency: isSell ? item.fiat?.currencySymbol : item.fiatCurrency,
cryptoAmount: isSell
? (item.crypto?.amount != null ? Number(item.crypto.amount) : undefined)
: (item.cryptoAmount != null ? Number(item.cryptoAmount) : undefined),
cryptoCurrency: isSell ? item.crypto?.assetInfo?.symbol : item.asset?.symbol,
paymentMethod: isSell ? item.fiat?.payoutMethod : item.paymentMethodType,
walletAddress: item.receiverAddress,
userId: item.purchaseViewToken || item.saleViewToken, // per-tx secret for follow-up lookups
rawPayload: body,
env
});

return { event };
}
}

/**
* Deterministic JSON serialization equivalent to fast-json-stable-stringify:
* object keys sorted alphabetically (recursively), no whitespace.
* Ramp signs webhook bodies over this representation.
*/
function stableStringify(obj: any): string {
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if (Array.isArray(obj)) return `[${obj.map(item => stableStringify(item === undefined ? null : item)).join(',')}]`;
const keys = Object.keys(obj).filter(k => obj[k] !== undefined).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
}
66 changes: 66 additions & 0 deletions packages/bitcore-wallet-service/src/externalservices/sardine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as crypto from 'crypto';
import * as request from 'request';
import config from '../config';
import { ClientError } from '../lib/errors/clienterror';
import { logger } from '../lib/logger';
import { OnrampWebhookEvent } from '../lib/model/onrampWebhookEvent';
import { checkRequired } from '../lib/server';

export class SardineService {
Expand Down Expand Up @@ -234,4 +237,67 @@ export class SardineService {
);
});
}

/**
* Handles incoming Sardine webhook events.
* NOTE: the Sardine on/off-ramp docs do not document any signature header
* (https://docs.payments.sardine.ai/integration_guides/onofframps/webhooks).
* If a webhookSecret is configured and an X-Sardine-Signature header is present,
* we verify it as HMAC-SHA256 over the raw body. Confirm the exact scheme with
* Sardine before relying on it.
*
* Payload contains order status updates (draft, expired, declined, processing,
* processed, complete).
*/
sardineHandleWebhook(req): { event: OnrampWebhookEvent } {
if (!config.sardine) throw new Error('Sardine missing credentials');

const webhookSecrets: { key: string; env: string }[] = [
{ key: (config.sardine.production as any)?.webhookSecret, env: 'production' },
{ key: (config.sardine.sandbox as any)?.webhookSecret, env: 'sandbox' }
].filter(k => !!k.key);

let env = 'production';
const sigHeader = req.headers['x-sardine-signature'] as string | undefined;
if (sigHeader && webhookSecrets.length) {
try {
const rawBody: string = (req as any).rawBody ?? JSON.stringify(req.body);
const given = Buffer.from(sigHeader, 'hex');
const matched = webhookSecrets.find(({ key }) => {
const expected = crypto.createHmac('sha256', key).update(rawBody).digest();
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
});
if (!matched) {
throw new Error('Sardine webhook signature mismatch');
}
env = matched.env;
} catch (err) {
const errMsg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err);
logger.warn('Sardine webhook signature error: %s', errMsg);
throw new Error('Sardine webhook signature verification failed');
}
} else {
logger.warn('Sardine webhook: signature not verified (header present: %s, secret configured: %s)', !!sigHeader, !!webhookSecrets.length);
}

const body = req.body || {};
// Sardine order payload fields (from GET /v1/orders response shape)
const order = body.order || body;

const event = OnrampWebhookEvent.create({
partner: 'sardine',
externalId: order.id || order.orderId,
status: order.status || body.eventType || '',
eventName: body.eventType,
createdAt: order.createdAt,
fiatAmount: order.fiatAmount != null ? Number(order.fiatAmount) : undefined,
fiatCurrency: order.currency,
cryptoCurrency: order.assetType || order.cryptoCurrency,
userId: order.userId || order.externalUserId,
rawPayload: body,
env
});

return { event };
}
}
Loading