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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@usherlabs/cex-broker",
"version": "0.2.14",
"version": "0.2.15",
"description": "Unified gRPC API to CEXs by Usher Labs.",
"repository": {
"type": "git",
Expand Down
18 changes: 18 additions & 0 deletions policy/policy.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,23 @@
{ "from": "USDT", "to": "ARB", "min": 1, "max": 10000 }
]
}
},
"travelRule": {
"rule": [
{
"exchange": "BINANCE",
"enabled": true,
"description": "Enable for jurisdictions that require travel-rule metadata on Binance withdrawals (e.g. Australia/AUSTRAC), where the standard withdraw endpoint returns error -4104. Each address needs a questionnaire; self-owned destinations use isAddressOwner=1, sendTo=1, declaration=true.",
"addresses": {
"0x9d467fa9062b6e9b1a46e26007ad82db116c67cb": {
"questionnaire": {
"isAddressOwner": 1,
"sendTo": 1,
"declaration": true
}
}
}
}
]
}
}
48 changes: 39 additions & 9 deletions src/handlers/execute-action/withdraw.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as grpc from "@grpc/grpc-js";
import { validateWithdraw } from "../../helpers";
import {
resolveTravelRuleDecision,
validateWithdraw,
withdrawViaLocalEntity,
} from "../../helpers";
import {
mapCcxtErrorToGrpcStatus,
stableGrpcErrorCode,
Expand Down Expand Up @@ -79,17 +83,43 @@ export async function handleWithdraw(ctx: ExecuteActionContext): Promise<void> {
null,
);
}
try {
const transaction = await broker.withdraw(
symbol,
transferValue.amount,
transferValue.recipientAddress,
undefined,

const travelRule = resolveTravelRuleDecision(
policy,
cex,
transferValue.recipientAddress,
);
if (travelRule.mode === "denied") {
return ctx.wrappedCallback(
{
...(transferValue.params ?? {}),
network: withdrawNetwork.exchangeNetworkId,
code: grpc.status.FAILED_PRECONDITION,
message: `travel_rule_denied: ${travelRule.error}`,
},
null,
);
}

try {
const transaction =
travelRule.mode === "localentity"
? await withdrawViaLocalEntity(broker, {
code: symbol,
amount: transferValue.amount,
address: transferValue.recipientAddress,
network: withdrawNetwork.exchangeNetworkId,
questionnaire: travelRule.questionnaire,
params: transferValue.params,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: await broker.withdraw(
symbol,
transferValue.amount,
transferValue.recipientAddress,
undefined,
{
...(transferValue.params ?? {}),
network: withdrawNetwork.exchangeNetworkId,
},
);
log.info(`Withdraw Result: ${JSON.stringify(transaction)}`);
ctx.wrappedCallback(null, {
proof: ctx.verity.proof,
Expand Down
3 changes: 3 additions & 0 deletions src/helpers/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ccxt from "@usherlabs/ccxt";
import type { BrokerAccountRole, BrokerCredentials } from "../types";
import { buildCcxtConfig } from "./exchange-credentials";
import { log } from "./logger";
import { registerBinanceTravelRuleWithdrawEndpoint } from "./travel-rule";

export type BrokerAccount = {
exchange: Exchange;
Expand Down Expand Up @@ -51,6 +52,8 @@ export function applyCommonExchangeConfig(exchange: Exchange) {
recvWindow: 60000,
adjustForTimeDifference: true,
});
// Register Binance's travel-rule withdraw endpoint (no-op for other exchanges).
registerBinanceTravelRuleWithdrawEndpoint(exchange);
}

export function createBroker(
Expand Down
32 changes: 32 additions & 0 deletions src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
parseMarketPattern,
parseMarketType,
} from "./market-type";
import { australiaQuestionnaireSchema } from "./travel-rule";

export { authenticateRequest } from "./auth";
export {
Expand All @@ -29,6 +30,13 @@ export {
selectBroker,
selectBrokerAccount,
} from "./broker";
export {
australiaQuestionnaireSchema,
registerBinanceTravelRuleWithdrawEndpoint,
resolveTravelRuleDecision,
type TravelRuleDecision,
withdrawViaLocalEntity,
} from "./travel-rule";
export {
buildHttpClientOverrideFromMetadata,
createVerityHttpClientOverride,
Expand Down Expand Up @@ -71,6 +79,26 @@ export function loadPolicy(policyPath: string): PolicyConfig {
.default([]),
});

// Travel-rule config: per-exchange opt-in flag plus static questionnaire
// answers keyed by destination address, validated against the AU schema at
// load time so a malformed questionnaire fails startup, not a live withdraw.
const travelRuleEntrySchema = Joi.object({
// Only Binance implements the travel-rule (localentity) withdraw endpoint,
// so reject other exchanges at load time rather than failing at withdraw
// time with a cryptic "endpoint not registered" error.
exchange: Joi.string().uppercase().valid("BINANCE").required(),
enabled: Joi.boolean().required(),
description: Joi.string().optional(),
addresses: Joi.object()
.pattern(
Joi.string(),
Joi.object({
questionnaire: australiaQuestionnaireSchema.required(),
}),
)
.required(),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Full PolicyConfig schema
const policyConfigSchema = Joi.object({
withdraw: Joi.object({
Expand All @@ -84,6 +112,10 @@ export function loadPolicy(policyPath: string): PolicyConfig {
order: Joi.object({
rule: orderRuleSchema.required(),
}).required(),

travelRule: Joi.object({
rule: Joi.array().items(travelRuleEntrySchema).required(),
}).optional(),
});

const { error, value } = policyConfigSchema.validate(
Expand Down
187 changes: 187 additions & 0 deletions src/helpers/travel-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type { Dict, Exchange } from "@usherlabs/ccxt";
import Joi from "joi";
import type { PolicyConfig, TravelRuleQuestionnaire } from "../types";

/**
* Binance travel-rule ("local entity") withdrawal support.
*
* Some jurisdictions (Australia from 2026-07-01 under AUSTRAC) require Binance
* withdrawals to carry beneficiary metadata. Binance enforces this by rejecting
* the standard `POST /sapi/v1/capital/withdraw/apply` endpoint with error -4104
* and only accepting `POST /sapi/v1/localentity/withdraw/apply`, which takes an
* extra required `questionnaire` field (a JSON string of beneficiary answers).
*
* ccxt has no wrapper for the localentity endpoint, so we register it at runtime
* via the exchange's own `defineRestApi` and call the generated implicit method.
* The questionnaire answers are static per destination address and are validated
* at policy-load time by {@link australiaQuestionnaireSchema}.
*/

// Australia questionnaire shape per Binance's travel-rule docs. Conditional
// requirements mirror the official spec:
// - bnfType required only when sending to another beneficiary (isAddressOwner=2)
// - individual name/location fields required only for bnfType=0 (individual)
// - corporate fields required only for bnfType=1 (corporate/entity)
// - vasp required only when sending to another VASP (sendTo=2)
// - vaspName required only when the VASP is not in Binance's list (vasp="others")
// Inapplicable fields are forbidden so config mistakes fail fast at startup.
// `is` schemas are explicitly `.required()` so that an ABSENT referenced field
// does not match (an optional Joi schema treats `undefined` as valid, which would
// otherwise wrongly trigger the required branch when e.g. bnfType is omitted).
const isAnotherBeneficiary = Joi.number().valid(2).required();
const isIndividual = Joi.number().valid(0).required();
const isCorporate = Joi.number().valid(1).required();
const isSendToVasp = Joi.number().valid(2).required();
const isVaspOthers = Joi.string().valid("others").required();

/** `base`, made required when sibling `ref` matches `is`, and forbidden otherwise. */
function requiredWhen(
base: Joi.Schema,
ref: string,
is: Joi.Schema,
): Joi.Schema {
return base.when(ref, {
is,
// biome-ignore lint/suspicious/noThenProperty: Joi .when() options object, not a thenable
then: Joi.required(),
otherwise: Joi.forbidden(),
});
}

export const australiaQuestionnaireSchema = Joi.object({
isAddressOwner: Joi.number().valid(1, 2).required(),
sendTo: Joi.number().valid(1, 2).required(),
// Binance requires an affirmative declaration; a false value can never yield a
// successful withdrawal, so reject it when the static config is loaded.
declaration: Joi.boolean().valid(true).required(),
bnfType: requiredWhen(
Joi.number().valid(0, 1),
"isAddressOwner",
isAnotherBeneficiary,
),
bnfFirstName: requiredWhen(Joi.string(), "bnfType", isIndividual),
bnfLastName: requiredWhen(Joi.string(), "bnfType", isIndividual),
country: requiredWhen(Joi.string(), "bnfType", isIndividual),
city: requiredWhen(Joi.string(), "bnfType", isIndividual),
bnfCorpName: requiredWhen(Joi.string(), "bnfType", isCorporate),
bnfCorpCountry: requiredWhen(Joi.string(), "bnfType", isCorporate),
bnfCorpCity: requiredWhen(Joi.string(), "bnfType", isCorporate),
vasp: requiredWhen(Joi.string(), "sendTo", isSendToVasp),
vaspName: requiredWhen(Joi.string(), "vasp", isVaspOthers),
});

export type TravelRuleDecision =
| { mode: "standard" }
| { mode: "localentity"; questionnaire: TravelRuleQuestionnaire }
| { mode: "denied"; error: string };

/**
* Decides whether a withdrawal must use Binance's travel-rule endpoint.
*
* Travel rule is opt-in per exchange via the `enabled` flag, so a non-AU account
* keeps using the standard endpoint. When enabled, the destination address must
* have a configured questionnaire; if it does not we fail closed rather than fall
* back to the standard endpoint (which would just reproduce the -4104 rejection).
*/
export function resolveTravelRuleDecision(
policy: PolicyConfig,
exchange: string,
recipientAddress: string,
): TravelRuleDecision {
const rules = policy.travelRule?.rule ?? [];
const exchangeNorm = exchange.trim().toUpperCase();
const entry = rules.find(
(rule) => rule.exchange.trim().toUpperCase() === exchangeNorm,
);
if (!entry || !entry.enabled) {
return { mode: "standard" };
}

const addressNorm = recipientAddress.trim().toLowerCase();
const match = Object.entries(entry.addresses).find(
([address]) => address.trim().toLowerCase() === addressNorm,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!match) {
return {
mode: "denied",
error: `no travel-rule questionnaire configured for ${exchangeNorm} address ${recipientAddress}`,
};
}

return { mode: "localentity", questionnaire: match[1].questionnaire };
}

/**
* Registers Binance's `localentity/withdraw/apply` endpoint on the exchange
* instance. No-op for non-Binance exchanges. Idempotent — ccxt just reassigns
* the generated implicit method if called again.
*/
export function registerBinanceTravelRuleWithdrawEndpoint(
exchange: Exchange,
): void {
if (exchange.id !== "binance") {
return;
}
// Same rate-limit weight as capital/withdraw/apply; the signing path is shared
// by all sapi private POSTs, so no ccxt fork change is needed.
exchange.defineRestApi(
{ sapi: { post: { "localentity/withdraw/apply": 4.0002 } } },
"request",
);
}

type LocalEntityWithdrawArgs = {
code: string;
amount: number;
address: string;
network: string;
questionnaire: TravelRuleQuestionnaire;
// Extra caller params (memo/tag, withdrawOrderId, etc.), forwarded to keep
// parity with the standard withdraw path. The fixed fields below win on
// collision so params can never override coin/amount/network/questionnaire.
params?: Record<string, string | number>;
};

type BinanceLocalEntityWithdraw = {
sapiPostLocalentityWithdrawApply?: (
params: Record<string, unknown>,
) => Promise<Dict>;
};

/**
* Mirrors ccxt's `binance.withdraw` currency/network/precision handling, but
* targets the travel-rule endpoint and attaches the questionnaire. ccxt's
* `urlencode` applies `encodeURIComponent` to the value, satisfying Binance's
* requirement that the questionnaire JSON be URL-encoded in the request body.
*/
export async function withdrawViaLocalEntity(
broker: Exchange,
args: LocalEntityWithdrawArgs,
) {
const exchange = broker as Exchange & BinanceLocalEntityWithdraw;
if (typeof exchange.sapiPostLocalentityWithdrawApply !== "function") {
throw new Error(
"binance_localentity_withdraw_unavailable: travel-rule withdraw endpoint is not registered on this exchange instance",
);
}

broker.checkAddress(args.address);
await broker.loadMarkets();
const currency = broker.currency(args.code);

const networks = broker.safeDict(broker.options, "networks", {});
const networkUpper = args.network.trim().toUpperCase();
const mappedNetwork = broker.safeString(networks, networkUpper, networkUpper);

const request: Record<string, unknown> = {
...args.params,
coin: currency.id,
address: args.address,
amount: broker.currencyToPrecision(args.code, args.amount),
network: mappedNetwork,
questionnaire: JSON.stringify(args.questionnaire),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const response = await exchange.sapiPostLocalentityWithdrawApply(request);
return broker.parseTransaction(response, currency);
}
Loading
Loading