Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ src/.DS_Store
*.secret
.env.local
.env*.local
.env

frontend/dist/
frontend/package-lock.json
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/views/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const BROKER_TO_CONTRACT: Record<string, string> = {
XLM: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
"USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN":
"CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75",
"EURC-GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4IBER":
"EURC-GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2":
"CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV",
"AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA":
"CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK",
Expand Down
335 changes: 306 additions & 29 deletions scripts/harvest_router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ import {
Keypair,
xdr,
} from "@stellar/stellar-sdk";
import { estimateSwap, StellarBrokerClient } from "@stellar-broker/client";
// NOTE: import the package's ESM entry by explicit subpath. The published
// `@stellar-broker/client@0.6.14` sets `main: lib/index.js` (which doesn't exist;
// the bundle is `lib/stellarbroker.js`) and only exposes the source via the
// bundler-only `module` field — so a bare specifier fails to resolve under Node.
import { estimateSwap, StellarBrokerClient } from "@stellar-broker/client/src/index.js";
import { StellarRouterContract } from "@creit-tech/stellar-router-sdk";

// ── Config ────────────────────────────────────────────────────────────────────
Expand All @@ -50,8 +54,11 @@ const SOROSWAP_ROUTER = process.env.SOROSWAP_ROUTER ?? (StellarRouterContract.v1
const SWAP_ROUTES_URL = process.env.SWAP_ROUTES_URL;
const KEEPER_INGEST_KEY = process.env.KEEPER_INGEST_KEY;
const KEEPER_SECRET = process.env.KEEPER_SECRET;
const STELLAR_BROKER_PARTNER_KEY = process.env.STELLAR_BROKER_PARTNER_KEY ?? "";
const EXECUTE = process.argv.includes("--execute");
const QUOTE_AMOUNT_BLND = BigInt(process.env.QUOTE_AMOUNT_BLND ?? "1000000000"); // 100 BLND @ 7dp
// Confirm-to-settlement timeout for a live Broker session.
const BROKER_TIMEOUT_MS = Number(process.env.BROKER_TIMEOUT_MS ?? "120000");

const BLND_CLASSIC = "BLND-GDJEHTBE6ZHUXSWFI642DCGLUOECLHPF3KSXHPXTSTJ7E3JF6MQ5EZYY";
const BLND_SOROBAN = "CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY";
Expand Down Expand Up @@ -168,9 +175,293 @@ async function logRoute(row: Record<string, unknown>): Promise<void> {
}
}

// ── On-chain execution helpers ────────────────────────────────────────────────

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const addrScVal = (a: string) => Address.fromString(a).toScVal();
const i128ScVal = (n: bigint) => nativeToScVal(n, { type: "i128" });
const boolScVal = (b: boolean) => xdr.ScVal.scvBool(b);

/** Decode an i128 contract return value to a bigint (0 if absent/void). */
function scValToBigInt(v: xdr.ScVal | undefined): bigint {
if (!v) return 0n;
const n = scValToNative(v);
return typeof n === "bigint" ? n : BigInt(Math.trunc(Number(n)));
}

interface InvokeResult {
hash: string;
returnValue: xdr.ScVal | undefined;
}

/**
* Read-only simulation of a no-arg getter returning an Address (e.g.
* `swap_account`). No signing, no submit. Returns the `G…`/`C…` string or null.
*/
async function simReadAddress(contractId: string, method: string): Promise<string | null> {
try {
const contract = new Contract(contractId);
const acc = await server.getAccount(SIM_ACCOUNT).catch(() => null);
if (!acc) return null;
const tx = new TransactionBuilder(acc, { fee: BASE_FEE, networkPassphrase: PASSPHRASE })
.addOperation(contract.call(method))
.setTimeout(30)
.build();
const sim = await server.simulateTransaction(tx);
if (!SorobanRpc.Api.isSimulationSuccess(sim) || !sim.result?.retval) return null;
return String(scValToNative(sim.result.retval));
} catch {
return null;
}
}

/**
* Build → simulate/assemble → sign (keeper) → submit → poll a single Soroban
* contract invocation. Single-signer, source-account auth (the keeper is the tx
* source, so `prepareTransaction` resolves the require_auth() footprints). Throws
* on submit error or a non-SUCCESS final status.
*/
async function invokeContract(
keeper: Keypair,
contractId: string,
method: string,
args: xdr.ScVal[],
): Promise<InvokeResult> {
const source = await server.getAccount(keeper.publicKey());
const contract = new Contract(contractId);
const built = new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase: PASSPHRASE })
.addOperation(contract.call(method, ...args))
.setTimeout(60)
.build();

const prepared = await server.prepareTransaction(built);
prepared.sign(keeper);

const sent = await server.sendTransaction(prepared);
if (sent.status === "ERROR") {
throw new Error(`${method} submit failed: ${JSON.stringify(sent.errorResult)}`);
}

let result = await server.getTransaction(sent.hash);
const deadline = Date.now() + 60_000;
while (result.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && Date.now() < deadline) {
await sleep(2000);
result = await server.getTransaction(sent.hash);
}
if (result.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
throw new Error(`${method} tx ${sent.hash} ended status=${result.status}`);
}
return { hash: sent.hash, returnValue: result.returnValue };
}

/**
* Live Stellar Broker swap (keeper account). Opens a signed websocket session,
* confirms the first successful streamed quote, and resolves with the realised
* bought amount (stroops). The keeper Keypair authorizes each streamed leg.
*/
function brokerSwap(
keeper: Keypair,
sellingClassic: string,
buyingClassic: string,
sellingAmount: bigint,
slippage: number,
): Promise<bigint> {
return new Promise<bigint>((resolve, reject) => {
const client = new StellarBrokerClient({
partnerKey: STELLAR_BROKER_PARTNER_KEY,
account: keeper.publicKey(),
// Keeper secret authorizes each leg (AuthorizationWrapper signs with the Keypair).
authorization: keeper.secret(),
});
let confirmed = false;
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
client.close();
} catch {
/* ignore */
}
fn();
};
const timer = setTimeout(() => finish(() => reject(new Error("broker session timeout"))), BROKER_TIMEOUT_MS);

client.on("quote", (e: unknown) => {
const q = (e as { detail: { status: string } }).detail;
if (confirmed || q.status !== "success") return;
confirmed = true;
try {
client.confirmQuote(keeper.publicKey());
} catch (err) {
finish(() => reject(err instanceof Error ? err : new Error(String(err))));
}
});
client.on("finished", (e: unknown) => {
const r = (e as { detail: { bought: string } }).detail;
finish(() => resolve(BigInt(Math.round(Number(r.bought ?? "0") * 1e7))));
});
client.on("error", (e: unknown) => {
const msg = (e as { detail?: unknown }).detail;
finish(() => reject(new Error(typeof msg === "string" ? msg : "broker session error")));
});

client
.connect()
.then(() =>
client.quote({
sellingAsset: sellingClassic,
buyingAsset: buyingClassic,
sellingAmount: (Number(sellingAmount) / 1e7).toString(),
slippageTolerance: slippage,
}),
)
.catch((err) => finish(() => reject(err instanceof Error ? err : new Error("broker connect failed"))));
});
}

/**
* Live harvest for one vault (keeper-operated, `--execute`):
* 1. `harvest_claim(keeper)` — claim BLND into the strategy (+ approve the swap
* account to pull it for the off-chain route).
* 2. Quote the *actually claimed* amount on both venues and pick the best.
* 3. Broker: pull BLND → off-chain swap → return underlying → `harvest_reinvest`
* (via_soroswap=false). Soroswap: `harvest_reinvest` (via_soroswap=true) does
* the on-chain swap + re-leverage atomically.
* 4. Log `status='executed'` with executed_out, slippage_bps, tx_hash.
*/
async function executeHarvest(v: Vault, keeper: Keypair): Promise<void> {
const keeperPk = keeper.publicKey();

// 1. Claim emissions into the strategy.
const claimRes = await invokeContract(keeper, v.strategyId, "harvest_claim", [addrScVal(keeperPk)]);
const claimed = scValToBigInt(claimRes.returnValue);
console.log(`[${v.symbol}] claimed BLND=${claimed}`);
if (claimed <= 0n) {
await logRoute({
network: "mainnet",
strategy_id: v.strategyId,
asset_symbol: v.symbol,
amount_in: "0",
broker_quote: null,
soroswap_quote: null,
chosen: "none",
reason: "no_emissions",
executed_out: "0",
amount_out_min: "0",
slippage_bps: null,
uplift_bps: null,
tx_hash: claimRes.hash,
keeper: keeperPk,
status: "executed",
});
return;
}

// 2. Quote the real claimed amount on both venues, then decide.
const [broker, soroswap] = await Promise.all([
v.underlyingClassic ? brokerQuote(BLND_CLASSIC, v.underlyingClassic, claimed) : Promise.resolve(null),
soroswapQuote(claimed, [BLND_SOROBAN, v.underlyingSoroban]),
]);
const d = decide(broker, soroswap);
if (!d) throw new Error(`${v.symbol}: no executable quote for claimed ${claimed} BLND`);

// 3. Execute the chosen route.
let executedOut: bigint;
let txHash: string;

if (d.chosen === "broker") {
if (!v.underlyingClassic) throw new Error(`${v.symbol}: broker chosen without a classic underlying`);
// Fail fast (before pulling) if the on-chain swap_account — the holder of the
// BLND allowance set by harvest_claim — isn't this keeper. Otherwise the
// transfer_from below would revert; the just-claimed BLND stays safely in the
// strategy and a later harvest can reinvest it on-chain via Soroswap.
const onchainSwapAccount = await simReadAddress(v.strategyId, "swap_account");
if (onchainSwapAccount && onchainSwapAccount !== keeperPk) {
throw new Error(
`${v.symbol}: on-chain swap_account ${onchainSwapAccount} != keeper ${keeperPk}; cannot pull BLND for the broker route`,
);
}
// Pull the approved BLND from the strategy to the keeper account.
await invokeContract(keeper, BLND_SOROBAN, "transfer_from", [
addrScVal(keeperPk),
addrScVal(v.strategyId),
addrScVal(keeperPk),
i128ScVal(claimed),
]);
// Off-chain best-route swap BLND → underlying.
const bought = await brokerSwap(keeper, BLND_CLASSIC, v.underlyingClassic, claimed, SLIPPAGE);
if (bought < d.amountOutMin) {
throw new Error(`${v.symbol}: broker out ${bought} below slippage floor ${d.amountOutMin}`);
}
// Return the underlying to the strategy, then re-leverage it directly.
await invokeContract(keeper, v.underlyingSoroban, "transfer", [
addrScVal(keeperPk),
addrScVal(v.strategyId),
i128ScVal(bought),
]);
const r = await invokeContract(keeper, v.strategyId, "harvest_reinvest", [
addrScVal(keeperPk),
i128ScVal(bought),
boolScVal(false),
i128ScVal(0n),
]);
executedOut = bought;
txHash = r.hash;
} else {
// On-chain Soroswap swap + re-leverage, atomically inside the contract.
const r = await invokeContract(keeper, v.strategyId, "harvest_reinvest", [
addrScVal(keeperPk),
i128ScVal(claimed),
boolScVal(true),
i128ScVal(d.amountOutMin),
]);
executedOut = scValToBigInt(r.returnValue);
txHash = r.hash;
}

// 4. Telemetry: realised slippage vs the chosen venue's quote.
const chosenQuote = d.chosen === "broker" ? d.brokerQuote : d.soroswapQuote;
const slippageBps =
chosenQuote != null && chosenQuote > 0n && executedOut <= chosenQuote
? Number(((chosenQuote - executedOut) * 10000n) / chosenQuote)
: 0;

await logRoute({
network: "mainnet",
strategy_id: v.strategyId,
asset_symbol: v.symbol,
amount_in: claimed.toString(),
broker_quote: d.brokerQuote?.toString() ?? null,
soroswap_quote: d.soroswapQuote?.toString() ?? null,
chosen: d.chosen,
reason: d.reason,
executed_out: executedOut.toString(),
amount_out_min: d.amountOutMin.toString(),
slippage_bps: slippageBps,
uplift_bps: d.upliftBps,
tx_hash: txHash,
keeper: keeperPk,
status: "executed",
});
console.log(`[${v.symbol}] executed via ${d.chosen}: out=${executedOut} tx=${txHash}`);
}

// ── Per-vault processing ─────────────────────────────────────────────────────

async function processVault(v: Vault): Promise<void> {
async function processVault(v: Vault, keeper: Keypair | null): Promise<void> {
// Live settlement path (keeper-operated): claim, re-quote the real amount,
// execute the best route, log status='executed'.
if (EXECUTE && keeper && v.strategyId !== "QUOTE_ONLY") {
await executeHarvest(v, keeper);
return;
}

// Dry-run A/B data gathering: quote a nominal amount on both venues and log
// status='quote_only' (no signing, no on-chain writes). QUOTE_ONLY vaults stay
// here even under --execute (no real strategy id to settle against).
const amountIn = QUOTE_AMOUNT_BLND;
const [broker, soroswap] = await Promise.all([
v.underlyingClassic ? brokerQuote(BLND_CLASSIC, v.underlyingClassic, amountIn) : Promise.resolve(null),
Expand All @@ -180,7 +471,7 @@ async function processVault(v: Vault): Promise<void> {
const d = decide(broker, soroswap);
if (!d) { console.warn(`[${v.symbol}] no quotes available`); return; }

const base: Record<string, unknown> = {
await logRoute({
network: "mainnet",
strategy_id: v.strategyId,
asset_symbol: v.symbol,
Expand All @@ -191,41 +482,27 @@ async function processVault(v: Vault): Promise<void> {
reason: d.reason,
amount_out_min: d.amountOutMin.toString(),
uplift_bps: d.upliftBps,
keeper: KEEPER_SECRET ? Keypair.fromSecret(KEEPER_SECRET).publicKey() : null,
};

if (!EXECUTE || v.strategyId === "QUOTE_ONLY") {
await logRoute({ ...base, status: "quote_only" });
return;
}

// ── Execution path (dedicated Node keeper only) ──────────────────────────
// 1. harvest_claim(keeper) on the strategy → BLND claimed + approved to the
// swap account.
// 2. If chosen === 'broker': open a StellarBrokerClient session with the
// keeper Keypair, confirmQuote, receive underlying in the swap account,
// transfer it to the strategy, then harvest_reinvest(via_soroswap=false,
// amount_in=executed_out). If chosen === 'soroswap':
// harvest_reinvest(via_soroswap=true, amount_out_min).
// 3. Log status='executed' with executed_out, slippage_bps, tx_hash.
// Left as a guarded TODO: live signing + settlement is operated from the
// dedicated keeper service with the key behind a secrets manager / remote
// signer, not from this dry-run-first scaffold.
console.warn(`[${v.symbol}] --execute requested but live settlement is operated from the keeper service; logging quote_only.`);
await logRoute({ ...base, status: "quote_only" });
keeper: keeper ? keeper.publicKey() : null,
status: "quote_only",
});
}

// ── Main ─────────────────────────────────────────────────────────────────────

async function main(): Promise<void> {
console.log(`harvest_router — mode=${EXECUTE ? "EXECUTE" : "DRY-RUN"} slippage=${SLIPPAGE} router=${SOROSWAP_ROUTER}`);
if (EXECUTE && !KEEPER_SECRET) {
console.error("--execute requires KEEPER_SECRET");
process.exit(1);
let keeper: Keypair | null = null;
if (EXECUTE) {
if (!KEEPER_SECRET) {
console.error("--execute requires KEEPER_SECRET");
process.exit(1);
}
keeper = Keypair.fromSecret(KEEPER_SECRET);
console.log(`keeper=${keeper.publicKey()} broker_partner=${STELLAR_BROKER_PARTNER_KEY ? "set" : "unset"}`);
}
for (const v of VAULTS) {
try {
await processVault(v);
await processVault(v, keeper);
} catch (e) {
console.error(`[${v.symbol}] failed:`, (e as Error).message);
}
Expand Down
Loading