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: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ Rules:
- Optional fields use `@IsOptional()` + `@ApiPropertyOptional()` and are typed
with `?:` (not `| undefined`).
- Amount fields are `string` (bigint-as-string) with a `@Matches(/^\d+$/)` guard.
- String fields with a clear bound must also declare `@MaxLength(...)` (for example,
Ed25519 signatures are base64-encoded 64-byte values and are capped at 88 chars).
- Custom validators live in `src/common/validators/`.

### Logger
Expand Down
50 changes: 50 additions & 0 deletions src/common/stellar-signature.contract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Keypair } from "@stellar/stellar-sdk";
import {
buildAcceptMessage,
buildCancelMessage,
buildFillMessage,
buildRegisterMessage,
buildSolverStatusMessage,
verifyStellarSignature,
} from "./stellar-signature";

const VALID_PUBLIC_KEY = "G" + "A".repeat(55);

const messageBuilders: Array<{ name: string; builder: (...args: any[]) => string; args: any[] }> = [
{ name: "buildCancelMessage", builder: buildCancelMessage, args: ["intent-123"] },
{ name: "buildAcceptMessage", builder: buildAcceptMessage, args: ["intent-123", VALID_PUBLIC_KEY] },
{ name: "buildFillMessage", builder: buildFillMessage, args: ["intent-123", VALID_PUBLIC_KEY] },
{ name: "buildRegisterMessage", builder: buildRegisterMessage, args: [VALID_PUBLIC_KEY] },
{
name: "buildSolverStatusMessage",
builder: buildSolverStatusMessage,
args: ["deactivate", VALID_PUBLIC_KEY],
},
];

describe("stellar-signature message contract", () => {
it.each(messageBuilders)("$name is deterministic and verifiable", ({ builder, args }) => {
const message = builder(...args);
const again = builder(...args);

expect(again).toBe(message);
expect(message).toContain(":");
expect(message.split(":").every((part) => !part.includes(":"))).toBe(true);

const signer = Keypair.random();
const signature = Buffer.from(signer.sign(Buffer.from(message, "utf8"))).toString("base64");

expect(() => verifyStellarSignature(signer.publicKey(), message, signature)).not.toThrow();
});

it("keeps each builder on the same canonical colon-delimited format", () => {
const messages = messageBuilders.map(({ builder, args }) => builder(...args));

for (const message of messages) {
const parts = message.split(":");
expect(parts.length).toBeGreaterThan(1);
expect(parts.every((part) => part.length > 0)).toBe(true);
expect(parts.every((part) => !part.includes(":"))).toBe(true);
}
});
});
46 changes: 46 additions & 0 deletions src/config/env.validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,49 @@ describe("envValidationSchema — SOROBAN_SIGNING_KEY", () => {
expect(value.SOROBAN_SIGNING_KEY).toBe(VALID_KEY);
});
});

describe("envValidationSchema — runtime config flags", () => {
it("accepts valid boolean, integer, and fee percentile settings", () => {
const { error, value } = envValidationSchema.validate({
...BASE_ENV,
ONCHAIN_INTENTS_ENABLED: "true",
WS_MAX_CONNECTIONS: "250",
SOROBAN_FEE_PERCENTILE: "p90",
});

expect(error).toBeUndefined();
expect(value.ONCHAIN_INTENTS_ENABLED).toBe(true);
expect(value.WS_MAX_CONNECTIONS).toBe(250);
expect(value.SOROBAN_FEE_PERCENTILE).toBe("p90");
});

it("rejects non-boolean ONCHAIN_INTENTS_ENABLED values", () => {
const { error } = envValidationSchema.validate({
...BASE_ENV,
ONCHAIN_INTENTS_ENABLED: "tru",
});

expect(error).toBeDefined();
expect(error?.message).toContain("ONCHAIN_INTENTS_ENABLED");
});

it("rejects non-integer WS_MAX_CONNECTIONS values", () => {
const { error } = envValidationSchema.validate({
...BASE_ENV,
WS_MAX_CONNECTIONS: "not-a-number",
});

expect(error).toBeDefined();
expect(error?.message).toContain("WS_MAX_CONNECTIONS");
});

it("rejects unsupported SOROBAN_FEE_PERCENTILE values", () => {
const { error } = envValidationSchema.validate({
...BASE_ENV,
SOROBAN_FEE_PERCENTILE: "p12",
});

expect(error).toBeDefined();
expect(error?.message).toContain("SOROBAN_FEE_PERCENTILE");
});
});
20 changes: 20 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,27 @@ export const envValidationSchema = Joi.object({
otherwise: Joi.string().allow("").default(""),
}),

ONCHAIN_INTENTS_ENABLED: Joi.boolean().default(false),
CORS_ORIGIN: Joi.string().default("*"),
WS_MAX_CONNECTIONS: Joi.number().integer().min(0).default(1000),
SOROBAN_FEE_PERCENTILE: Joi.string()
.valid(
"min",
"mode",
"p10",
"p20",
"p30",
"p40",
"p50",
"p60",
"p70",
"p80",
"p90",
"p95",
"p99",
"max",
)
.default("p50"),

// ── Persistence adapter selection ─────────────────────────────────────────
// Controls which repository adapter is used for intents and solvers.
Expand Down
9 changes: 7 additions & 2 deletions src/intents/dto/accept-intent.dto.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { IsString, MinLength } from "class-validator";
import { IsString, MaxLength, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";

const ED25519_SIGNATURE_MAX_LENGTH = 88;

export class AcceptIntentDto {
@ApiProperty({ description: "Solver address accepting the intent" })
@ApiProperty({ description: "Solver address accepting the intent", maxLength: 56 })
@IsString()
@MinLength(5)
@MaxLength(56)
solver!: string;

@ApiProperty({
description:
'Base64-encoded Ed25519 signature of the message "accept:<intentId>:<solver>" ' +
"produced by the solver's private key",
maxLength: ED25519_SIGNATURE_MAX_LENGTH,
})
@IsString()
@MinLength(10)
@MaxLength(ED25519_SIGNATURE_MAX_LENGTH)
signature!: string;
}
9 changes: 7 additions & 2 deletions src/intents/dto/cancel-intent.dto.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { IsString, MinLength } from "class-validator";
import { IsString, MaxLength, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";

const ED25519_SIGNATURE_MAX_LENGTH = 88;

export class CancelIntentDto {
@ApiProperty({ description: "Stellar address of the intent's original creator (must match)" })
@ApiProperty({ description: "Stellar address of the intent's original creator (must match)", maxLength: 56 })
@IsString()
@MinLength(10)
@MaxLength(56)
user!: string;

@ApiProperty({
description:
'Base64-encoded Ed25519 signature of the message "cancel:<intentId>" ' +
"produced by the private key of `user`",
maxLength: ED25519_SIGNATURE_MAX_LENGTH,
})
@IsString()
@MinLength(10)
@MaxLength(ED25519_SIGNATURE_MAX_LENGTH)
signature!: string;
}
48 changes: 48 additions & 0 deletions src/intents/dto/create-intent.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { validate } from "class-validator";
import { CreateIntentDto } from "./create-intent.dto";

const VALID_PUBLIC_KEY = "G" + "A".repeat(55);

describe("CreateIntentDto", () => {
const makeDto = (overrides: Partial<CreateIntentDto> = {}) =>
Object.assign(new CreateIntentDto(), {
user: VALID_PUBLIC_KEY,
srcChain: "ethereum" as const,
srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
srcTokenSymbol: "USDC",
srcTokenDecimals: 6,
srcAmount: "1000000",
dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
dstTokenSymbol: "USDC",
dstTokenDecimals: 6,
minDstAmount: "990000",
...overrides,
});

it("rejects a Stellar self-swap when srcTokenAddress matches dstTokenContract", async () => {
const dto = makeDto({
srcChain: "stellar",
srcTokenAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
});

const errors = await validate(dto);
const selfSwapErrors = errors.filter((error) => error.property === "dstTokenContract");

expect(selfSwapErrors.length).toBeGreaterThan(0);
expect(selfSwapErrors[0].constraints).toMatchObject({
isNotSelfSwap: expect.stringContaining("Self-swaps are not allowed"),
});
});

it("allows same-symbol contracts across different chains", async () => {
const dto = makeDto({
srcChain: "ethereum",
srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
});

const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
52 changes: 47 additions & 5 deletions src/intents/dto/create-intent.dto.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,51 @@
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MinLength } from "class-validator";
import {
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
registerDecorator,
ValidationArguments,
ValidationOptions,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { SUPPORTED_CHAINS, SupportedChain } from "../intents.types";
import { IsValidAddress } from "../../common/validators/is-valid-address.validator";
import { IsValidDeadline } from "../../common/validators/deadline.validator";

function IsNotSelfSwap(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: "isNotSelfSwap",
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
const obj = args.object as Record<string, unknown>;
const srcChain = obj.srcChain;
const srcTokenAddress = obj.srcTokenAddress;
const dstTokenContract = value;

return !(srcChain === "stellar" && srcTokenAddress === dstTokenContract);
},
defaultMessage() {
return "Self-swaps are not allowed: a Stellar asset cannot be swapped against itself on the same chain";
},
},
});
};
}

export class CreateIntentDto {
@ApiProperty({ description: "Stellar address of the user creating the intent" })
@ApiProperty({ description: "Stellar address of the user creating the intent", maxLength: 56 })
@IsString()
@MinLength(10)
@MaxLength(56)
user!: string;

@ApiProperty({ enum: SUPPORTED_CHAINS, description: "Source chain the funds are coming from" })
Expand All @@ -18,8 +56,9 @@ export class CreateIntentDto {
@IsValidAddress()
srcTokenAddress!: string;

@ApiProperty({ description: "Source token symbol, e.g. USDC" })
@ApiProperty({ description: "Source token symbol, e.g. USDC", maxLength: 16 })
@IsString()
@MaxLength(16)
srcTokenSymbol!: string;

@ApiProperty({ minimum: 0, maximum: 18, description: "Source token decimals" })
Expand All @@ -33,13 +72,16 @@ export class CreateIntentDto {
@Matches(/^\d+$/)
srcAmount!: string;

@ApiProperty({ description: "Destination Stellar token contract" })
@ApiProperty({ description: "Destination Stellar token contract", maxLength: 56 })
@IsString()
@Matches(/^[A-Z0-9]{56}$/)
@MaxLength(56)
@IsNotSelfSwap()
dstTokenContract!: string;

@ApiProperty({ description: "Destination token symbol, e.g. USDC" })
@ApiProperty({ description: "Destination token symbol, e.g. USDC", maxLength: 16 })
@IsString()
@MaxLength(16)
dstTokenSymbol!: string;

@ApiProperty({ minimum: 0, maximum: 18, description: "Destination token decimals" })
Expand Down
12 changes: 9 additions & 3 deletions src/intents/dto/fill-intent.dto.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
import { IsOptional, IsString, Matches, MinLength } from "class-validator";
import { IsOptional, IsString, Matches, MaxLength, MinLength } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";

const ED25519_SIGNATURE_MAX_LENGTH = 88;

export class FillIntentDto {
@ApiProperty({ description: "Solver address filling the intent (must match the accepting solver)" })
@ApiProperty({ description: "Solver address filling the intent (must match the accepting solver)", maxLength: 56 })
@IsString()
@MinLength(5)
@MaxLength(56)
solver!: string;

@ApiProperty({ description: "Amount filled, as a non-negative integer string" })
@IsString()
@Matches(/^\d+$/)
fillAmount!: string;

@ApiPropertyOptional({ description: "Stellar fill transaction hash" })
@ApiPropertyOptional({ description: "Stellar fill transaction hash", maxLength: 128 })
@IsOptional()
@IsString()
@MaxLength(128)
txHash?: string;

@ApiProperty({
description:
'Base64-encoded Ed25519 signature of the message "fill:<intentId>:<solver>" ' +
"produced by the solver's private key",
maxLength: ED25519_SIGNATURE_MAX_LENGTH,
})
@IsString()
@MinLength(10)
@MaxLength(ED25519_SIGNATURE_MAX_LENGTH)
signature!: string;
}
Loading