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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@
"@commitlint/config-conventional": "^21.2.3",
"@nestjs/cli": "^11.0.24",
"@nestjs/schematics": "^11.1.0",
"@msgpack/msgpack": "^3.0.0",
"joi": "^18.2.9"
"@nestjs/testing": "^12.1.0",
"@stryker-mutator/core": "^8.0.0",
"@stryker-mutator/jest-runner": "^8.0.0",
Expand All @@ -96,7 +98,7 @@
"@types/uuid": "^10.0.0",
"@types/ws": "^8.5.12",
"@msgpack/msgpack": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/eslint-plugin": "^8.70.1",
"@typescript-eslint/parser": "^7.13.0",
"eslint": "^8.57.0",
"fast-check": "^4.10.2",
Expand Down
18 changes: 18 additions & 0 deletions packages/solver-sdk/src/signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const messages = {
accept: (intentId: string, solver: string) => `accept:${intentId}:${solver}`,
fill: (intentId: string, solver: string) => `fill:${intentId}:${solver}`,
cancel: (intentId: string) => `cancel:${intentId}`,
amend: (intentId: string, user: string, minDstAmount: string, deadline: number) =>
`amend:${intentId}:${user}:${minDstAmount}:${deadline}`,
wsAuth: (solver: string, timestamp: number | string) => `solver-auth:${solver}:${String(timestamp)}`,
register: (address: string) => `register:${address}`,
};
Expand Down Expand Up @@ -110,6 +112,22 @@ export function signCancel(keypair: Keypair, intentId: string, options?: IntentS
return { user, ...context, signature: signMessage(keypair, messagesV2.cancel(intentId, user, context)) };
}

/** Body for POST /api/v1/intents/{id}/amend (signed by the intent's user). */
export function signAmend(
keypair: Keypair,
intentId: string,
minDstAmount: string,
deadline: number,
) {
const user = keypair.publicKey();
return {
user,
minDstAmount,
deadline,
signature: signMessage(keypair, messages.amend(intentId, user, minDstAmount, deadline)),
};
}

/** WS `{ type: "auth" }` frame. */
export function signWsAuth(keypair: Keypair, timestamp = Math.floor(Date.now() / 1000)) {
const solver = keypair.publicKey();
Expand Down
12 changes: 12 additions & 0 deletions src/common/stellar-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ export function buildCancelMessage(intentId: string, context?: IntentSignatureCo
return `cancel:${intentId}`;
}

/**
* Build the canonical message that an intent owner must sign to amend it.
*/
export function buildAmendMessage(
intentId: string,
user: string,
minDstAmount: string,
deadline: number,
): string {
return `amend:${intentId}:${user}:${minDstAmount}:${deadline}`;
}

/**
* Build the canonical message that a solver must sign to authenticate its WS connection.
*/
Expand Down
33 changes: 33 additions & 0 deletions src/intents/dto/amend-intent.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { IsInt, IsString, Matches, MaxLength, Min, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { IsValidDeadline } from "../../common/validators/deadline.validator";

const ED25519_SIGNATURE_MAX_LENGTH = 88;

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

@ApiProperty({ description: "Replacement minimum destination amount in base units" })
@IsString()
@Matches(/^\d+$/)
minDstAmount!: string;

@ApiProperty({ description: "Replacement Unix timestamp deadline" })
@IsInt()
@Min(1)
@IsValidDeadline()
deadline!: number;

@ApiProperty({
description: 'Base64 Ed25519 signature of "amend:<intentId>:<user>:<minDstAmount>:<deadline>"',
maxLength: ED25519_SIGNATURE_MAX_LENGTH,
})
@IsString()
@MinLength(10)
@MaxLength(ED25519_SIGNATURE_MAX_LENGTH)
signature!: string;
}
10 changes: 10 additions & 0 deletions src/intents/dual-write-intents.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ export class DualWriteIntentsRepository implements IIntentsRepository {
return this.mirrored("update", this.primary.update(id, patch, expectedVersion));
}

async amendIfOpen(
id: string,
patch: Pick<Intent, "minDstAmount" | "deadline">,
now?: number,
): Promise<Intent | null> {
const amended = this.primary.amendIfOpen(id, patch, now);
if (amended) await this.mirror("amendIfOpen", amended);
return amended;
}

/**
* Deletes from memory only. Retention eviction exists to bound process
* memory; Postgres keeps the durable history.
Expand Down
28 changes: 28 additions & 0 deletions src/intents/in-memory-intents.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,32 @@ describe("InMemoryIntentsRepository", () => {
it("update returns null for a missing id", () => {
expect(repo.update("nope", { state: "cancelled" })).toBeNull();
});

it("amendIfOpen updates both terms without changing intent identity or creation history", () => {
const now = Math.floor(Date.now() / 1000);
const original = makeIntent({ intentId: "amend-1", createdAt: now - 10, deadline: now + 100 });
repo.save(original);

const amended = repo.amendIfOpen("amend-1", { minDstAmount: "980000", deadline: now + 200 }, now);

expect(amended).toMatchObject({
intentId: original.intentId,
createdAt: original.createdAt,
state: "open",
minDstAmount: "980000",
deadline: now + 200,
});
});

it("amendIfOpen refuses a non-open or expired intent", () => {
const now = Math.floor(Date.now() / 1000);
repo.save(makeIntent({ intentId: "accepted", state: "accepted", deadline: now + 100 }));
repo.save(makeIntent({ intentId: "expired", deadline: now - 1 }));

expect(repo.amendIfOpen("accepted", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull();
expect(repo.amendIfOpen("expired", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull();
expect(repo.amendIfOpen("missing", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull();
});
});
});
});
96 changes: 96 additions & 0 deletions src/intents/intents.controller.amend.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { ConfigService } from "@nestjs/config";
import { ForbiddenException, UnauthorizedException } from "@nestjs/common";
import { Keypair } from "@stellar/stellar-sdk";
import { AppConfig } from "../config/configuration";
import { buildAmendMessage } from "../common/stellar-signature";
import { IntentsController } from "./intents.controller";
import { IntentsService } from "./intents.service";
import { Intent } from "./intents.types";
import { SolversService } from "../solvers/solvers.service";
import { IntentsGateway } from "./intents.gateway";
import { TokensService } from "../tokens/tokens.service";
import { RoutingService } from "../routing/routing.service";
import { KillSwitchService } from "../killswitch/killswitch.service";

describe("IntentsController.amend", () => {
const keypair = Keypair.random();
const now = Math.floor(Date.now() / 1000);
const intent: Intent = {
intentId: "amend-test-id",
user: keypair.publicKey(),
srcChain: "ethereum",
srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" },
srcAmount: "1000",
dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 },
minDstAmount: "900",
state: "open",
createdAt: now - 60,
deadline: now + 600,
};

function setup(current = intent) {
const amended: Intent = { ...current, minDstAmount: "850", deadline: now + 900 };
const service = {
get: jest.fn().mockResolvedValue(current),
amendIfOpen: jest.fn().mockResolvedValue(amended),
appendAuditEntry: jest.fn(),
} as unknown as jest.Mocked<IntentsService>;
const config = { get: jest.fn().mockReturnValue([]) } as unknown as ConfigService<AppConfig, true>;
const controller = new IntentsController(
service,
{} as SolversService,
{} as IntentsGateway,
{} as TokensService,
{} as RoutingService,
{} as KillSwitchService,
config,
);
const dto = {
user: keypair.publicKey(),
minDstAmount: "850",
deadline: now + 900,
signature: keypair
.sign(Buffer.from(buildAmendMessage(intent.intentId, keypair.publicKey(), "850", now + 900)))
.toString("base64"),
};
return { controller, service, amended, dto };
}

it("applies both signed replacement terms and appends amendment history", async () => {
const { controller, service, amended, dto } = setup();

await expect(controller.amend(intent.intentId, dto)).resolves.toBe(amended);
expect(service.amendIfOpen).toHaveBeenCalledWith(intent.intentId, {
minDstAmount: "850",
deadline: now + 900,
});
expect(service.appendAuditEntry).toHaveBeenCalledWith(
intent.intentId,
"open",
keypair.publicKey(),
"user amended",
{
previousMinDstAmount: "900",
minDstAmount: "850",
previousDeadline: intent.deadline,
deadline: now + 900,
},
);
});

it("rejects a request signed by an address other than the intent owner", async () => {
const { controller, service, dto } = setup();

await expect(controller.amend(intent.intentId, { ...dto, user: Keypair.random().publicKey() }))
.rejects.toBeInstanceOf(ForbiddenException);
expect(service.amendIfOpen).not.toHaveBeenCalled();
});

it("rejects a signature that does not cover the replacement values", async () => {
const { controller, service, dto } = setup();

await expect(controller.amend(intent.intentId, { ...dto, minDstAmount: "851" }))
.rejects.toBeInstanceOf(UnauthorizedException);
expect(service.amendIfOpen).not.toHaveBeenCalled();
});
});
25 changes: 25 additions & 0 deletions src/intents/intents.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ export interface IIntentsRepository {
*/
update(id: string, patch: Partial<Intent>): Intent | null | Promise<Intent | null>;

/**
* Atomically replace an open intent's minimum output and deadline while its
* current deadline is still in the future. Returns null when the intent is
* missing, no longer open, or already expired.
*/
amendIfOpen(
id: string,
patch: Pick<Intent, "minDstAmount" | "deadline">,
now?: number,
): Intent | null | Promise<Intent | null>;

/**
* Remove a stored intent. Used only for in-memory retention sweeps for stale
* terminal-state records; Prisma-backed stores ignore this call by design.
Expand Down Expand Up @@ -196,6 +207,20 @@ export class InMemoryIntentsRepository implements IIntentsRepository {
return updated;
}

amendIfOpen(
id: string,
patch: Pick<Intent, "minDstAmount" | "deadline">,
now = Math.floor(Date.now() / 1000),
): Intent | null {
const existing = this.store.get(id);
if (!existing || existing.state !== "open" || existing.deadline <= now || patch.deadline <= now) {
return null;
}
const updated: Intent = { ...existing, ...patch };
this.store.set(id, updated);
return updated;
}

delete(id: string): boolean {
return this.store.delete(id);
}
Expand Down
Loading
Loading