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 src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface AppConfig {
// a placeholder. Never log this value.
signingKey: string;
};
onchainIntentsEnabled: boolean;
corsOrigin: string;
}

Expand All @@ -41,5 +42,6 @@ export default (): AppConfig => ({
solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "",
signingKey: process.env.SOROBAN_SIGNING_KEY ?? "",
},
onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true",
corsOrigin: process.env.CORS_ORIGIN ?? "*",
});
4 changes: 2 additions & 2 deletions src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ export class IntentsController {
}

@Post()
create(@Body() dto: CreateIntentDto) {
async create(@Body() dto: CreateIntentDto) {
const now = Math.floor(Date.now() / 1000);
const intent = this.intentsService.create({
const intent = await this.intentsService.create({
user: dto.user,
srcChain: dto.srcChain,
srcToken: {
Expand Down
107 changes: 104 additions & 3 deletions src/intents/intents.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
import { ConfigService } from "@nestjs/config";
import { Keypair } from "@stellar/stellar-sdk";
import { AppConfig } from "../config/configuration";
import { StellarTxService } from "../soroban/stellar-tx.service";
import { IntentsService } from "./intents.service";

const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";

function fakeConfig(overrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}) {
const values: Record<string, unknown> = {
onchainIntentsEnabled: overrides.onchainIntentsEnabled ?? false,
"stellar.settlementContractId": overrides.settlementContractId ?? "",
};
return { get: (path: string) => values[path] } as ConfigService<AppConfig, true>;
}

function fakeStellarTxService() {
return { invokeContract: jest.fn() } as unknown as jest.Mocked<StellarTxService>;
}

function validCreateData() {
return {
user: Keypair.random().publicKey(),
srcChain: "ethereum" as const,
srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" as const },
srcAmount: "1000000",
dstToken: { contract: VALID_CONTRACT_ID, symbol: "USDC", decimals: 7 },
minDstAmount: "990000",
deadline: Math.floor(Date.now() / 1000) + 1800,
};
}

describe("IntentsService", () => {
let service: IntentsService;

beforeEach(() => {
service = new IntentsService();
service = new IntentsService(fakeConfig(), fakeStellarTxService());
});

it("seeds 5 intents on construction", () => {
Expand All @@ -18,10 +48,10 @@ describe("IntentsService", () => {
}
});

it("create adds an open intent with a generated id", () => {
it("create adds an open intent with a generated id", async () => {
const before = service.getAll().length;
const deadline = Math.floor(Date.now() / 1000) + 1800;
const intent = service.create({
const intent = await service.create({
user: "GTEST...0000",
srcChain: "ethereum",
srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" },
Expand Down Expand Up @@ -66,4 +96,75 @@ describe("IntentsService", () => {
expect(intent.state).toBe("filled");
}
});

describe("on-chain registration (ONCHAIN_INTENTS_ENABLED)", () => {
it("stays fully in-memory when the flag is off, never touching StellarTxService", async () => {
const stellarTxService = fakeStellarTxService();
const service = new IntentsService(fakeConfig({ onchainIntentsEnabled: false }), stellarTxService);

const intent = await service.create(validCreateData());

expect(stellarTxService.invokeContract).not.toHaveBeenCalled();
expect(service.get(intent.intentId)).toEqual(intent);
});

it("invokes the settlement contract and preserves the Intent shape when the flag is on", async () => {
const stellarTxService = fakeStellarTxService();
stellarTxService.invokeContract.mockResolvedValue({ hash: "deadbeef", status: "SUCCESS" } as never);
const service = new IntentsService(
fakeConfig({ onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }),
stellarTxService,
);

const data = validCreateData();
const intent = await service.create(data);

expect(stellarTxService.invokeContract).toHaveBeenCalledTimes(1);
const call = stellarTxService.invokeContract.mock.calls[0][0];
expect(call.contractId).toBe(VALID_CONTRACT_ID);
expect(call.method).toBe("create_intent");

// response shape is unchanged: no fields added/removed relative to the in-memory path
expect(Object.keys(intent).sort()).toEqual(
Object.keys({
intentId: "",
user: "",
srcChain: "",
srcToken: "",
srcAmount: "",
dstToken: "",
minDstAmount: "",
state: "",
createdAt: 0,
deadline: 0,
}).sort(),
);
expect(service.get(intent.intentId)).toBeDefined();
});

it("rejects with a clear error and does not create the intent when SETTLEMENT_CONTRACT_ID is unset", async () => {
const stellarTxService = fakeStellarTxService();
const service = new IntentsService(fakeConfig({ onchainIntentsEnabled: true }), stellarTxService);
const before = service.getAll().length;

await expect(service.create(validCreateData())).rejects.toMatchObject({
message: expect.stringContaining("SETTLEMENT_CONTRACT_ID"),
});
expect(stellarTxService.invokeContract).not.toHaveBeenCalled();
expect(service.getAll()).toHaveLength(before);
});

it("rejects and does not create the intent when the on-chain call fails", async () => {
const stellarTxService = fakeStellarTxService();
stellarTxService.invokeContract.mockRejectedValue(new Error("submission failed after 5 attempts"));
const service = new IntentsService(
fakeConfig({ onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }),
stellarTxService,
);
const before = service.getAll().length;

await expect(service.create(validCreateData())).rejects.toThrow(/settlement contract/i);
expect(service.getAll()).toHaveLength(before);
});
});
});
64 changes: 61 additions & 3 deletions src/intents/intents.service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { v4 as uuidv4 } from "uuid";
import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk";
import { Intent, IntentState } from "./intents.types";
import { buildSeedIntents } from "./intents.seed";
import { AppConfig } from "../config/configuration";
import { StellarTxService } from "../soroban/stellar-tx.service";

@Injectable()
export class IntentsService {
private readonly logger = new Logger(IntentsService.name);
private readonly intents = new Map<string, Intent>();

constructor() {
constructor(
private readonly configService: ConfigService<AppConfig, true>,
private readonly stellarTxService: StellarTxService,
) {
this.seed();
}

create(data: Omit<Intent, "intentId" | "createdAt" | "state">): Intent {
async create(data: Omit<Intent, "intentId" | "createdAt" | "state">): Promise<Intent> {
const now = Math.floor(Date.now() / 1000);
const intent: Intent = {
...data,
Expand All @@ -20,10 +28,60 @@ export class IntentsService {
createdAt: now,
deadline: data.deadline ?? now + 1800,
};

if (this.configService.get("onchainIntentsEnabled", { infer: true })) {
await this.registerOnChain(intent);
}

this.intents.set(intent.intentId, intent);
return intent;
}

/**
* Registers `intent` with the settlement contract. Only called when
* ONCHAIN_INTENTS_ENABLED is on; while that flag is off, create() stays
* fully in-memory (the rollout fallback).
*
* The exact call — method name and argument encoding — is provisional:
* the settlement contract's interface isn't finalized yet (see the
* on-chain settlement ADR and the typed contract bindings work), so this
* uses the SDK's native-value conversion rather than hand-written XDR
* types that would need to change the moment real bindings land.
*/
private async registerOnChain(intent: Intent): Promise<void> {
const contractId = this.configService.get("stellar.settlementContractId", { infer: true });
if (!contractId) {
throw new ServiceUnavailableException(
"On-chain intent registration is enabled but SETTLEMENT_CONTRACT_ID is not configured",
);
}

try {
const result = await this.stellarTxService.invokeContract({
contractId,
method: "create_intent",
args: this.buildCreateIntentArgs(intent),
});
this.logger.log(`Registered intent ${intent.intentId} on-chain (tx ${result.hash})`);
} catch (err) {
this.logger.error(`Failed to register intent ${intent.intentId} on-chain: ${(err as Error).message}`);
throw new ServiceUnavailableException("Failed to register intent with the settlement contract");
}
}

private buildCreateIntentArgs(intent: Intent): xdr.ScVal[] {
return [
nativeToScVal(intent.intentId, { type: "string" }),
new Address(intent.user).toScVal(),
nativeToScVal(intent.srcChain, { type: "symbol" }),
nativeToScVal(intent.srcToken.address, { type: "string" }),
nativeToScVal(BigInt(intent.srcAmount), { type: "i128" }),
new Address(intent.dstToken.contract).toScVal(),
nativeToScVal(BigInt(intent.minDstAmount), { type: "i128" }),
nativeToScVal(intent.deadline, { type: "u64" }),
];
}

get(id: string): Intent | undefined {
return this.intents.get(id);
}
Expand Down
148 changes: 148 additions & 0 deletions src/soroban/signer.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { inspect } from "node:util";
import { ConfigService } from "@nestjs/config";
import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
import { AppConfig } from "../config/configuration";
import { SignerService } from "./signer.service";
import { SorobanService } from "./soroban.service";

function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") {
const values: Record<string, unknown> = {
"stellar.signerSecretKey": signerSecretKey,
"stellar.network": network,
};
return { get: (path: string) => values[path] } as ConfigService<AppConfig, true>;
}

function fakeSorobanService(startingSequence = "100") {
return {
getAccount: jest.fn().mockImplementation(async (publicKey: string) => new Account(publicKey, startingSequence)),
} as unknown as jest.Mocked<SorobanService>;
}

describe("SignerService", () => {
it("reports unconfigured when no secret is set", () => {
const service = new SignerService(configWith(""), fakeSorobanService());
expect(service.isConfigured()).toBe(false);
});

it("throws a clear, secret-free error when signing without a configured key", () => {
const service = new SignerService(configWith(""), fakeSorobanService());
expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNER_SECRET_KEY/);
});

it("derives the public key from the configured secret", () => {
const keypair = Keypair.random();
const service = new SignerService(configWith(keypair.secret()), fakeSorobanService());

expect(service.isConfigured()).toBe(true);
expect(service.getPublicKey()).toBe(keypair.publicKey());
});

it("maps network config to the right passphrase", () => {
const soroban = fakeSorobanService();
expect(new SignerService(configWith("", "testnet"), soroban).getNetworkPassphrase()).toBe(Networks.TESTNET);
expect(new SignerService(configWith("", "futurenet"), soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET);
expect(new SignerService(configWith("", "mainnet"), soroban).getNetworkPassphrase()).toBe(Networks.PUBLIC);
});

it("signs a transaction with the configured key", () => {
const keypair = Keypair.random();
const service = new SignerService(configWith(keypair.secret()), fakeSorobanService());

const account = new Account(keypair.publicKey(), "1");
const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET })
.addOperation(Operation.bumpSequence({ bumpTo: "2" }))
.setTimeout(30)
.build();

expect(tx.signatures).toHaveLength(0);
const signed = service.sign(tx);
expect(signed.signatures).toHaveLength(1);
});

it("never includes the raw secret in string/JSON/inspect representations", () => {
const keypair = Keypair.random();
const service = new SignerService(configWith(keypair.secret()), fakeSorobanService());

const secret = keypair.secret();
expect(String(service)).not.toContain(secret);
expect(JSON.stringify(service)).not.toContain(secret);
expect(inspect(service)).not.toContain(secret);
});

describe("withNextSequence", () => {
it("fetches the starting sequence once and increments it locally", async () => {
const keypair = Keypair.random();
const soroban = fakeSorobanService("100");
const service = new SignerService(configWith(keypair.secret()), soroban);

const first = await service.withNextSequence(async (sequence) => sequence);
const second = await service.withNextSequence(async (sequence) => sequence);
const third = await service.withNextSequence(async (sequence) => sequence);

expect([first, second, third]).toEqual(["101", "102", "103"]);
expect(soroban.getAccount).toHaveBeenCalledTimes(1);
});

it("hands out a distinct, gap-free sequence to every concurrent caller", async () => {
const keypair = Keypair.random();
const soroban = fakeSorobanService("0");
const service = new SignerService(configWith(keypair.secret()), soroban);

const results = await Promise.all(
Array.from({ length: 20 }, () => service.withNextSequence(async (sequence) => sequence)),
);

const numeric = results.map(Number).sort((a, b) => a - b);
expect(new Set(numeric).size).toBe(20); // no two callers got the same sequence
expect(numeric).toEqual(Array.from({ length: 20 }, (_, i) => i + 1)); // 1..20, no gaps
});

it("runs callers strictly one at a time, in call order", async () => {
const keypair = Keypair.random();
const service = new SignerService(configWith(keypair.secret()), fakeSorobanService("0"));
const order: number[] = [];

const slow = service.withNextSequence(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
order.push(1);
});
const fast = service.withNextSequence(async () => {
order.push(2);
});

await Promise.all([slow, fast]);
expect(order).toEqual([1, 2]); // fast waited for slow despite finishing faster on its own
});

it("drops the cached sequence after a failure so the next call re-syncs from the network", async () => {
const keypair = Keypair.random();
const soroban = fakeSorobanService("100");
const service = new SignerService(configWith(keypair.secret()), soroban);

await expect(
service.withNextSequence(async () => {
throw new Error("submission failed");
}),
).rejects.toThrow("submission failed");

const next = await service.withNextSequence(async (sequence) => sequence);
expect(next).toBe("101");
expect(soroban.getAccount).toHaveBeenCalledTimes(2); // re-fetched after the failure
});

it("does not let a failed caller block callers queued behind it", async () => {
const keypair = Keypair.random();
const service = new SignerService(configWith(keypair.secret()), fakeSorobanService("0"));

const failing = service.withNextSequence(async () => {
throw new Error("boom");
});
const following = service.withNextSequence(async (sequence) => sequence);

await expect(failing).rejects.toThrow("boom");
// cache was dropped after the failure, so this re-syncs from the network (still "0") and gets "1"
await expect(following).resolves.toBe("1");
});
});
});
Loading