diff --git a/src/refiller/README.md b/src/refiller/README.md index 98dbefc858..f1d213f199 100644 --- a/src/refiller/README.md +++ b/src/refiller/README.md @@ -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: diff --git a/src/refiller/Refiller.ts b/src/refiller/Refiller.ts index 1ce4ca4aea..755a08da6c 100644 --- a/src/refiller/Refiller.ts +++ b/src/refiller/Refiller.ts @@ -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"; @@ -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( { diff --git a/src/utils/PaxosTransitUtils.ts b/src/utils/PaxosTransitUtils.ts index 729d73c304..b6948dd14d 100644 --- a/src/utils/PaxosTransitUtils.ts +++ b/src/utils/PaxosTransitUtils.ts @@ -8,8 +8,8 @@ import { bnZero, ConvertDecimals, delay, - fetchWithTimeout, FetchHeaders, + HttpError, isDefined, MAX_SAFE_ALLOWANCE, Signer, @@ -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, @@ -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(endpoint: string): Promise { + 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(endpoint: string, nRetries = this.nRetries): Promise { try { - return await fetchWithTimeout(`${this.baseUrl}/${endpoint}`, {}, this.defaultHeaders()); + return await this.get(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(endpoint, --nRetries); } @@ -347,6 +435,14 @@ export class PaxosTransitClient { } } +function toStringHeaders(headers: FetchHeaders): Record { + 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"); diff --git a/test/PaxosTransitClient.ts b/test/PaxosTransitClient.ts new file mode 100644 index 0000000000..d1abb7d43f --- /dev/null +++ b/test/PaxosTransitClient.ts @@ -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)["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("gateway", { 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); + }); + }); +});