diff --git a/packages/bitcore-wallet-service/src/externalservices/banxa.ts b/packages/bitcore-wallet-service/src/externalservices/banxa.ts index cb91794bee..a4b63746d3 100644 --- a/packages/bitcore-wallet-service/src/externalservices/banxa.ts +++ b/packages/bitcore-wallet-service/src/externalservices/banxa.ts @@ -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 { @@ -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 }; + } } \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/externalservices/moonpay.ts b/packages/bitcore-wallet-service/src/externalservices/moonpay.ts index dd9b56eb02..b3fb4e34d1 100644 --- a/packages/bitcore-wallet-service/src/externalservices/moonpay.ts +++ b/packages/bitcore-wallet-service/src/externalservices/moonpay.ts @@ -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 { @@ -544,4 +547,95 @@ export class MoonpayService { ); }); } + + /** + * Handles incoming MoonPay webhook events. + * MoonPay signs requests with HMAC-SHA256. Header: Moonpay-Signature-V2 + * Format: t=,s= + * 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 = {}; + 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 }; + } } \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/externalservices/ramp.ts b/packages/bitcore-wallet-service/src/externalservices/ramp.ts index 3c57fbba7d..ceb6674406 100644 --- a/packages/bitcore-wallet-service/src/externalservices/ramp.ts +++ b/packages/bitcore-wallet-service/src/externalservices/ramp.ts @@ -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 { @@ -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(',')}}`; } \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/externalservices/sardine.ts b/packages/bitcore-wallet-service/src/externalservices/sardine.ts index a6415b270c..b1a2adb2d5 100644 --- a/packages/bitcore-wallet-service/src/externalservices/sardine.ts +++ b/packages/bitcore-wallet-service/src/externalservices/sardine.ts @@ -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 { @@ -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 }; + } } \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/externalservices/simplex.ts b/packages/bitcore-wallet-service/src/externalservices/simplex.ts index a207577e03..f6bce674dc 100644 --- a/packages/bitcore-wallet-service/src/externalservices/simplex.ts +++ b/packages/bitcore-wallet-service/src/externalservices/simplex.ts @@ -1,8 +1,11 @@ +import * as crypto from 'crypto'; import * as request from 'request'; import Uuid from 'uuid'; 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 SimplexService { @@ -278,4 +281,78 @@ export class SimplexService { ); }); } + + /** + * Handles incoming Simplex webhook events. + * Simplex signs each request with a RS256 JWT in the X-Signature-SHA256 header. + * The JWT expires after 5 minutes to prevent replay attacks. + * https://integrations.simplex.com/docs/webhooks + * + * Sandbox and production use different public keys, so the environment is + * determined by which configured key verifies the JWT, not by the request. + */ + simplexHandleWebhook(req): { event: OnrampWebhookEvent } { + if (!config.simplex) throw new Error('Simplex missing credentials'); + + const publicKeys: { key: string; env: string }[] = [ + { key: config.simplex.production?.publicKeyWebhook, env: 'production' }, + { key: config.simplex.sandbox?.publicKeyWebhook, env: 'sandbox' } + ].filter(k => !!k.key); + + let env = 'production'; + if (publicKeys.length) { + const signature = req.headers['x-signature-sha256'] as string; + if (!signature) { + throw new Error('Simplex webhook missing X-Signature-SHA256 header'); + } + try { + // Verify RS256 JWT: split into header.payload.signature parts + const parts = signature.split('.'); + if (parts.length !== 3) throw new Error('Invalid JWT format'); + const signingInput = `${parts[0]}.${parts[1]}`; + const sig = Buffer.from(parts[2], 'base64url'); + const matched = publicKeys.find(({ key }) => { + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(signingInput); + return verifier.verify(key, sig); + }); + if (!matched) { + throw new Error('Simplex webhook signature mismatch'); + } + const jwtPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + if (jwtPayload?.exp != null && jwtPayload.exp * 1000 < Date.now()) { + throw new Error('Simplex webhook JWT expired'); + } + env = matched.env; + } catch (err) { + const errMsg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err); + logger.warn('Simplex webhook signature error: %s', errMsg); + throw new Error('Simplex webhook signature verification failed'); + } + } else { + logger.warn('Simplex webhook: no publicKeyWebhook configured, skipping signature verification'); + } + + const body = req.body || {}; + const payment = body.payment || {}; + const fiatAmount = payment.fiat_total_amount?.amount; + const fiatCurrency = payment.fiat_total_amount?.currency; + const cryptoCurrency = payment.requested_digital_amount?.currency; + + const event = OnrampWebhookEvent.create({ + partner: 'simplex', + externalId: payment.id || body.event_id, + status: body.name || '', + eventName: body.name, + createdAt: payment.created_at || body.timestamp, + fiatAmount: fiatAmount != null ? Number(fiatAmount) : undefined, + fiatCurrency, + cryptoCurrency, + userId: payment.user_id, + rawPayload: body, + env + }); + + return { event }; + } } \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/externalservices/transak.ts b/packages/bitcore-wallet-service/src/externalservices/transak.ts index 3adee861a2..fb3c357081 100644 --- a/packages/bitcore-wallet-service/src/externalservices/transak.ts +++ b/packages/bitcore-wallet-service/src/externalservices/transak.ts @@ -1,7 +1,10 @@ +import * as crypto from 'crypto'; 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 TransakService { @@ -331,4 +334,86 @@ export class TransakService { ); }); } + + /** + * Handles incoming Transak webhook events. + * The payload data field is a signed HS256 JWT. https://docs.transak.com/features/webhooks + * + * NOTE: per Transak docs the JWT should be verified with the Partner Access Token + * (the token returned by /partners/api/v2/refresh-token), which rotates. We first + * try the configured static secretKey for each env; if real webhooks fail to + * verify, this needs to be switched to a cached access token. + * + * The environment is determined by which configured key verifies the JWT, + * not by the request. + * + * Decoded payload: { webhookData: { id, status, fiatCurrency, fiatAmount, + * cryptoCurrency, userId, createdAt, ... }, eventID } + */ + transakHandleWebhook(req): { event: OnrampWebhookEvent } { + if (!config.transak) throw new Error('Transak missing credentials'); + + const secretKeys: { key: string; env: string }[] = [ + { key: config.transak.production?.secretKey, env: 'production' }, + { key: config.transak.sandbox?.secretKey, env: 'sandbox' } + ].filter(k => !!k.key); + + const jwtToken: string | undefined = req.body?.data; + if (!jwtToken || typeof jwtToken !== 'string') { + throw new Error('Transak webhook missing JWT data field'); + } + const parts = jwtToken.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT format'); + } + + let env = 'production'; + if (secretKeys.length) { + try { + // Verify HS256 JWT manually using Node crypto (no external library needed) + const signingInput = `${parts[0]}.${parts[1]}`; + const given = Buffer.from(parts[2], 'base64url'); + const matched = secretKeys.find(({ key }) => { + const expected = crypto.createHmac('sha256', key).update(signingInput).digest(); + return expected.length === given.length && crypto.timingSafeEqual(expected, given); + }); + if (!matched) { + throw new Error('Transak webhook JWT signature mismatch'); + } + env = matched.env; + } catch (err) { + const errMsg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err); + logger.warn('Transak webhook JWT error: %s', errMsg); + throw new Error('Transak webhook signature verification failed'); + } + } else { + logger.warn('Transak webhook: no secretKey configured, skipping signature verification'); + } + + let webhookData: any = {}; + let eventID: string | undefined; + try { + const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + webhookData = decoded.webhookData || {}; + eventID = decoded.eventID; + } catch { + throw new Error('Transak webhook JWT payload is not valid JSON'); + } + + const event = OnrampWebhookEvent.create({ + partner: 'transak', + externalId: webhookData.id, + status: webhookData.status || '', + eventName: eventID, + createdAt: webhookData.createdAt, + fiatAmount: webhookData.fiatAmount != null ? Number(webhookData.fiatAmount) : undefined, + fiatCurrency: webhookData.fiatCurrency, + cryptoCurrency: webhookData.cryptoCurrency, + userId: webhookData.userId, + rawPayload: req.body || {}, + env + }); + + return { event }; + } } \ No newline at end of file diff --git a/packages/bitcore-wallet-service/src/lib/braze.ts b/packages/bitcore-wallet-service/src/lib/braze.ts new file mode 100644 index 0000000000..ca51b32d97 --- /dev/null +++ b/packages/bitcore-wallet-service/src/lib/braze.ts @@ -0,0 +1,82 @@ +import * as request from 'request'; +import config from '../config'; +import { logger } from './logger'; + +const TRACK_EVENT_TIMEOUT_MS = 3000; +export interface BrazeTrackEvent { + externalId: string; + name: string; + time?: string; // ISO8601, defaults to now + properties?: Record; +} + +/** + * Minimal client for the Braze REST API "track" endpoint, used to log custom + * events against a user profile (https://www.braze.com/docs/api/endpoints/user_data/post_user_track/). + */ +class BrazeService { + request: any = request; + + /** + * Fire-and-forget: never rejects. Callers should not await this before + * responding to time-sensitive requests (e.g. partner webhooks). + */ + trackEvent(event: BrazeTrackEvent): Promise { + return new Promise(resolve => { + const url: string | undefined = config.braze?.bwsTrackEventApi; + const apiKey: string | undefined = config.braze?.bwsTrackEventApiKey; + if (!url || !apiKey) { + logger.warn('Braze trackEvent skipped: bwsTrackEventApi/bwsTrackEventApiKey not configured'); + return resolve(); + } + if (!event.externalId) { + logger.debug('Braze trackEvent skipped: missing externalId (event=%s)', event.name); + return resolve(); + } + + const body = { + events: [ + { + external_id: event.externalId, + name: event.name, + time: event.time || new Date().toISOString(), + properties: event.properties || {} + } + ] + }; + + this.request.post( + url + '/users/track', + { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + apiKey + }, + body, + json: true, + timeout: TRACK_EVENT_TIMEOUT_MS + }, + (err, res, data) => { + const statusCode = res?.statusCode; + if (err) { + logger.warn('Braze trackEvent request failed (event=%s): %o', event.name, err); + } else if (!statusCode || statusCode < 200 || statusCode >= 300) { + const redactApiKey = (message: any, apiKey: string): string => + String(message ?? '').split(apiKey).join('[redacted]'); + logger.warn( + 'Braze trackEvent rejected with HTTP %s (event=%s): %s', + statusCode, + event.name, + redactApiKey(data?.message, apiKey) + ); + } else if (data?.errors?.length) { + logger.warn('Braze trackEvent returned errors (event=%s): %o', event.name, data.errors); + } + return resolve(); + } + ); + }); + } +} + +export const brazeService = new BrazeService(); diff --git a/packages/bitcore-wallet-service/src/lib/expressapp.ts b/packages/bitcore-wallet-service/src/lib/expressapp.ts index 38194e8356..628df25917 100644 --- a/packages/bitcore-wallet-service/src/lib/expressapp.ts +++ b/packages/bitcore-wallet-service/src/lib/expressapp.ts @@ -19,6 +19,7 @@ import { registerTransactionRoutes } from './routes/transactions'; import { TssRouter } from './routes/tss'; import { registerWalletDataRoutes } from './routes/walletdata'; import { registerWalletRoutes } from './routes/wallets'; +import { registerWebhookRoutes } from './routes/webhooks'; import { WalletService } from './server'; export class ExpressApp { @@ -130,6 +131,11 @@ export class ExpressApp { returnError }); + registerWebhookRoutes(router, { + getServer, + returnError + }); + /** Imported routes */ router.use(new AaveRouter({ returnError, getServer }).router); router.use(new TssRouter({ returnError, opts }).router); diff --git a/packages/bitcore-wallet-service/src/lib/model/index.ts b/packages/bitcore-wallet-service/src/lib/model/index.ts index a38ce1d1df..3921386c7c 100644 --- a/packages/bitcore-wallet-service/src/lib/model/index.ts +++ b/packages/bitcore-wallet-service/src/lib/model/index.ts @@ -1,5 +1,11 @@ export { Address } from './address'; export { Copayer } from './copayer'; +export { + IOnrampWebhookEvent, + IStoredOnrampWebhookEvent, + IStoreOnrampWebhookEventResult, + OnrampWebhookEvent +} from './onrampWebhookEvent'; export { Advertisement } from './advertisement'; export { Email } from './email'; export { INotification, Notification } from './notification'; diff --git a/packages/bitcore-wallet-service/src/lib/model/onrampWebhookEvent.ts b/packages/bitcore-wallet-service/src/lib/model/onrampWebhookEvent.ts new file mode 100644 index 0000000000..749089d52f --- /dev/null +++ b/packages/bitcore-wallet-service/src/lib/model/onrampWebhookEvent.ts @@ -0,0 +1,96 @@ +export interface IOnrampWebhookEvent { + partner: string; // 'simplex' | 'moonpay' | 'ramp' | 'transak' | 'banxa' | 'sardine' + externalId: string; // transaction/order/payment ID from partner + status: string; // raw status string from partner + eventName?: string; // event type/name if available (e.g. 'ORDER_COMPLETED', 'transaction_updated') + createdAt?: string; // ISO timestamp from partner payload + updatedAt?: string; // ISO timestamp of this transaction state at the partner + // Tells two deliveries of the same transaction apart. Each partner documents + // a different one (moonpay: updatedAt, simplex: event_id, banxa: status); + // defaults to status when the handler sets none. + deliveryVersion?: string; + externalTransactionId?: string; + fiatAmount?: number; + fiatCurrency?: string; + cryptoAmount?: number; // amount of crypto (quoteCurrencyAmount) + cryptoCurrency?: string; + paymentMethod?: string; + walletAddress?: string; + walletAddressTag?: string; + userId?: string; // external customer/user ID from partner + rawPayload: object; // original payload available to the webhook processor + receivedAt: number; // epoch ms when received + env: string; // 'sandbox' | 'production' + isEmbedded?: boolean; // true if the event comes from the embedded flow (verified with the embedded webhook key) +} + +export class OnrampWebhookEvent implements IOnrampWebhookEvent { + partner: string; + externalId: string; + status: string; + eventName?: string; + createdAt?: string; + updatedAt?: string; + deliveryVersion?: string; + externalTransactionId?: string; + fiatAmount?: number; + fiatCurrency?: string; + cryptoAmount?: number; + cryptoCurrency?: string; + paymentMethod?: string; + walletAddress?: string; + walletAddressTag?: string; + userId?: string; + rawPayload: object; + receivedAt: number; + env: string; + isEmbedded?: boolean; + + static create(opts: Partial): OnrampWebhookEvent { + const x = new OnrampWebhookEvent(); + x.partner = opts.partner; + x.externalId = opts.externalId; + x.status = opts.status; + x.eventName = opts.eventName; + x.createdAt = opts.createdAt; + x.updatedAt = opts.updatedAt; + x.deliveryVersion = opts.deliveryVersion; + x.externalTransactionId = opts.externalTransactionId; + x.fiatAmount = opts.fiatAmount; + x.fiatCurrency = opts.fiatCurrency; + x.cryptoAmount = opts.cryptoAmount; + x.cryptoCurrency = opts.cryptoCurrency; + x.paymentMethod = opts.paymentMethod; + x.walletAddress = opts.walletAddress; + x.walletAddressTag = opts.walletAddressTag; + x.userId = opts.userId; + x.rawPayload = opts.rawPayload || {}; + x.receivedAt = opts.receivedAt || Date.now(); + x.env = opts.env || 'production'; + x.isEmbedded = opts.isEmbedded; + return x; + } + + static fromObj(obj: any): OnrampWebhookEvent { + const x = new OnrampWebhookEvent(); + Object.assign(x, obj); + return x; + } +} + +/** Shape persisted in the onramp_webhook_events collection. */ +export interface IStoredOnrampWebhookEvent extends Omit< + IOnrampWebhookEvent, + 'eventName' | 'rawPayload' | 'deliveryVersion' +> { + _id: string; + eventName: string; + expiresAt: Date; // TTL index anchor; the delivery log is not kept forever +} + +export interface IStoreOnrampWebhookEventResult { + inserted: boolean; + id: string; + event: IStoredOnrampWebhookEvent; + isStale: boolean; +} diff --git a/packages/bitcore-wallet-service/src/lib/routes/setup.ts b/packages/bitcore-wallet-service/src/lib/routes/setup.ts index 5d2dd742a1..4d7be561ee 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/setup.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/setup.ts @@ -44,11 +44,18 @@ export function setupAppMiddleware(app: express.Express, opts) { const POST_LIMIT = 1024 * 100; const POST_LIMIT_LARGE = 2 * 1024 * 1024; + // Capture raw body for webhook routes (needed for HMAC signature verification) + const captureRawBody = (req: any, _res: any, buf: Buffer) => { + if (req.path?.includes('/webhook')) { + req.rawBody = buf.toString('utf8'); + } + }; + app.use((req, res, next) => { if (req.path.includes('/txproposals') || req.path.includes('/tss/')) { - return express.json({ limit: POST_LIMIT_LARGE })(req, res, next); + return express.json({ limit: POST_LIMIT_LARGE, verify: captureRawBody })(req, res, next); } else { - return express.json({ limit: POST_LIMIT })(req, res, next); + return express.json({ limit: POST_LIMIT, verify: captureRawBody })(req, res, next); } }); diff --git a/packages/bitcore-wallet-service/src/lib/routes/webhooks.ts b/packages/bitcore-wallet-service/src/lib/routes/webhooks.ts new file mode 100644 index 0000000000..9b060b786c --- /dev/null +++ b/packages/bitcore-wallet-service/src/lib/routes/webhooks.ts @@ -0,0 +1,196 @@ +import express from 'express'; +import { brazeService } from '../braze'; +import { logger } from '../logger'; +import { OnrampWebhookEvent } from '../model/onrampWebhookEvent'; +import type * as Types from '../../types/expressapp'; + +interface RouteContext { + getServer: Types.GetServerFn; + returnError: Types.ReturnErrorFn; +} + +/** + * Shared handler: parse/verify event via service handler, log it, optionally + * store it, respond 200. + * Invalid signatures and payloads that cannot be keyed get a 400; a storage + * outage gets a 503 so the partner retries the delivery. + * Never rejects: any unexpected error falls through to a 500 so the partner + * always gets a response instead of hanging until it times out. + * + * storeEvent (when provided) must run BEFORE the Braze track call: partners + * can (and do) redeliver the same event, so we only want to emit analytics + * once per unique delivery. If storeEvent reports `inserted: false`, this was + * already handled before (a duplicate/retry) and tracking is skipped. If it + * reports `isStale: true`, a newer state for the same transaction was already + * recorded (the partner delivered events out of order) and tracking is + * skipped too, so a late/older update never overwrites newer state downstream. + */ +async function handleWebhook( + req: express.Request, + res: express.Response, + partner: string, + parseEvent: () => { event: OnrampWebhookEvent }, + storeEvent?: (event: OnrampWebhookEvent) => Promise<{ inserted: boolean; isStale?: boolean } | undefined> +): Promise { + let event: OnrampWebhookEvent; + try { + ({ event } = parseEvent()); + } catch (err) { + logger.error(`[webhook:${partner}] Failed to process payload: %o`, err); + // Return 400 so partner knows the payload was rejected (e.g. bad signature) + return res.status(400).json({ error: (err as Error).message }); + } + + try { + logger.info(`[webhook:${partner}] Received event externalId=%s status=%s`, event?.externalId, event?.status); + + // Persist first (if applicable) so we know whether this delivery was + // already handled before deciding whether to emit analytics for it. + let isDuplicate = false; + if (storeEvent) { + try { + const result = await storeEvent(event); + isDuplicate = result?.inserted === false || result?.isStale === true; + if (isDuplicate) { + logger.info(`[webhook:${partner}] Duplicate or stale/out-of-order delivery detected, skipping Braze tracking externalId=%s`, event?.externalId); + } + } catch (err) { + logger.error(`[webhook:${partner}] Failed to store event: %o`, err); + if ((err as any)?.invalidPayload) { + return res.status(400).json({ error: (err as Error).message }); + } + return res.status(503).json({ error: 'Webhook storage temporarily unavailable' }); + } + } + + if (!isDuplicate) { + // Fire-and-forget: analytics tracking must never block or fail the webhook ack. + brazeService + .trackEvent({ + externalId: event?.userId, + name: 'BWS - ONRAMP Webhook Received', + properties: { + partner: event?.partner, + status: event?.status, + eventName: event?.eventName, + externalId: event?.externalId, + fiatAmount: event?.fiatAmount, + fiatCurrency: event?.fiatCurrency, + cryptoAmount: event?.cryptoAmount, + cryptoCurrency: event?.cryptoCurrency, + paymentMethod: event?.paymentMethod, + walletAddress: event?.walletAddress, + walletAddressTag: event?.walletAddressTag, + env: event?.env, + isEmbedded: event?.isEmbedded + } + }) + .catch(err => logger.warn(`[webhook:${partner}] Braze tracking failed: %o`, err)); + } + + return res.status(200).json({ ok: true }); + } catch (err) { + // Safety net: guarantees a response even if something above throws + // unexpectedly (e.g. a bug in a fire-and-forget call), instead of leaving + // an unhandled rejection and the partner request hanging. + logger.error(`[webhook:${partner}] Unexpected error handling webhook: %o`, err); + if (res.headersSent) return res; + return res.status(500).json({ error: 'Internal error handling webhook' }); + } +} + +export function registerWebhookRoutes(router: express.Router, context: RouteContext) { + const { getServer } = context; + + /** + * POST /v1/service/simplex/webhook + * Simplex payment event webhook. Secured with RS256 JWT in X-Signature-SHA256. + */ + router.post('/v1/service/simplex/webhook', (req, res) => { + const server = getServer(req, res); + if (!server) return; + return handleWebhook( + req, res, 'simplex', + () => server.externalServices.simplex.simplexHandleWebhook(req) + ).catch(err => logger.error('[webhook:simplex] Unhandled error: %o', err)); + }); + + /** + * POST /v1/service/moonpay/webhook + * MoonPay buy/sell transaction event. Secured with HMAC-SHA256 in moonpay-signature-v2. + */ + router.post('/v1/service/moonpay/webhook', (req, res) => { + const server = getServer(req, res); + if (!server) return; + return handleWebhook( + req, res, 'moonpay', + () => server.externalServices.moonpay.moonpayHandleWebhook(req), + event => server.storage.storeOnrampWebhookEvent({ event }) + ).catch(err => logger.error('[webhook:moonpay] Unhandled error: %o', err)); + }); + + /** + * POST /v1/service/ramp/webhook + * Ramp buy (purchase) event. Configured via webhookStatusUrl in the Ramp widget. + */ + router.post('/v1/service/ramp/webhook', (req, res) => { + const server = getServer(req, res); + if (!server) return; + return handleWebhook( + req, res, 'ramp', + () => server.externalServices.ramp.rampHandleWebhook(req) + ).catch(err => logger.error('[webhook:ramp] Unhandled error: %o', err)); + }); + + /** + * POST /v1/service/ramp/offramp-webhook + * Ramp sell (offramp) event. Configured via offrampWebhookV3Url in the Ramp widget. + */ + router.post('/v1/service/ramp/offramp-webhook', (req, res) => { + const server = getServer(req, res); + if (!server) return; + return handleWebhook( + req, res, 'ramp', + () => server.externalServices.ramp.rampHandleWebhook(req) + ).catch(err => logger.error('[webhook:ramp] Unhandled error: %o', err)); + }); + + /** + * POST /v1/service/transak/webhook + * Transak order event. Payload data field is a HS256 JWT signed with SECRET_KEY. + */ + router.post('/v1/service/transak/webhook', (req, res) => { + const server = getServer(req, res); + if (!server) return; + return handleWebhook( + req, res, 'transak', + () => server.externalServices.transak.transakHandleWebhook(req) + ).catch(err => logger.error('[webhook:transak] Unhandled error: %o', err)); + }); + + /** + * POST /v1/service/banxa/webhook + * Banxa order status event. Secured with HMAC-SHA256 in Authorization header. + */ + router.post('/v1/service/banxa/webhook', (req, res) => { + const server = getServer(req, res); + if (!server) return; + return handleWebhook( + req, res, 'banxa', + () => server.externalServices.banxa.banxaHandleWebhook(req) + ).catch(err => logger.error('[webhook:banxa] Unhandled error: %o', err)); + }); + + /** + * POST /v1/service/sardine/webhook + * Sardine order event. Optionally secured with HMAC-SHA256 in X-Sardine-Signature. + */ + router.post('/v1/service/sardine/webhook', (req, res) => { + const server = getServer(req, res); + if (!server) return; + return handleWebhook( + req, res, 'sardine', + () => server.externalServices.sardine.sardineHandleWebhook(req) + ).catch(err => logger.error('[webhook:sardine] Unhandled error: %o', err)); + }); +} diff --git a/packages/bitcore-wallet-service/src/lib/storage.ts b/packages/bitcore-wallet-service/src/lib/storage.ts index 92a547d7cd..477fb73203 100644 --- a/packages/bitcore-wallet-service/src/lib/storage.ts +++ b/packages/bitcore-wallet-service/src/lib/storage.ts @@ -11,6 +11,9 @@ import { Advertisement, Copayer, Email, + IOnrampWebhookEvent, + IStoreOnrampWebhookEventResult, + IStoredOnrampWebhookEvent, Notification, Preferences, PushNotificationSub, @@ -42,13 +45,36 @@ const collections = { TX_CONFIRMATION_SUBS: 'tx_confirmation_subs', LOCKS: 'locks', TSS_KEYGEN: 'tss_keygen', - TSS_SIGN: 'tss_sign' + TSS_SIGN: 'tss_sign', + ONRAMP_WEBHOOK_EVENTS: 'onramp_webhook_events' }; const Defaults = Common.Defaults; const Utils = Common.Utils; const ObjectID = mongodb.ObjectID; +const ONRAMP_WEBHOOK_EVENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +const requireOnrampValue = (value: string | undefined, name: string): string => { + if (typeof value !== 'string' || !value.trim()) { + // Flagged so the route answers 400 instead of 503: no amount of partner + // retries will add a field the payload never carried. + const err: any = new Error(`Missing onramp webhook ${name}`); + err.invalidPayload = true; + throw err; + } + return value; +}; + +const requireOnrampIdPart = (value: string | undefined, name: string) => { + return encodeURIComponent(requireOnrampValue(value, name)); +}; + +const onrampDocumentId = (prefix: string, parts: Array<{ name: string; value: string }>) => { + return [prefix, ...parts.map(part => requireOnrampIdPart(part.value, part.name))].join(':'); +}; + +const isDuplicateKeyError = (err: any) => err && err.code === 11000; const objectIdDate = function(date) { return Math.floor(date / 1000).toString(16) + '0000000000000000'; @@ -169,6 +195,15 @@ export class Storage { db.collection(collections.TSS_SIGN).createIndex({ id: 1 }, { unique: true }); + db.collection(collections.ONRAMP_WEBHOOK_EVENTS).createIndex({ + partner: 1, + env: 1, + externalId: 1, + updatedAt: -1 + }); + db.collection(collections.ONRAMP_WEBHOOK_EVENTS).createIndex({ + expiresAt: 1 + }, { expireAfterSeconds: 0 }); } connect(opts, cb) { @@ -1924,4 +1959,76 @@ export class Storage { return this.db.collection(collections.TSS_SIGN).deleteOne({ id }, { w: 1 }); } + /** + * Persists a webhook delivery keyed by (partner, env, eventName, externalId, + * deliveryVersion) so retried deliveries are idempotent: a duplicate insert + * (same _id) is detected via the unique key violation (E11000) and the + * previously stored event is returned instead of throwing. + */ + async storeOnrampWebhookEvent({ event }: { + event: IOnrampWebhookEvent; + }): Promise { + if (!this.db) throw new Error('Storage not ready'); + + const eventName = requireOnrampValue(event.eventName, 'eventName'); + const deliveryVersion = requireOnrampValue(event.deliveryVersion ?? event.status, 'deliveryVersion'); + const id = onrampDocumentId('onramp-webhook', [ + { name: 'provider', value: event.partner }, + { name: 'env', value: event.env }, + { name: 'eventName', value: eventName }, + { name: 'transactionId', value: event.externalId }, + { name: 'deliveryVersion', value: deliveryVersion } + ]); + const receivedAt = Number.isFinite(event.receivedAt) ? event.receivedAt : Date.now(); + const storedEvent: IStoredOnrampWebhookEvent = { + _id: id, + partner: event.partner, + externalId: event.externalId, + status: event.status, + eventName, + receivedAt, + expiresAt: new Date(receivedAt + ONRAMP_WEBHOOK_EVENT_RETENTION_MS), + env: event.env, + ...(event.createdAt !== undefined && { createdAt: event.createdAt }), + ...(event.updatedAt !== undefined && { updatedAt: event.updatedAt }), + ...(event.externalTransactionId !== undefined && { externalTransactionId: event.externalTransactionId }), + ...(event.fiatAmount !== undefined && { fiatAmount: event.fiatAmount }), + ...(event.fiatCurrency !== undefined && { fiatCurrency: event.fiatCurrency }), + ...(event.cryptoAmount !== undefined && { cryptoAmount: event.cryptoAmount }), + ...(event.cryptoCurrency !== undefined && { cryptoCurrency: event.cryptoCurrency }), + ...(event.paymentMethod !== undefined && { paymentMethod: event.paymentMethod }), + ...(event.walletAddress !== undefined && { walletAddress: event.walletAddress }), + ...(event.walletAddressTag !== undefined && { walletAddressTag: event.walletAddressTag }), + ...(event.userId !== undefined && { userId: event.userId }), + ...(event.isEmbedded !== undefined && { isEmbedded: event.isEmbedded }) + }; + + console.log(`Storing onramp webhook event ${eventName} for partner ${event.partner} with id ${id} storedEvent:`, JSON.stringify(storedEvent)); + + try { + await this.db.collection(collections.ONRAMP_WEBHOOK_EVENTS).insertOne(storedEvent); + let isStale = false; + if (storedEvent.updatedAt) { + const newer = await this.db.collection(collections.ONRAMP_WEBHOOK_EVENTS).findOne( + { + partner: event.partner, + env: event.env, + externalId: event.externalId, + _id: { $ne: id }, + updatedAt: { $gt: storedEvent.updatedAt } + }, + { projection: { _id: 1 } } + ); + isStale = !!newer; + } + + return { inserted: true, id, event: storedEvent, isStale }; + } catch (err) { + if (!isDuplicateKeyError(err)) throw err; + const existing = await this.db.collection(collections.ONRAMP_WEBHOOK_EVENTS).findOne({ _id: id }); + if (!existing) throw err; + return { inserted: false, id, event: existing as IStoredOnrampWebhookEvent, isStale: false }; + } + } + }