Skip to content
Merged

Fixes #886

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
168 changes: 168 additions & 0 deletions src/tests/offerPriceValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { ResolvedNetworkConfig } from "../shared/types";
import { compose } from "../transaction/compose";
import {
DEFAULT_MAX_OFFER_PRICE,
DEFAULT_MIN_OFFER_PRICE,
} from "../transaction/offerPrice";

const { mockLoadAccount } = vi.hoisted(() => ({
mockLoadAccount: vi.fn(),
}));

const MOCK_XDR = "AAAAAQAAAAA=";

vi.mock("@stellar/stellar-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@stellar/stellar-sdk")>();

class MockTransactionBuilder {
constructor(_sourceAccount: unknown, _options: unknown) {}

addOperation() {
return this;
}

setTimeout() {
return this;
}

addMemo() {
return this;
}

build() {
return { toXDR: () => MOCK_XDR };
}
}

return {
...actual,
Horizon: {
...actual.Horizon,
Server: vi.fn().mockImplementation(() => ({
loadAccount: mockLoadAccount,
})),
},
TransactionBuilder: MockTransactionBuilder,
};
});

import { buildManageOfferTransaction } from "../transaction/buildTransaction";

const networkConfig: ResolvedNetworkConfig = {
network: "testnet",
horizonUrl: "https://horizon-testnet.stellar.org",
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
};

const TRADER = "GBTABBLFJWSIJKGRVJMOV477L42GXCHFHGDUOCDMC7MXWASTPZKQNB25";
const ISSUER = "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2";

const mockAccount = {
accountId: () => TRADER,
sequenceNumber: () => "1",
incrementSequenceNumber: () => {},
};

const baseOffer = {
sellingAssetCode: "XLM",
buyingAssetCode: "EURC",
buyingAssetIssuer: ISSUER,
amount: "100",
};

describe("offer price ratio validation", () => {
let warning: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
mockLoadAccount.mockResolvedValue(mockAccount);
warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
});

afterEach(() => {
warning.mockRestore();
});

it("warns for prices below the default minimum", async () => {
const result = await buildManageOfferTransaction(
networkConfig.horizonUrl,
networkConfig,
TRADER,
{ ...baseOffer, price: "0.0001" },
);

expect(result.status).toBe("ok");
expect(warning).toHaveBeenCalledWith(
expect.stringContaining(`[${DEFAULT_MIN_OFFER_PRICE}`),
);
});

it("warns for prices above the default maximum, including rational prices", async () => {
const result = await buildManageOfferTransaction(
networkConfig.horizonUrl,
networkConfig,
TRADER,
{ ...baseOffer, price: { n: 1_000_000_000, d: 1 } as unknown as string },
);

expect(result.status).toBe("ok");
expect(warning).toHaveBeenCalledWith(
expect.stringContaining(`${DEFAULT_MAX_OFFER_PRICE}]`),
);
});

it("allows callers to suppress the warning", async () => {
const result = await buildManageOfferTransaction(
networkConfig.horizonUrl,
networkConfig,
TRADER,
{ ...baseOffer, price: "0.0001", suppressPriceWarnings: true },
);

expect(result.status).toBe("ok");
expect(warning).not.toHaveBeenCalled();
});

it("does not warn at the inclusive default boundaries", async () => {
const composeBuilder = compose(TRADER, networkConfig, {
sequenceNumber: "1",
estimatedFee: "100",
});

composeBuilder.addManageOffer({
selling: { assetCode: "XLM" },
buying: { assetCode: "EURC", assetIssuer: ISSUER },
amount: "100",
price: String(DEFAULT_MIN_OFFER_PRICE),
});
composeBuilder.addManageOffer({
selling: { assetCode: "XLM" },
buying: { assetCode: "EURC", assetIssuer: ISSUER },
amount: "100",
price: String(DEFAULT_MAX_OFFER_PRICE),
});

expect(warning).not.toHaveBeenCalled();
});

it("warns from the compose builder and honors builder-level suppression", () => {
compose(TRADER, networkConfig, { suppressPriceWarnings: true }).addManageOffer({
selling: { assetCode: "XLM" },
buying: { assetCode: "EURC", assetIssuer: ISSUER },
amount: "100",
price: "0.00001",
});

expect(warning).not.toHaveBeenCalled();

compose(TRADER, networkConfig).addManageBuyOffer({
selling: { assetCode: "XLM" },
buying: { assetCode: "EURC", assetIssuer: ISSUER },
amount: "100",
price: "1001",
});

expect(warning).toHaveBeenCalledTimes(1);
});
});
98 changes: 98 additions & 0 deletions src/tests/pathPaymentPathValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Keypair, Networks } from "@stellar/stellar-sdk";
import { describe, expect, it } from "vitest";
import { SorokitErrorCode } from "../shared/response";
import { buildPathPayment } from "../transaction/buildTransaction";
import {
buildPathPaymentTransaction,
MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS,
validatePathPaymentPathLength,
} from "../transaction/pathPayment";

const issuer = Keypair.random().publicKey();
const source = Keypair.random().publicKey();
const destination = Keypair.random().publicKey();
const network = {
horizonUrl: "https://example.invalid",
networkPassphrase: Networks.TESTNET,
sorobanRpcUrl: "https://example.invalid",
};

const baseParams = {
destination,
sendAssetCode: "XLM",
destAssetCode: "USDC",
destAssetIssuer: issuer,
amount: "100",
mode: "strict-send" as const,
sequenceNumber: "1",
estimatedFee: "100",
slippageAmount: "95",
};

function intermediateAssets(count: number) {
return Array.from({ length: count }, () => ({
assetCode: "USDC",
assetIssuer: issuer,
}));
}

describe("path payment path length validation", () => {
it("requires at least one intermediate asset when a path is provided", async () => {
const result = await buildPathPayment(network.horizonUrl, network, source, {
...baseParams,
path: [],
});

expect(result.status).toBe("error");
if (result.status === "error") {
expect(result.error.code).toBe(SorokitErrorCode.ROUTER_INVALID_PATH);
expect(result.error.message).toContain("at least one intermediate asset");
}
});

it("rejects paths longer than the Stellar maximum", async () => {
const result = await buildPathPayment(network.horizonUrl, network, source, {
...baseParams,
path: intermediateAssets(MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS + 1),
});

expect(result.status).toBe("error");
if (result.status === "error") {
expect(result.error.code).toBe(SorokitErrorCode.ROUTER_INVALID_PATH);
expect(result.error.message).toContain(
`more than ${MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS}`,
);
}
});

it("validates path length in buildPathPaymentTransaction", async () => {
const result = await buildPathPaymentTransaction({
sourcePublicKey: source,
destination,
sendAssetCode: "XLM",
sendAssetIssuer: null,
destAssetCode: "USDC",
destAssetIssuer: issuer,
amount: "100",
mode: "strict-send",
});

expect(result.status).toBe("error");
if (result.status === "error") {
expect(result.error.code).toBe(SorokitErrorCode.ROUTER_INVALID_PATH);
expect(result.error.message).toContain("at least one intermediate asset");
}
});

it("allows empty paths only when the caller opts into direct conversions", () => {
expect(validatePathPaymentPathLength(0).status).toBe("error");
expect(validatePathPaymentPathLength(0, { allowEmpty: true }).status).toBe(
"ok",
);
expect(validatePathPaymentPathLength(1).status).toBe("ok");
expect(
validatePathPaymentPathLength(MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS)
.status,
).toBe("ok");
});
});
32 changes: 32 additions & 0 deletions src/transaction/buildTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { DEFAULT_TX_TIMEOUT_SECONDS } from "../shared/constants";
import type { ResolvedNetworkConfig } from "../shared/types";
import { createHorizonServer, createSorobanServer } from "../shared/serverFactory";
import { MAX_OPERATIONS_PER_TRANSACTION } from "./validateTransaction";
import { validatePathPaymentPathLength } from "./pathPayment";
import { warnOnExtremeOfferPrice } from "./offerPrice";
import type {
MemoParams,
MemoValidationConfig,
Expand Down Expand Up @@ -927,6 +929,12 @@ function resolvePathAssets(
* Build a path payment transaction XDR.
* Use mode "strict-send" to send an exact amount, or "strict-receive" to receive an exact amount.
* Returns the unsigned XDR ready for signing.
*
* Path semantics:
* - Omit `path` to let Horizon discover a route, including a direct route with
* no intermediate assets (a valid Stellar direct conversion).
* - If `path` is provided it must contain 1–5 intermediate assets. An explicit
* empty array is rejected as ambiguous; omit it instead.
*/
export async function buildPathPayment(
horizonUrl: string,
Expand All @@ -948,6 +956,14 @@ export async function buildPathPayment(
console.warn(message);
}
}

if (params.path !== undefined) {
const explicitPathLength = validatePathPaymentPathLength(
params.path.length,
);
if (explicitPathLength.status === "error") return explicitPathLength;
}

const sendAssetResult = resolveAsset(
params.sendAssetCode,
params.sendAssetIssuer,
Expand Down Expand Up @@ -1060,6 +1076,12 @@ export async function buildPathPayment(
}
}

const finalPathLength = validatePathPaymentPathLength(
finalPath?.length ?? 0,
{ allowEmpty: true },
);
if (finalPathLength.status === "error") return finalPathLength;

const pathResult = resolvePathAssets(finalPath);
if (pathResult.status === "error") return pathResult;

Expand Down Expand Up @@ -1971,6 +1993,16 @@ export async function buildManageOfferTransaction(
const priceResult = validateOfferPrice(params.price);
if (priceResult.status === "error") return priceResult;

if (parseFloat(params.amount) > 0) {
warnOnExtremeOfferPrice(params.price, {
suppress: params.suppressPriceWarnings === true,
...(params.priceWarningRange !== undefined && {
range: params.priceWarningRange,
}),
context: `Offer price (${params.sellingAssetCode}/${params.buyingAssetCode})`,
});
}

// ── Memo ──────────────────────────────────────────────────────────────────
const memoResult = validateMemoParams(params);
if (memoResult.status === "error") return memoResult;
Expand Down
Loading
Loading