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
2 changes: 1 addition & 1 deletion src/refiller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ However, ideally this logic for refilling USDH is moved into a separate client.

## Sweeping mainnet USDG to Robinhood

Robinhood inventory holds USDG on chain 4663; mainnet USDG (`USDG-MAINNET`, `0xe343167631d89B6Ffc58B88d6b7fB0228795491D`) should not accumulate. When a `REFILL_BALANCES` entry targets that token on mainnet (`chainId: 1`, `token: 0xe343167631d89B6Ffc58B88d6b7fB0228795491D`), the refiller routes to a bespoke handler that sweeps the base signer's full mainnet USDG balance to Robinhood USDG via the Paxos Transit API when the balance exceeds `MIN_USDG_SWEEP_AMOUNT` (default 10 USDG). Paxos Transit enforces a separate $5 minimum per order.
Robinhood inventory holds USDG on chain 4663; mainnet USDG (`USDG-MAINNET`, `0xe343167631d89B6Ffc58B88d6b7fB0228795491D`) should not accumulate. When a `REFILL_BALANCES` entry targets that token on mainnet (`chainId: 1`, `token: 0xe343167631d89B6Ffc58B88d6b7fB0228795491D`), the refiller routes to a bespoke handler that sweeps the base signer's full mainnet USDG balance to Robinhood USDG via the Paxos Transit API when the balance exceeds `MIN_USDG_SWEEP_AMOUNT` (default 10 USDG). Paxos Transit enforces its own per-order minimum on top of that, and it floats with execution costs — the $5 floor in `PAXOS_TRANSIT_MINIMUMS` is the documented lower bound, not the live value (observed at $160.93 on 2026-08-15). A balance above `MIN_USDG_SWEEP_AMOUNT` but below the live minimum is logged and skipped, not treated as a failure, so `MIN_USDG_SWEEP_AMOUNT` only controls how often the sweep is attempted.

Required environment variables:

Expand Down
38 changes: 27 additions & 11 deletions src/refiller/Refiller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ import {
getNativeTokenInfoForChain,
retry,
getMainnetUsdgAddress,
getPaxosTransitMinimumOfferAmount,
isPaxosTransitAmountBelowMinimumError,
} from "../utils";
import { getRedisCache, RedisCache } from "../cache/Redis";
import { SWAP_ROUTES, SwapRoute, CUSTOM_BRIDGE, CANONICAL_BRIDGE } from "../common";
import { BridgeTransactionDetails } from "../adapter/bridges/BaseBridgeAdapter";
import { PaxosTransitBridge } from "../adapter/bridges/PaxosTransitBridge";
import ERC20_ABI from "../common/abi/MinimalERC20.json";
import { arch } from "@across-protocol/sdk";
Expand Down Expand Up @@ -651,17 +654,30 @@ export class Refiller {
l1Token,
this.logger
);
const {
contract,
method,
args,
value = bnZero,
} = await tokenBridge.constructL1ToL2Txn(
this.baseSignerAddress,
l1Token,
toAddressType(rhUsdgAddress, CHAIN_IDs.ROBINHOOD),
amountToTransfer
);
let bridgeTxn: BridgeTransactionDetails;
try {
bridgeTxn = await tokenBridge.constructL1ToL2Txn(
this.baseSignerAddress,
l1Token,
toAddressType(rhUsdgAddress, CHAIN_IDs.ROBINHOOD),
amountToTransfer
);
} catch (error) {
if (!isPaxosTransitAmountBelowMinimumError(error)) {
throw error;
}
// Paxos's order minimum floats with execution costs and can sit above MIN_USDG_SWEEP_AMOUNT.
// The balance is swept once it clears that minimum, so this is a wait, not a failure.
this.logger.debug({
at: "Refiller#sweepMainnetUsdgToRobinhood",
message: "Mainnet USDG balance is below the Paxos Transit order minimum",
amountToTransfer,
paxosMinimum: getPaxosTransitMinimumOfferAmount(error),
minUsdgSweepAmount: this.config.minUsdgSweepAmount,
});
return;
}
const { contract, method, args, value = bnZero } = bridgeTxn;

const txn = await sendAndConfirmTransaction(
{
Expand Down
104 changes: 100 additions & 4 deletions src/utils/PaxosTransitUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
bnZero,
ConvertDecimals,
delay,
fetchWithTimeout,
FetchHeaders,
HttpError,
isDefined,
MAX_SAFE_ALLOWANCE,
Signer,
Expand Down Expand Up @@ -224,6 +224,71 @@ export function getPaxosTransitDestinationToken(dstChainId: number, l1Token: Add
return PAXOS_TRANSIT_DESTINATION_TOKENS[dstChainId]?.[l1Token.toNative()];
}

/**
* Non-2xx response from the Paxos Transit API, carrying the structured error body Paxos returns:
* `{ error: { code, message, status } }`. Extends the SDK's `HttpError` so status-based callers
* (`isHttpError(err) && err.status === 404`) keep working; `apiStatus` is Paxos's canonical status
* string (e.g. `INVALID_ARGUMENT`).
*
* The SDK's fetch helpers only lift a *string* `error` key off the body, so a Paxos rejection would
* otherwise reach the logs as `HttpError: [object Object]` with the operator-facing message lost.
*/
export class PaxosTransitApiError extends HttpError {
constructor(
status: number,
message: string,
readonly apiStatus?: string
) {
super(status, message);
this.name = "PaxosTransitApiError";
}
}

function toPaxosTransitApiError(status: number, statusText: string, body: string): PaxosTransitApiError {
let parsed: { error?: { message?: unknown; status?: unknown } } | undefined;
try {
parsed = JSON.parse(body);
} catch {
// Non-JSON error body (e.g. an upstream proxy's HTML); fall back to the status line below.
}
const asString = (value: unknown) => (typeof value === "string" && value.length > 0 ? value : undefined);
return new PaxosTransitApiError(
status,
asString(parsed?.error?.message) ?? `HTTP ${status}: ${statusText}`,
asString(parsed?.error?.status)
);
}

/**
* Paxos rejects orders below a per-route minimum that floats with execution costs, so it can sit
* well above the documented {@link PAXOS_TRANSIT_MINIMUMS} floor (observed at $160.93 for
* mainnet -> Robinhood on 2026-08-15). Paxos returns no machine-readable discriminator for it, so
* the message is the only signal.
*/
export function isPaxosTransitAmountBelowMinimumError(error: unknown): error is PaxosTransitApiError {
return error instanceof PaxosTransitApiError && /below the minimum/i.test(error.message);
}

/**
* Extracts the live order minimum, in offer-asset base units, from a below-minimum rejection.
* Returns undefined if Paxos stops quoting the amount in the message.
*/
export function getPaxosTransitMinimumOfferAmount(error: PaxosTransitApiError): BigNumber | undefined {
const [, minimum] = /\((\d+) base units\)/.exec(error.message) ?? [];
return isDefined(minimum) ? toBN(minimum) : undefined;
}

/**
* Paxos returns the same answer to an identical bad request, so only transient failures are worth a
* retry: transport errors (no status) plus the usual back-off statuses.
*/
function isRetryablePaxosTransitError(error: unknown): boolean {
if (!(error instanceof PaxosTransitApiError)) {
return true;
}
return error.status >= 500 || [408, 425, 429].includes(error.status);
}

export class PaxosTransitClient {
constructor(
readonly baseUrl: string,
Expand Down Expand Up @@ -328,17 +393,40 @@ export class PaxosTransitClient {
};
}

/**
* GETs `endpoint`, throwing a {@link PaxosTransitApiError} on a non-2xx response. Bypasses the
* SDK's fetch helpers so that Paxos's `{ error: { message, status } }` body survives into the
* thrown error.
*/
protected async get<T>(endpoint: string): Promise<T> {
const url = `${this.baseUrl}/${endpoint}`;
const response = await fetch(url, { headers: toStringHeaders(this.defaultHeaders()) });
const text = await response.text();
if (!response.ok) {
throw toPaxosTransitApiError(response.status, response.statusText, text);
}
try {
return JSON.parse(text) as T;
} catch {
const contentType = response.headers.get("content-type") ?? "unknown";
throw new Error(`Expected JSON response from ${url} but received content-type: ${contentType}`);
}
}

async getWithRetry<T>(endpoint: string, nRetries = this.nRetries): Promise<T> {
try {
return await fetchWithTimeout<T>(`${this.baseUrl}/${endpoint}`, {}, this.defaultHeaders());
return await this.get<T>(endpoint);
} catch (e) {
const apiError = e instanceof PaxosTransitApiError ? e : undefined;
this.logger?.debug({
at: "PaxosTransitClient#getWithRetry",
message: "Failed to query Paxos Transit API",
endpoint,
e,
status: apiError?.status,
apiStatus: apiError?.apiStatus,
error: (e as Error).message,
});
if (nRetries > 0) {
if (nRetries > 0 && isRetryablePaxosTransitError(e)) {
await delay(1);
return this.getWithRetry<T>(endpoint, --nRetries);
}
Expand All @@ -347,6 +435,14 @@ export class PaxosTransitClient {
}
}

function toStringHeaders(headers: FetchHeaders): Record<string, string> {
return Object.fromEntries(
Object.entries(headers)
.filter(([, value]) => isDefined(value))
.map(([key, value]) => [key, String(value)])
);
}

export function createPaxosTransitClient(logger?: winston.Logger): PaxosTransitClient {
const { PAXOS_API_BASE = "https://api.paxoslabs.com", PAXOS_API_KEY } = process.env;
assert(isDefined(PAXOS_API_KEY), "PAXOS_API_KEY must be set in the environment");
Expand Down
114 changes: 114 additions & 0 deletions test/PaxosTransitClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import sinon from "sinon";
import { expect } from "chai";
import {
PaxosTransitApiError,
PaxosTransitClient,
getPaxosTransitMinimumOfferAmount,
isPaxosTransitAmountBelowMinimumError,
toBN,
} from "../src/utils";

// Verbatim rejection from `GET v1/transit/orders/quote` for a mainnet USDG -> Robinhood sweep of
// 137.911345 USDG on 2026-08-15, when Paxos's floating order minimum sat at $160.93.
const BELOW_MINIMUM_BODY = JSON.stringify({
error: {
code: 400,
message: "Order amount is below the minimum of $160.93 (160924200 base units). Please increase your offer amount.",
status: "INVALID_ARGUMENT",
},
});

describe("PaxosTransitClient", function () {
let client: PaxosTransitClient;

beforeEach(function () {
// One retry keeps the retry assertions cheap; getWithRetry sleeps 1s between attempts.
client = new PaxosTransitClient("https://mock-paxos.test", "test-api-key", undefined, 1);
});

afterEach(() => sinon.restore());

it("returns the parsed body and sends the api key", async function () {
const fetchStub = sinon
.stub(globalThis, "fetch")
.resolves(new Response(JSON.stringify({ ok: true }), { status: 200 }));

expect(await client.getWithRetry("v1/transit/orders")).to.deep.equal({ ok: true });
expect(fetchStub.firstCall.args[0]).to.equal("https://mock-paxos.test/v1/transit/orders");
expect((fetchStub.firstCall.args[1]?.headers as Record<string, string>)["x-api-key"]).to.equal("test-api-key");
});

it("throws a PaxosTransitApiError carrying the API's message and status", async function () {
sinon
.stub(globalThis, "fetch")
.resolves(new Response(BELOW_MINIMUM_BODY, { status: 400, statusText: "Bad Request" }));

const err = await client.getWithRetry("v1/transit/orders/quote").then(
() => undefined,
(e) => e
);
expect(err).to.be.instanceOf(PaxosTransitApiError);
expect(err.status).to.equal(400);
expect(err.apiStatus).to.equal("INVALID_ARGUMENT");
// Without the structured body this reaches the logs as `HttpError: [object Object]`.
expect(err.message).to.include("Order amount is below the minimum of $160.93");
});

it("falls back to the status line when the error body is not JSON", async function () {
// A fresh Response per call: 502 is retryable, and a Response body can only be read once.
sinon
.stub(globalThis, "fetch")
.callsFake(async () => new Response("<html>gateway</html>", { status: 502, statusText: "Bad Gateway" }));

const err = await client.getWithRetry("v1/transit/orders").then(
() => undefined,
(e) => e
);
expect(err).to.be.instanceOf(PaxosTransitApiError);
expect(err.status).to.equal(502);
expect(err.apiStatus).to.equal(undefined);
expect(err.message).to.equal("HTTP 502: Bad Gateway");
});

it("does not retry a rejected request", async function () {
const fetchStub = sinon
.stub(globalThis, "fetch")
.resolves(new Response(BELOW_MINIMUM_BODY, { status: 400, statusText: "Bad Request" }));

await client.getWithRetry("v1/transit/orders/quote").catch(() => undefined);
expect(fetchStub.callCount).to.equal(1);
});

it("retries a transient failure", async function () {
const fetchStub = sinon.stub(globalThis, "fetch");
fetchStub.onFirstCall().resolves(new Response("", { status: 503, statusText: "Service Unavailable" }));
fetchStub.onSecondCall().resolves(new Response(JSON.stringify({ ok: true }), { status: 200 }));

expect(await client.getWithRetry("v1/transit/orders")).to.deep.equal({ ok: true });
expect(fetchStub.callCount).to.equal(2);
});

describe("below-minimum classification", function () {
it("recognises a below-minimum rejection and reads back the live minimum", function () {
const error = new PaxosTransitApiError(
400,
"Order amount is below the minimum of $160.93 (160924200 base units). Please increase your offer amount.",
"INVALID_ARGUMENT"
);
expect(isPaxosTransitAmountBelowMinimumError(error)).to.equal(true);
expect(getPaxosTransitMinimumOfferAmount(error)?.eq(toBN(160924200))).to.equal(true);
});

it("still classifies when Paxos stops quoting the minimum in base units", function () {
const error = new PaxosTransitApiError(400, "Order amount is below the minimum of $160.93.", "INVALID_ARGUMENT");
expect(isPaxosTransitAmountBelowMinimumError(error)).to.equal(true);
expect(getPaxosTransitMinimumOfferAmount(error)).to.equal(undefined);
});

it("does not classify other rejections as below-minimum", function () {
const error = new PaxosTransitApiError(403, "API key is not authorized for this route", "PERMISSION_DENIED");
expect(isPaxosTransitAmountBelowMinimumError(error)).to.equal(false);
expect(isPaxosTransitAmountBelowMinimumError(new Error("below the minimum"))).to.equal(false);
});
});
});