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
31 changes: 29 additions & 2 deletions scripts/solver-bot.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
// Reference solver bot - see scripts/README.md.
import WebSocket from "ws";
import { Keypair } from "@stellar/stellar-sdk";
import { buildAcceptMessage, buildFillMessage } from "../src/common/stellar-signature";

const API_BASE = process.env.API_BASE ?? "http://localhost:4000";
const WS_URL = process.env.WS_URL ?? "ws://localhost:4000/ws";
const SOLVER_ADDRESS = process.env.SOLVER_ADDRESS ?? "SOLVER_ALPHA";

// The bot requires a Stellar secret key (S...) to sign requests.
// Set SOLVER_SECRET to the solver keypair's secret key.
// The derived public key (G...) is used as the solver address.
const SOLVER_SECRET = process.env.SOLVER_SECRET;
if (!SOLVER_SECRET) {
console.error("[solver-bot] SOLVER_SECRET env var is required (Stellar secret key S...)");
process.exit(1);
}

const keypair = Keypair.fromSecret(SOLVER_SECRET);
const SOLVER_ADDRESS = keypair.publicKey();

console.log(`[solver-bot] solver address: ${SOLVER_ADDRESS}`);

interface Intent {
intentId: string;
Expand All @@ -12,11 +27,19 @@ interface Intent {
deadline: number;
}

/** Sign a UTF-8 message with the solver keypair; return base64 signature. */
function sign(message: string): string {
return keypair.sign(Buffer.from(message, "utf8")).toString("base64");
}

async function acceptIntent(intentId: string): Promise<boolean> {
const message = buildAcceptMessage(intentId, SOLVER_ADDRESS);
const signature = sign(message);

const res = await fetch(`${API_BASE}/api/v1/intents/${intentId}/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ solver: SOLVER_ADDRESS }),
body: JSON.stringify({ solver: SOLVER_ADDRESS, signature }),
});
if (!res.ok) {
console.log(`[solver-bot] accept ${intentId} failed: ${res.status} ${await res.text()}`);
Expand All @@ -27,13 +50,17 @@ async function acceptIntent(intentId: string): Promise<boolean> {
}

async function fillIntent(intentId: string, minDstAmount: string): Promise<void> {
const message = buildFillMessage(intentId, SOLVER_ADDRESS);
const signature = sign(message);

const res = await fetch(`${API_BASE}/api/v1/intents/${intentId}/fill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
solver: SOLVER_ADDRESS,
fillAmount: minDstAmount,
txHash: `demo-${Date.now()}`,
signature,
}),
});
if (!res.ok) {
Expand Down
68 changes: 68 additions & 0 deletions src/common/stellar-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Stellar keypair signature verification helper.
*
* Convention used throughout this project:
* message = the canonical string that was signed
* signature = base64-encoded 64-byte Ed25519 signature produced by
* Keypair.sign(Buffer.from(message))
*
* The signer proves control of `publicKey` by supplying a valid signature
* over the message. We never trust a caller-supplied address alone.
*/
import { Keypair } from "@stellar/stellar-sdk";
import { UnauthorizedException } from "@nestjs/common";

/**
* Verify that `signature` (base64) over `message` (utf-8) was produced by
* the private key corresponding to `publicKey` (Stellar G-address).
*
* Throws UnauthorizedException on any failure so callers can let it propagate
* straight to the HTTP layer.
*/
export function verifyStellarSignature(
publicKey: string,
message: string,
signature: string,
): void {
try {
const keypair = Keypair.fromPublicKey(publicKey);
const messageBytes = Buffer.from(message, "utf8");
const sigBytes = Buffer.from(signature, "base64");
const valid = keypair.verify(messageBytes, sigBytes);
if (!valid) {
throw new UnauthorizedException("Signature verification failed");
}
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
// Invalid public key, bad base64, etc.
throw new UnauthorizedException("Invalid signature or public key");
}
}

/**
* Build the canonical message that a user must sign to cancel an intent.
*/
export function buildCancelMessage(intentId: string): string {
return `cancel:${intentId}`;
}

/**
* Build the canonical message that a solver must sign to accept an intent.
*/
export function buildAcceptMessage(intentId: string, solver: string): string {
return `accept:${intentId}:${solver}`;
}

/**
* Build the canonical message that a solver must sign to fill an intent.
*/
export function buildFillMessage(intentId: string, solver: string): string {
return `fill:${intentId}:${solver}`;
}

/**
* Build the canonical message that a solver must sign to register.
*/
export function buildRegisterMessage(address: string): string {
return `register:${address}`;
}
9 changes: 9 additions & 0 deletions src/intents/dto/accept-intent.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,13 @@ export class AcceptIntentDto {
@IsString()
@MinLength(5)
solver!: string;

@ApiProperty({
description:
'Base64-encoded Ed25519 signature of the message "accept:<intentId>:<solver>" ' +
"produced by the solver's private key",
})
@IsString()
@MinLength(10)
signature!: string;
}
9 changes: 9 additions & 0 deletions src/intents/dto/cancel-intent.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,13 @@ export class CancelIntentDto {
@IsString()
@MinLength(10)
user!: string;

@ApiProperty({
description:
'Base64-encoded Ed25519 signature of the message "cancel:<intentId>" ' +
"produced by the private key of `user`",
})
@IsString()
@MinLength(10)
signature!: string;
}
9 changes: 9 additions & 0 deletions src/intents/dto/fill-intent.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,13 @@ export class FillIntentDto {
@IsOptional()
@IsString()
txHash?: string;

@ApiProperty({
description:
'Base64-encoded Ed25519 signature of the message "fill:<intentId>:<solver>" ' +
"produced by the solver's private key",
})
@IsString()
@MinLength(10)
signature!: string;
}
15 changes: 15 additions & 0 deletions src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { AcceptIntentDto } from "./dto/accept-intent.dto";
import { FillIntentDto } from "./dto/fill-intent.dto";
import { CancelIntentDto } from "./dto/cancel-intent.dto";
import { QuoteRequestDto } from "./dto/quote-request.dto";
import {
verifyStellarSignature,
buildCancelMessage,
buildAcceptMessage,
buildFillMessage,
} from "../common/stellar-signature";

@ApiTags("intents")
@Controller("api/v1/intents")
Expand Down Expand Up @@ -115,6 +121,9 @@ export class IntentsController {
throw new ForbiddenException("Solver not registered or inactive");
}

// Verify the solver controls the claimed address
verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature);

const updated = this.intentsService.update(id, {
state: "accepted",
solver: dto.solver,
Expand All @@ -140,6 +149,9 @@ export class IntentsController {
throw new GoneException("Fill window has expired");
}

// Verify the solver controls the claimed address
verifyStellarSignature(dto.solver, buildFillMessage(id, dto.solver), dto.signature);

const fillAmount = BigInt(dto.fillAmount);
const minAmount = BigInt(intent.minDstAmount);
if (fillAmount < minAmount) {
Expand Down Expand Up @@ -174,6 +186,9 @@ export class IntentsController {
throw new ConflictException(`Cannot cancel intent in state: ${intent.state}`);
}

// Verify the user controls the claimed address
verifyStellarSignature(dto.user, buildCancelMessage(id), dto.signature);

const updated = this.intentsService.update(id, { state: "cancelled" });
this.intentsGateway.broadcast({ type: "intent_cancelled", intentId: id });
return updated;
Expand Down
4 changes: 4 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ async function bootstrap() {

const configService = app.get(ConfigService<AppConfig, true>);
const port = configService.get("port", { infer: true });
const corsOrigin = configService.get("corsOrigin", { infer: true });

app.enableCors({ origin: corsOrigin });

await app.listen(port);
console.log(`\nVortex backend (Nest) running on :${port}`);
console.log(`WS → ws://localhost:${port}/ws`);
Expand Down
54 changes: 54 additions & 0 deletions src/solvers/dto/register-solver.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { IsArray, IsBoolean, IsIn, IsOptional, IsString, Matches, MinLength } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { SupportedChain } from "../../intents/intents.types";

const SUPPORTED_CHAINS: SupportedChain[] = [
"stellar",
"ethereum",
"base",
"polygon",
"arbitrum",
"optimism",
"avalanche",
];

export class RegisterSolverDto {
@ApiProperty({ description: "Stellar G-address of the solver" })
@IsString()
@MinLength(10)
address!: string;

@ApiProperty({ description: "Human-readable solver name" })
@IsString()
@MinLength(1)
name!: string;

@ApiProperty({ description: "Bond amount as a non-negative integer string (base units)" })
@IsString()
@Matches(/^\d+$/)
bondAmount!: string;

@ApiPropertyOptional({ default: false, description: "Whether the solver is immediately active" })
@IsOptional()
@IsBoolean()
isActive?: boolean;

@ApiProperty({ enum: SUPPORTED_CHAINS, isArray: true, description: "Chains the solver supports" })
@IsArray()
@IsIn(SUPPORTED_CHAINS, { each: true })
supportedChains!: SupportedChain[];

@ApiProperty({ isArray: true, description: "Token symbols the solver supports" })
@IsArray()
@IsString({ each: true })
supportedTokens!: string[];

@ApiProperty({
description:
'Base64-encoded Ed25519 signature of the message "register:<address>" ' +
"produced by the solver's private key — proves key ownership",
})
@IsString()
@MinLength(10)
signature!: string;
}
21 changes: 20 additions & 1 deletion src/solvers/solvers.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import { Controller, Get, NotFoundException, Param } from "@nestjs/common";
import { Body, Controller, Get, NotFoundException, Param, Post } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { SolversService } from "./solvers.service";
import { RegisterSolverDto } from "./dto/register-solver.dto";
import { verifyStellarSignature, buildRegisterMessage } from "../common/stellar-signature";

@ApiTags("solvers")
@Controller("api/v1/solvers")
export class SolversController {
constructor(private readonly solversService: SolversService) {}

@Post("register")
register(@Body() dto: RegisterSolverDto) {
// Prove the caller controls the claimed solver address before registering.
verifyStellarSignature(dto.address, buildRegisterMessage(dto.address), dto.signature);

const solver = this.solversService.register({
address: dto.address,
name: dto.name,
bondAmount: dto.bondAmount,
isActive: dto.isActive ?? false,
avgFillTime: 0,
supportedChains: dto.supportedChains,
supportedTokens: dto.supportedTokens,
});
return solver;
}

@Get()
getLeaderboard() {
const solvers = [...this.solversService.getAll()].sort(
Expand Down
25 changes: 22 additions & 3 deletions src/solvers/solvers.seed.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { Keypair } from "@stellar/stellar-sdk";
import { SolverRecord } from "./solvers.types";

/**
* Well-known test keypairs for seeded solvers.
* Secret keys are only used in tests / the solver-bot demo.
* In production solvers register via POST /api/v1/solvers/register.
*/
export const SEED_SOLVER_SECRETS = {
ALPHA: "SCWJJ7RJRPSCSLIJ2FUPEE5MSKKL7TIBK65EXB7NDFC5MGBN6IPOU7PF",
BETA: "SBWYIL4TL74OJO3AY2C7C6HACHBDQUURZAY5H2URSARNNJ2OHKO6BQ7A",
GAMMA: "SABOQPQHOWLQD27MQ5EHT2B2HCEFOUN2QONXBUZWWKPOMVQYBZVRASCM",
} as const;

/** Keypairs derived from the secrets above. Used by tests and the solver-bot demo. */
export const SEED_SOLVER_KEYPAIRS = {
ALPHA: Keypair.fromSecret(SEED_SOLVER_SECRETS.ALPHA),
BETA: Keypair.fromSecret(SEED_SOLVER_SECRETS.BETA),
GAMMA: Keypair.fromSecret(SEED_SOLVER_SECRETS.GAMMA),
};

export function buildSeedSolvers(): SolverRecord[] {
const now = Math.floor(Date.now() / 1000);

return [
{
address: "SOLVER_ALPHA",
address: SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(),
name: "Alpha Market Making",
bondAmount: "50000000000", // 5000 USDC
fillsCompleted: 842,
Expand All @@ -18,7 +37,7 @@ export function buildSeedSolvers(): SolverRecord[] {
supportedTokens: ["USDC", "WETH", "WBTC"],
},
{
address: "SOLVER_BETA",
address: SEED_SOLVER_KEYPAIRS.BETA.publicKey(),
name: "Beta Liquidity Co",
bondAmount: "100000000000", // 10000 USDC
fillsCompleted: 1241,
Expand All @@ -31,7 +50,7 @@ export function buildSeedSolvers(): SolverRecord[] {
supportedTokens: ["USDC", "WETH", "WBTC", "MATIC", "AVAX"],
},
{
address: "SOLVER_GAMMA",
address: SEED_SOLVER_KEYPAIRS.GAMMA.publicKey(),
name: "Gamma Arb Labs",
bondAmount: "25000000000", // 2500 USDC
fillsCompleted: 187,
Expand Down
Loading