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
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { enterpriseOrgsRoutes } from "./routes/enterprise-orgs.js";
import { enterprisePoliciesRoutes } from "./routes/enterprise-policies.js";
import { enterpriseApprovalsRoutes } from "./routes/enterprise-approvals.js";
import { stateChannelRoutes } from "./routes/state-channels.js";
import { yieldVaultRoutes } from "./routes/yield-vaults.js";
import { getChatInfrastructure } from "./lib/chat-infrastructure.js";

const MAX_PAYMENTS_CACHE = 10000;
Expand Down Expand Up @@ -415,3 +416,4 @@ app.register(stateChannelRoutes, {
db: pgPool,
redis: undefined,
});
app.register(yieldVaultRoutes, { prefix: "/api/v1" });
34 changes: 34 additions & 0 deletions apps/api/src/db/migrations/010_add_yield_aggregation_vaults.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Automated Liquidity Reserve Rebalancing & Cross-Asset Yield Aggregation Vault (#408)
-- Sequential next migration after 009_add_enterprise_tenant_rbac.sql.
-- Issue description referenced 026_add_yield_aggregation_vaults.sql; this file
-- is intentionally 010 to keep the migrator sequential (same precedent as #401,
-- where the issue-referenced 019 became 009). See PR description.
--
-- yield_vault_configs : one row per settlement asset routed into an
-- external Soroban yield vault. liquid_buffer_ratio
-- is the dynamic instant-withdrawal reserve target.
-- provider_vault_shares : share-token position per provider per vault.
-- Share BALANCES only change on deposit/withdraw —
-- harvested yield raises the exchange rate instead
-- (invariant: rate never decreases during harvest).

CREATE TABLE IF NOT EXISTS yield_vault_configs (
vault_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_address VARCHAR(56) NOT NULL UNIQUE,
liquid_buffer_ratio NUMERIC(3,2) NOT NULL DEFAULT 0.20 CHECK (
liquid_buffer_ratio >= 0 AND liquid_buffer_ratio <= 1
),
current_tvl_stroops BIGINT NOT NULL DEFAULT 0 CHECK (current_tvl_stroops >= 0),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS provider_vault_shares (
provider_id VARCHAR(64) NOT NULL,
vault_id UUID NOT NULL REFERENCES yield_vault_configs(vault_id) ON DELETE CASCADE,
share_balance BIGINT NOT NULL DEFAULT 0 CHECK (share_balance >= 0),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (provider_id, vault_id)
);

CREATE INDEX IF NOT EXISTS idx_provider_vault_shares_vault ON provider_vault_shares (vault_id);
26 changes: 26 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { broadcastCircuitBreakerPause, readEscrowTokenBalance } from "./lib/stel
import { evaluateReserveConservation } from "./lib/invariant-checker.js";
import { createEnterpriseStore } from "./lib/enterprise-store.js";
import { startApprovalTimeoutWorker } from "./lib/workers/approvalTimeoutWorker.js";
import { startYieldRebalanceWorker } from "./lib/workers/yieldRebalanceWorker.js";
import { InMemoryStrategyAdapter } from "./lib/yield/strategy-adapter.js";

const port = Number(process.env.PORT ?? 3000);

Expand Down Expand Up @@ -108,6 +110,30 @@ async function startServer() {
} catch (error) {
app.log.error(error, "approval timeout worker failed to start");
}

// (#408) Yield rebalance: compound deployed collateral and keep the
// dynamic 20% liquid buffer topped up for instant settlements. Runs on
// the in-memory store until the pg-backed vault store is provisioned;
// disable with YIELD_REBALANCE_DISABLED=true.
if (process.env.YIELD_REBALANCE_DISABLED !== "true") {
try {
startYieldRebalanceWorker(
{
adapter: new InMemoryStrategyAdapter(),
logger: {
info: (obj, msg) => app.log.info(obj, msg),
warn: (obj, msg) => app.log.warn(obj, msg),
error: (obj, msg) => app.log.error(obj, msg),
},
},
process.env.YIELD_REBALANCE_POLL_MS
? { pollIntervalMs: Number(process.env.YIELD_REBALANCE_POLL_MS) }
: undefined,
);
} catch (error) {
app.log.error(error, "yield rebalance worker failed to start");
}
}
} catch (err) {
app.log.error(err);
process.exit(1);
Expand Down
70 changes: 70 additions & 0 deletions apps/api/src/lib/liquidity-netting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,74 @@ export function verifyNoUnbackedValue(
}
}
return true;
}

/* ------------------------------------------------------------------ */
/* Instant settlement draws against the yield-aggregated reserve */
/* (#408) */
/* ------------------------------------------------------------------ */

export interface InstantSettlementDrawParams {
/** Underlying the settlement needs right now (stroops). */
requiredStroops: bigint;
/** Unallocated balance held directly by the escrow (stroops). */
liquidReserveStroops: bigint;
/** Escrow underlying currently deployed into the yield vault (stroops). */
deployedToVaultStroops: bigint;
}

export interface InstantSettlementDrawPlan {
source: "BUFFER_ONLY" | "BUFFER_PLUS_VAULT_RECALL" | "INSUFFICIENT";
requiredStroops: bigint;
liquidReserveStroops: bigint;
/** Portion that must be recalled from the yield vault first. */
recallFromVaultStroops: bigint;
/** > 0 only when even a full recall cannot cover the requirement. */
shortfallStroops: bigint;
}

const ZERO = 0n;

/**
* Plan how an instant cash-trade settlement is funded without ever delaying
* the hand-off: draw from the liquid buffer first; whatever it lacks is
* flagged as a permissionless `recall_from_vault()` leg sized exactly to the
* gap (never more). Mirrors the on-chain recall in
* contracts/escrow/src/yield_vault.rs and is pure so the concurrency stress
* test can hammer it alongside the HTTP layer.
*/
export function planInstantSettlementDraw(
params: InstantSettlementDrawParams,
): InstantSettlementDrawPlan {
const { requiredStroops, liquidReserveStroops, deployedToVaultStroops } =
params;
if (requiredStroops < ZERO) {
throw new RangeError("requiredStroops must be non-negative");
}
if (liquidReserveStroops < ZERO || deployedToVaultStroops < ZERO) {
throw new RangeError("reserves must be non-negative");
}

if (liquidReserveStroops >= requiredStroops) {
return {
source: "BUFFER_ONLY",
requiredStroops,
liquidReserveStroops,
recallFromVaultStroops: ZERO,
shortfallStroops: ZERO,
};
}

const gap = requiredStroops - liquidReserveStroops;
const recallable =
deployedToVaultStroops < gap ? deployedToVaultStroops : gap;
const shortfall = gap - recallable;

return {
source: shortfall > ZERO ? "INSUFFICIENT" : "BUFFER_PLUS_VAULT_RECALL",
requiredStroops,
liquidReserveStroops,
recallFromVaultStroops: recallable,
shortfallStroops: shortfall,
};
}
15 changes: 15 additions & 0 deletions apps/api/src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export const RPC_TIMEOUTS = {
genericBuildSim: 15_000,
/** Generic poll budget used by submitSignedEnvelope. */
genericPoll: 30_000,
/** (#408) Quote / position read against a yield strategy adapter. */
vaultQuote: 10_000,
/** (#408) Deploy idle reserves into a yield vault (build+simulate). */
vaultDeploy: 15_000,
/** (#408) Instant recall from a yield vault (build+simulate). */
vaultRecall: 15_000,
} as const;

export const NETWORK_PASSPHRASE = IS_PUBLIC
Expand All @@ -106,6 +112,15 @@ export async function getLatestLedgerSequence(): Promise<number> {
return (await server.getLatestLedger()).sequence;
}

/**
* (#408) Contract ID of the escrow's external yield vault for the settlement
* token, when one has been deployed and configured. Placeholder-free: absent
* config means yield aggregation stays dormant rather than guessing.
*/
export function escrowYieldVaultContractId(): string | null {
return process.env.YIELD_VAULT_CONTRACT_ID ?? null;
}

/**
* Loads the deployer/buyer keypair — testnet-only.
*
Expand Down
84 changes: 84 additions & 0 deletions apps/api/src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export function clearStore() {
providersStore.clear();
globalH3SpatialIndex.clear();
globalOrderAllocator.clear();
// (#408) Reset yield-vault config/share mirrors too.
clearYieldStores();
}

export function saveProvider(record: ProviderRecord) {
Expand Down Expand Up @@ -307,3 +309,85 @@ export function logTimeoutIncident(
export function getTimeoutLogs(): TimeoutIncidentLog[] {
return Array.from(timeoutLogs);
}

/* ------------------------------------------------------------------ */
/* Yield aggregation vaults (#408) — in-memory dual of migration 010 */
/* ------------------------------------------------------------------ */

/**
* In-memory mirror of `yield_vault_configs`. `currentTvlStroops` is a string
* for JSON safety, matching amountStroops elsewhere in this store; the pg
* column is BIGINT.
*/
export interface YieldVaultConfigRecord {
vaultId: string;
assetAddress: string;
liquidBufferRatio: number;
currentTvlStroops: string;
/** Instantly withdrawable portion tracked by the route/worker layer. */
liquidStroops: string;
/** Last settled share exchange rate, scaled per YIELD_VAULT.EXCHANGE_RATE_SCALE. */
lastExchangeRateScaled: string;
}

/** In-memory mirror of `provider_vault_shares`. */
export interface ProviderVaultShareRecord {
providerId: string;
vaultId: string;
shareBalance: string;
}

const yieldVaultConfigs = new Map<string, YieldVaultConfigRecord>();
const providerVaultShares = new Map<string, ProviderVaultShareRecord>();

function providerShareKey(providerId: string, vaultId: string): string {
return `${providerId}\u0000${vaultId}`;
}

export function saveYieldVaultConfig(record: YieldVaultConfigRecord): void {
yieldVaultConfigs.set(record.vaultId, record);
}

export function getYieldVaultConfig(vaultId: string): YieldVaultConfigRecord | undefined {
return yieldVaultConfigs.get(vaultId);
}

export function getYieldVaultConfigByAsset(assetAddress: string): YieldVaultConfigRecord | undefined {
for (const config of yieldVaultConfigs.values()) {
if (config.assetAddress === assetAddress) return config;
}
return undefined;
}

export function listYieldVaultConfigs(): YieldVaultConfigRecord[] {
return Array.from(yieldVaultConfigs.values());
}

export function upsertProviderVaultShare(record: ProviderVaultShareRecord): void {
providerVaultShares.set(providerShareKey(record.providerId, record.vaultId), record);
}

export function getProviderVaultShare(
providerId: string,
vaultId: string,
): ProviderVaultShareRecord | undefined {
return providerVaultShares.get(providerShareKey(providerId, vaultId));
}

export function listProviderVaultShares(vaultId?: string): ProviderVaultShareRecord[] {
const all = Array.from(providerVaultShares.values());
return vaultId ? all.filter((share) => share.vaultId === vaultId) : all;
}

/** Total shares outstanding for a vault — the denominator of the exchange rate. */
export function totalSharesForVault(vaultId: string): bigint {
return listProviderVaultShares(vaultId).reduce(
(sum, share) => sum + BigInt(share.shareBalance),
0n,
);
}

export function clearYieldStores(): void {
yieldVaultConfigs.clear();
providerVaultShares.clear();
}
Loading
Loading