Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
c7a38f8
feat: make proposal nonce submission idempotent
yvesfracari Aug 26, 2026
8f7b4a0
feat(proposals): persist supersession metadata
yvesfracari Aug 26, 2026
0bbbc57
feat(subsolver): introduce reusable provider boundary
yvesfracari Aug 26, 2026
6eedd5d
fix(db): register proposal supersession migration
yvesfracari Aug 26, 2026
2f5b2c6
fix(db): register proposal supersession migration
yvesfracari Aug 26, 2026
7dc6892
chore: lock subsolver core workspace
yvesfracari Aug 26, 2026
d9394d5
test(proposals): use unique nonces in shared fixtures
yvesfracari Aug 26, 2026
ad5ed26
test(proposals): use unique nonces in shared fixtures
yvesfracari Aug 26, 2026
fdd8c9d
test(solve): make encoded route nonce explicit
yvesfracari Aug 26, 2026
7675c9a
test(solve): make encoded route nonce explicit
yvesfracari Aug 26, 2026
06f1167
style: format solve route fixture
yvesfracari Aug 26, 2026
e261bce
style: format solve route fixture
yvesfracari Aug 26, 2026
50f3045
feat(proposals): supersede older live proposals on activation
yvesfracari Aug 26, 2026
2bf03e3
docs(api): describe superseded proposals
yvesfracari Aug 26, 2026
03b88e0
Merge branch 'pedro/cow-1274-create-fynd-subsolver' into pedro/cow-12…
yvesfracari Aug 27, 2026
a99daa2
fix(proposals): keep superseded non-terminal for expiry
yvesfracari Aug 27, 2026
7b14a3d
feat(subsolver): add Fynd quote provider
yvesfracari Aug 27, 2026
407c3a1
fix(proposals): keep superseded non-terminal for expiry
yvesfracari Aug 27, 2026
fe7f858
feat(dev): add subsolver compose profiles
yvesfracari Aug 27, 2026
4bcd958
feat(subsolver): submit Fynd routes through BYOS
yvesfracari Aug 27, 2026
268604a
feat(subsolver): gate Fynd quotes on sidecar readiness
yvesfracari Aug 27, 2026
072bbe3
feat(subsolver): add shared Fynd polling pipeline
yvesfracari Aug 27, 2026
d54fd6d
feat(proposals): complete supersession lifecycle
yvesfracari Aug 27, 2026
b5d26f4
Merge branch 'pedro/cow-1274-create-fynd-subsolver' into pedro/cow-12…
yvesfracari Aug 27, 2026
45a5a54
feat(subsolver): complete shared client pipeline
yvesfracari Aug 27, 2026
b4fd32a
test(proposals): cover supersession edge cases
yvesfracari Aug 27, 2026
f6b9f03
Merge branch 'pedro/cow-1274-create-fynd-subsolver' into pedro/cow-12…
yvesfracari Aug 27, 2026
7b5b294
style(subsolver): satisfy shared pipeline lint
yvesfracari Aug 27, 2026
56c2f56
fix(subsolver): mount compose config
yvesfracari Aug 27, 2026
51406df
refactor(subsolver): separate Fynd app
yvesfracari Aug 27, 2026
44cdbf3
Rename baseline subsolver and fix lint
yvesfracari Aug 28, 2026
6915cd3
fix(subsolver): harden Fynd polling
yvesfracari Aug 31, 2026
70d6fe8
Merge remote-tracking branch 'origin/main' into pedro/cow-1274-fynd-s…
yvesfracari Aug 31, 2026
d5ac8dc
feat(subsolver): add BSC config with PancakeSwap V2 addresses
jean-neiverth Sep 1, 2026
83ec4b7
fix(subsolver): fix BSC config fields and bake into Docker image
jean-neiverth Sep 1, 2026
b6aa20e
Merge pull request #78 from bleu/jean/bsc-subsolver-config
jean-neiverth Sep 1, 2026
a5b285c
Merge main into pedro/cow-1274-fynd-subsolver
jean-neiverth Sep 9, 2026
89be371
fix: subsolver core signature
jean-neiverth Sep 9, 2026
ef88d95
fix: docker compose build
jean-neiverth Sep 10, 2026
f349e1d
fix: lint
jean-neiverth Sep 10, 2026
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
8 changes: 8 additions & 0 deletions .env.baseline-subsolver.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ORDERBOOK_URL=http://host.docker.internal:8080
BYOS_URL=http://host.docker.internal:9585
RPC_URL=
SUBSOLVER_PRIVATE_KEY=
CONFIG_PATH=config.toml
BYOS_REQUESTS_PER_MINUTE=100
BYOS_READ_RESERVE=10
PROPOSAL_REFRESH_INTERVAL_SECONDS=15
13 changes: 13 additions & 0 deletions .env.fynd-subsolver.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ORDERBOOK_URL=http://host.docker.internal:8080
BYOS_URL=http://host.docker.internal:9585
RPC_URL=
SUBSOLVER_PRIVATE_KEY=
CONFIG_PATH=config.toml
FYND_URL=http://fynd:3000
FYND_SLIPPAGE_BPS=50
FYND_QUOTE_CONCURRENCY=32
FYND_MAX_QUOTE_AGE_SECONDS=10
GPV2_SETTLEMENT=
BYOS_REQUESTS_PER_MINUTE=100
BYOS_READ_RESERVE=10
PROPOSAL_REFRESH_INTERVAL_SECONDS=15
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ node_modules/
.env
.env.*
!.env.example
!.env.*.example

# Local agent state
.claude/
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The normative BYOS specification lives in `docs/shared/` (a Git submodule pointi
```
CONTEXT.md Domain language and architecture map — read first
apps/byos/ The BYOS service (proposal API, solver engine, workers)
apps/subsolver/ Reference sub-solver client
apps/baseline-subsolver/ Reference sub-solver client
packages/common/ Shared contract ABIs, EIP-712, DTOs, trampoline encoding
tests/integration/ API integration tests (in-process, mocked blockchain)
tests/e2e/ End-to-end tests (Anvil + full CoW stack)
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Domain vocabulary and the normative specification are in `docs/shared/` (a Git s
| Package | What it is |
|---|---|
| `apps/byos` | The BYOS service: proposal API, solver engine, background workers (validation, retention, penalty, audit) |
| `apps/subsolver` | Reference sub-solver client and e2e-test counterpart |
| `apps/baseline-subsolver` | Baseline reference sub-solver client and e2e-test counterpart |
| `packages/common` | Shared contract ABIs, EIP-712 encoding, DTOs, trampoline encoding |
| `tests/integration` | API integration tests (in-process, no running service needed) |
| `tests/e2e` | End-to-end tests (Anvil + full CoW Protocol stack in Docker) |
Expand Down
15 changes: 14 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ RUN corepack enable pnpm
# Copy workspace manifests first for layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/byos/package.json apps/byos/
COPY apps/subsolver/package.json apps/subsolver/
COPY apps/baseline-subsolver/package.json apps/baseline-subsolver/
COPY apps/fynd-subsolver/package.json apps/fynd-subsolver/
COPY packages/common/package.json packages/common/
COPY packages/subsolver-core/package.json packages/subsolver-core/

RUN pnpm install --frozen-lockfile

Expand All @@ -32,6 +34,17 @@ COPY --from=build /app/apps/byos/node_modules apps/byos/node_modules/
COPY --from=build /app/packages/common/dist packages/common/dist/
COPY --from=build /app/packages/common/package.json packages/common/
COPY --from=build /app/packages/common/node_modules packages/common/node_modules/
# Baseline subsolver executable and reusable provider package.
COPY --from=build /app/apps/baseline-subsolver/dist apps/baseline-subsolver/dist/
COPY --from=build /app/apps/baseline-subsolver/package.json apps/baseline-subsolver/
COPY --from=build /app/apps/baseline-subsolver/node_modules apps/baseline-subsolver/node_modules/
# Fynd subsolver executable.
COPY --from=build /app/apps/fynd-subsolver/dist apps/fynd-subsolver/dist/
COPY --from=build /app/apps/fynd-subsolver/package.json apps/fynd-subsolver/
COPY --from=build /app/apps/fynd-subsolver/node_modules apps/fynd-subsolver/node_modules/
COPY --from=build /app/packages/subsolver-core/dist packages/subsolver-core/dist/
COPY --from=build /app/packages/subsolver-core/package.json packages/subsolver-core/
COPY --from=build /app/packages/subsolver-core/node_modules packages/subsolver-core/node_modules/

EXPOSE 9585 9586
CMD ["node", "apps/byos/dist/index.js"]
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ See [`docs/shared/`](docs/shared/) for the normative BYOS specification, domain
| Path | Description | Status |
|------|-------------|--------|
| `apps/byos` | Main BYOS service (proposal API + solver engine + background jobs) | Complete |
| `apps/subsolver` | Reference sub-solver (Uniswap V2 routing, orderbook polling) | Complete |
| `apps/baseline-subsolver` | Baseline reference sub-solver (Uniswap V2 routing, orderbook polling) | Complete |
| `packages/common` | Shared types: EIP-712, ABIs, DTOs, settlement encoding | Complete |
| `tests/integration` | API integration tests (proposal lifecycle, /solve, /notify) | Complete |
| `tests/e2e` | End-to-end tests (order → proposal → settlement on Anvil) | Complete |
Expand Down
9 changes: 9 additions & 0 deletions apps/baseline-subsolver/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Reference sub-solver

`apps/baseline-subsolver` is the baseline Uniswap V2 reference sub-solver. It uses
`@byos/subsolver-core` for shared BYOS and orderbook clients.

Each sub-solver identity needs its own `SUBSOLVER_PRIVATE_KEY`, Trampoline,
and escrow collateral. The BSC Fynd provider is a separate app at
`apps/fynd-subsolver`, with its own configuration, executable, Compose
profile, and logs.
10 changes: 10 additions & 0 deletions apps/baseline-subsolver/config.toml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copy this to config.toml, replace every placeholder, then start the Compose profile.
# For a differently located file, set SUBSOLVER_CONFIG_PATH before running Compose.
chain-id = 56
trampoline-factory = "0x0000000000000000000000000000000000000000"
uniswap-router = "0x0000000000000000000000000000000000000000"
uniswap-factory = "0x0000000000000000000000000000000000000000"
pair-init-code-hash = "0x0000000000000000000000000000000000000000000000000000000000000000"
proposal-ttl-secs = 60
poll-interval-secs = 2
append-revert = false
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@byos/subsolver",
"name": "@byos/baseline-subsolver",
"version": "0.1.0",
"private": true,
"type": "module",
Expand All @@ -11,6 +11,7 @@
},
"dependencies": {
"@byos/common": "workspace:*",
"@byos/subsolver-core": "workspace:^",
"dotenv": "^16.0.0",
"pino": "^9.0.0",
"smol-toml": "^1.7.1",
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ describe("Uniswap V2 routing", () => {
// How much input to get 906 output from 10000/10000 reserves?
const input = amountIn(906n, 10000n, 10000n);
expect(input).not.toBeNull();
if (input === null) throw new Error("expected a valid input amount");
// Verify round-trip: amountOut(input, ...) >= 906
const output = amountOut(input!, 10000n, 10000n);
const output = amountOut(input, 10000n, 10000n);
expect(output).not.toBeNull();
expect(output!).toBeGreaterThanOrEqual(906n);
if (output === null) throw new Error("expected a valid output amount");
expect(output).toBeGreaterThanOrEqual(906n);
});

it("returns null when output exceeds reserve", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ContractInteraction, type Proposal, signProposal } from "@byos/common";
import type { SignedProposal } from "@byos/subsolver-core";
import type { Address, Hex, TypedDataDomain } from "viem";
import { encodeFunctionData, keccak256 } from "viem";
import { amountIn, amountOut } from "./routing.js";
Expand All @@ -11,6 +12,10 @@ export interface Order {
sellAmount: bigint;
buyAmount: bigint;
kind: "sell" | "buy";
/** The auction's original amounts, used to calculate a partial remaining fill. */
fullSellAmount?: bigint;
fullBuyAmount?: bigint;
estimatedNativeSurplus?: bigint;
}

/** Parameters for building a routed proposal. */
Expand All @@ -24,20 +29,6 @@ export interface RouteParams {
extraInteractions: ContractInteraction[];
}

/** A fully signed proposal ready for submission to BYOS. */
export interface SignedProposal {
orderUid: Hex;
sellToken: Address;
buyToken: Address;
sellAmount: bigint;
minBuyAmount: bigint;
quoteBuyAmount: bigint;
interactions: ContractInteraction[];
validUntil: bigint;
nonce: bigint;
signature: Hex;
}

/** ERC20 approve ABI for building approve interactions. */
const erc20ApproveAbi = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "dotenv/config";
import { byosDomain } from "@byos/common";
import { randomNonce } from "@byos/subsolver-core";
import pino from "pino";
import type { Address, Hex } from "viem";
import { createPublicClient, createWalletClient, http } from "viem";
Expand All @@ -14,11 +15,11 @@ import { OrderbookClient } from "./infra/orderbook.js";
interface Live {
id: number;
validUntil: bigint;
lastSubmitted: bigint;
}

class Subsolver {
private live = new Map<string, Live>();
private nextNonce: bigint;

constructor(
private readonly config: ReturnType<typeof parseConfig>,
Expand All @@ -30,14 +31,12 @@ class Subsolver {
// biome-ignore lint/suspicious/noExplicitAny: viem overloaded signTypedData types
private readonly signFn: (params: any) => Promise<Hex>,
private readonly logger: pino.Logger,
) {
this.nextNonce = BigInt(Date.now());
}
) {}

async pollOnce(now: bigint): Promise<void> {
// Cleanup expired proposals
for (const [uid, live] of this.live) {
if (live.validUntil <= now) {
if (live.validUntil < now) {
this.live.delete(uid);
}
}
Expand All @@ -61,7 +60,8 @@ class Subsolver {
if (pending.length === 0) return;

// Batch fetch reserves (single multicall)
const queries: ReservesQuery[] = pending.map((o) => ({
const candidates = pending;
const queries: ReservesQuery[] = candidates.map((o) => ({
pair: pairAddress(
this.config.toml.uniswapFactory,
this.config.toml.pairInitCodeHash,
Expand All @@ -81,19 +81,19 @@ class Subsolver {
}

// Per-order proposal
for (let i = 0; i < pending.length; i++) {
const order = pending[i]!;
for (let i = 0; i < candidates.length; i++) {
const order = candidates[i];
const reserve = reserves[i];
if (!reserve) continue;
if (!order || !reserve) continue;

try {
const result = await this.propose(order, reserve.reserveSell, reserve.reserveBuy, now);
if (result) {
this.live.set(order.uid.toLowerCase(), result);
this.logger.info({ id: result.id, orderUid: order.uid }, "proposal submitted");
}
} catch (e) {
this.logger.warn({ err: e, orderUid: order.uid }, "proposal failed");
} catch (error) {
this.logger.warn({ err: error, orderUid: order.uid }, "proposal failed");
}
}
}
Expand Down Expand Up @@ -126,7 +126,7 @@ class Subsolver {
now: bigint,
): Promise<Live | null> {
const validUntil = now + BigInt(this.config.toml.proposalTtl);
const nonce = this.nextNonce++;
const nonce = randomNonce();

const proposal = await buildProposal(
order,
Expand All @@ -146,7 +146,7 @@ class Subsolver {
if (!proposal) return null;

const id = await this.byos.submit(proposal);
return { id, validUntil };
return { id, validUntil, lastSubmitted: now };
}
}

Expand Down Expand Up @@ -180,7 +180,6 @@ async function main() {
// Clients
const orderbook = new OrderbookClient(config.orderBookUrl);
const byos = new ByosClient(config.byosUrl, domain, signFn);

// Subsolver
const subsolver = new Subsolver(
config,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export class ChainClient {
return results.map((result, i) => {
if (result.status === "failure") return null;
const [reserve0, reserve1] = result.result;
const query = queries[i]!;
const query = queries[i];
if (!query) return null;

// Reorient by trade direction: Uniswap V2 stores reserves by token address sort order
if (query.sellToken.toLowerCase() < query.buyToken.toLowerCase()) {
Expand Down
5 changes: 5 additions & 0 deletions apps/baseline-subsolver/src/infra/byos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
ByosClient,
ByosRateLimitError,
type ProposalMetadata,
} from "@byos/subsolver-core";
1 change: 1 addition & 0 deletions apps/baseline-subsolver/src/infra/orderbook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { OrderbookClient, type OrderbookOrder } from "@byos/subsolver-core";
File renamed without changes.
File renamed without changes.
16 changes: 16 additions & 0 deletions apps/byos/drizzle/0004_nonce_uniqueness_and_supersession.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
ALTER TABLE "proposals" ADD COLUMN "superseded_by_proposal_id" bigint;--> statement-breakpoint
ALTER TABLE "proposals" ADD CONSTRAINT "proposals_superseded_by_proposal_id_proposals_id_fk" FOREIGN KEY ("superseded_by_proposal_id") REFERENCES "public"."proposals"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
WITH newest_active AS (
SELECT DISTINCT ON (sub_solver, order_uid) id, sub_solver, order_uid
FROM "proposals"
WHERE status = 'active'
ORDER BY sub_solver, order_uid, id DESC
)
UPDATE "proposals" AS old
SET status = 'superseded', superseded_by_proposal_id = newest_active.id
FROM newest_active
WHERE old.sub_solver = newest_active.sub_solver
AND old.order_uid = newest_active.order_uid
AND old.id < newest_active.id
AND old.status IN ('submitted', 'active');--> statement-breakpoint
CREATE UNIQUE INDEX "proposals_sub_solver_nonce_key" ON "proposals" USING btree ("sub_solver","nonce");
2 changes: 2 additions & 0 deletions apps/byos/drizzle/0005_supersession_reference_retention.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "proposals" DROP CONSTRAINT "proposals_superseded_by_proposal_id_proposals_id_fk";--> statement-breakpoint
ALTER TABLE "proposals" ADD CONSTRAINT "proposals_superseded_by_proposal_id_proposals_id_fk" FOREIGN KEY ("superseded_by_proposal_id") REFERENCES "public"."proposals"("id") ON DELETE set null ON UPDATE no action;
14 changes: 14 additions & 0 deletions apps/byos/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@
"when": 1786896000000,
"tag": "0003_buffer_entries",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787780800000,
"tag": "0004_nonce_uniqueness_and_supersession",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787940000000,
"tag": "0005_supersession_reference_retention",
"breakpoints": true
}
]
}
11 changes: 11 additions & 0 deletions apps/byos/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,9 @@ components:
- `penalized` — that debit landed; see `penaltyTxHash`.
- `simFailed` — the settlement simulation reverted.
- `cancelled` — cancelled by a signed `DELETE`.
- `superseded` — a newer validated proposal from the same sub-solver
replaced this route for the order. It may still receive a delayed
settlement outcome and expires at `validUntil`.

All states except `submitted`, `active`, `executing`, and
`settleFailed` are terminal.
Expand All @@ -433,6 +436,7 @@ components:
- penalized
- simFailed
- cancelled
- superseded
RejectionReason:
description: |
Why the background validator rejected a proposal:
Expand Down Expand Up @@ -593,17 +597,24 @@ components:
required:
- id
- subSolver
- orderUid
- validUntil
- status
properties:
id:
$ref: "#/components/schemas/ProposalId"
subSolver:
$ref: "#/components/schemas/Address"
orderUid:
$ref: "#/components/schemas/OrderUid"
validUntil:
$ref: "#/components/schemas/UnixTimestamp"
status:
$ref: "#/components/schemas/ProposalStatus"
supersededByProposalId:
description: Present when this proposal was replaced by a newer active proposal.
allOf:
- $ref: "#/components/schemas/ProposalId"
ListProposalsResponse:
type: object
required:
Expand Down
Loading