Skip to content
Open
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
11 changes: 11 additions & 0 deletions packages/bitcore-wallet-service/bws.example.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ module.exports = {
// sellWidgetApi: 'https://sell.moonpay.com',
// secretKey: 'moonpay_production_secret_key_here',
// secretKeyEmbedded: 'moonpay_production_secret_key_embedded_here',
// webhookSecretKey: 'moonpay_production_webhook_secret_key_here',
// webhookSecretKeyEmbedded: 'moonpay_production_webhook_secret_key_embedded_here',
// },
// sandboxWeb: {
// apiKey: 'moonpay_sandbox_web_api_key_here',
Expand All @@ -244,17 +246,20 @@ module.exports = {
// }
// },
// ramp: {
// webhookCallbackBaseUrl: 'https://bws.bitpay.com/bws/api',
// sandbox: {
// apiKey: 'ramp_sandbox_api_key_here',
// api: 'https://api.demo.rampnetwork.com/api',
// widgetApi: 'https://app.demo.rampnetwork.com/',
// signingKey: 'ramp_sandbox_signing_key_here',
// webhookSigningKey: 'ramp_sandbox_webhook_signing_key_here',
// },
// production: {
// apiKey: 'ramp_production_api_key_here',
// api: 'https://api.rampnetwork.com/api',
// widgetApi: 'https://app.rampnetwork.com/',
// signingKey: 'ramp_production_signing_key_here',
// webhookSigningKey: 'ramp_production_webhook_signing_key_here',
// },
// sandboxWeb: {
// apiKey: 'ramp_sandbox_web_api_key_here',
Expand Down Expand Up @@ -299,6 +304,7 @@ module.exports = {
// appProviderId: 'simplex_provider_id_here',
// appSellRefId: 'simplex_sell_ref_id_here',
// publicKey: 'simplex_sandbox_public_key_here',
// publicKeyWebhook: 'simplex_sandbox_webhook_public_key_here',
// },
// production: {
// apiKey: 'simplex_production_api_key_here',
Expand All @@ -307,6 +313,7 @@ module.exports = {
// appProviderId: 'simplex_provider_id_here',
// appSellRefId: 'simplex_sell_ref_id_here',
// publicKey: 'simplex_public_key_here',
// publicKeyWebhook: 'simplex_production_webhook_public_key_here',
// },
// sandboxWeb: {
// apiKey: 'simplex_sandbox_web_api_key_here',
Expand Down Expand Up @@ -406,6 +413,10 @@ module.exports = {
// apiKey: 'moralis_api_key_here',
// whitelist: []
// },
// braze: {
// bwsTrackEventApiKey: 'braze_bws_track_event_api_key_here',
// bwsTrackEventApi: 'https://rest.iad-05.braze.com'
// },
// To use email notifications uncomment this:
// emailOpts: {
// host: 'localhost',
Expand Down
11 changes: 11 additions & 0 deletions packages/bitcore-wallet-service/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ const Config = (): any => {
// sellWidgetApi: 'https://sell.moonpay.com',
// secretKey: 'moonpay_production_secret_key_here',
// secretKeyEmbedded: 'moonpay_production_secret_key_embedded_here',
// webhookSecretKey: 'moonpay_production_webhook_secret_key_here',
// webhookSecretKeyEmbedded: 'moonpay_production_webhook_secret_key_embedded_here',
// },
// sandboxWeb: {
// apiKey: 'moonpay_sandbox_web_api_key_here',
Expand All @@ -319,17 +321,20 @@ const Config = (): any => {
// }
// },
// ramp: {
// webhookCallbackBaseUrl: 'https://bws.bitpay.com/bws/api',
// sandbox: {
// apiKey: 'ramp_sandbox_api_key_here',
// api: 'https://api.demo.ramp.network/api',
// widgetApi: 'https://app.demo.ramp.network/',
// signingKey: 'ramp_sandbox_signing_key_here',
// webhookSigningKey: 'ramp_sandbox_webhook_signing_key_here',
// },
// production: {
// apiKey: 'ramp_production_api_key_here',
// api: 'https://api.ramp.network/api',
// widgetApi: 'https://app.ramp.network/',
// signingKey: 'ramp_production_signing_key_here',
// webhookSigningKey: 'ramp_production_webhook_signing_key_here',
// },
// sandboxWeb: {
// apiKey: 'ramp_sandbox_web_api_key_here',
Expand Down Expand Up @@ -374,6 +379,7 @@ const Config = (): any => {
// appProviderId: 'simplex_provider_id_here',
// appSellRefId: 'simplex_sell_ref_id_here',
// publicKey: 'simplex_sandbox_public_key_here',
// publicKeyWebhook: 'simplex_sandbox_webhook_public_key_here',
// },
// production: {
// apiKey: 'simplex_production_api_key_here',
Expand All @@ -382,6 +388,7 @@ const Config = (): any => {
// appProviderId: 'simplex_provider_id_here',
// appSellRefId: 'simplex_sell_ref_id_here',
// publicKey: 'simplex_public_key_here',
// publicKeyWebhook: 'simplex_production_webhook_public_key_here',
// },
// sandboxWeb: {
// apiKey: 'simplex_sandbox_web_api_key_here',
Expand Down Expand Up @@ -483,6 +490,10 @@ const Config = (): any => {
// apiKey: 'moralis_api_key_here',
// whitelist: []
// },
// braze: {
// bwsTrackEventApiKey: 'braze_bws_track_event_api_key_here',
// bwsTrackEventApi: 'https://rest.iad-05.braze.com'
// },
};

// Override default values with bws.config.js' values, if present
Expand Down
91 changes: 91 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 @@ -196,6 +198,14 @@ export class BanxaService {
}
delete req.body.payment_method;

// Banxa's webhook payload doesn't carry any partner-supplied user identifier
// `meta_data` is a free-form string Banxa echoes back (as `metadata`) on the
// webhook
if (req.body.userId && !req.body.meta_data) {
req.body.meta_data = req.body.userId;
}
delete req.body.userId;

const UriPath = '/orders';
const URL: string = API + UriPath;
const auth = this.getBanxaSignature('post', UriPath, API_KEY, SECRET_KEY, req.body);
Expand Down Expand Up @@ -262,4 +272,85 @@ 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 as any)?.webhookSecretKey || config.banxa.production?.secretKey, env: 'production', webhookPath: (config.banxa.production as any)?.webhookPath || '/v1/service/banxa/webhook' },
{ key: (config.banxa.sandbox as any)?.webhookSecretKey || config.banxa.sandbox?.secretKey, env: 'sandbox', webhookPath: (config.banxa.sandbox as any)?.webhookPath || '/v1/service/banxa/webhook' }
Comment on lines +293 to +295
].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');
}
Comment on lines +326 to +328

const body = req.body || {};

const event = OnrampWebhookEvent.create({
partner: 'banxa',
externalId: body.order_id,
status: body.status || '',
eventName: body.status,
createdAt: body.created_at,
// Banxa docs recommend order_id + status as the dedup key; updated_at is
// also used here for out-of-order detection.
updatedAt: body.updated_at,
deliveryVersion: body.updated_at,
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,
// `metadata` is what we ask Banxa to echo back for the meta_data sent at
// order creation (see banxaCreateOrder).
userId: typeof body.metadata === 'string' ? body.metadata : undefined,
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');
}
Comment on lines +598 to +600

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 };
}
}
Loading