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
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,11 @@ NEXT_PUBLIC_ORACLE_ADDRESS=
ORACLE_SECRET_KEY=

# ── Liquidation Keeper (scripts/liquidation-keeper.ts) ────────────────────────
# Automated bot that liquidates under-collateralized loans. Deployed as a
# background worker that monitors every minute: vercel.json schedules
# POST /api/cron/liquidation on "* * * * *" (authenticated with Bearer CRON_SECRET,
# same as the other crons) — or run `npm run liquidation:keeper:service`
# Automated bot that liquidates under-collateralized loans. Vercel Hobby only
# permits daily crons, so vercel.json runs POST /api/cron/liquidation once a
# day as a safety net and .github/workflows/keepers.yml hits the same endpoint
# every 5 minutes (needs the KEEPER_BASE_URL + CRON_SECRET repo secrets) — or
# run `npm run liquidation:keeper:service`
# (--interval=60) self-hosted. One-shot `npm run liquidation:keeper` remains
# available for cron schedulers. Requires ADMIN_SECRET_KEY (above) to sign
# liquidation transactions, plus NEXT_PUBLIC_LENDING_CONTRACT_ID /
Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/keepers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Keepers (liquidation + price oracle)

# Vercel Hobby only allows one cron run per day, which is far too slow for the
# liquidation keeper and the collateral price oracle. GitHub Actions can fire
# every 5 minutes, so it pings the same authenticated cron endpoints instead.
#
# Required repository secrets:
# KEEPER_BASE_URL e.g. https://trustlend-stellar.vercel.app (no trailing slash)
# CRON_SECRET must match the CRON_SECRET env var configured on Vercel
#
# The workflow is a no-op until both secrets exist, so it is safe on forks.

on:
schedule:
- cron: "*/5 * * * *"
workflow_dispatch:

concurrency:
group: keepers
cancel-in-progress: false

jobs:
run:
name: Trigger cron endpoints
runs-on: ubuntu-latest
timeout-minutes: 5
env:
KEEPER_BASE_URL: ${{ secrets.KEEPER_BASE_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
steps:
- name: Skip when secrets are not configured
id: gate
run: |
if [ -z "$KEEPER_BASE_URL" ] || [ -z "$CRON_SECRET" ]; then
echo "KEEPER_BASE_URL / CRON_SECRET not set — nothing to do."
echo "configured=false" >> "$GITHUB_OUTPUT"
else
echo "configured=true" >> "$GITHUB_OUTPUT"
fi

- name: Liquidation keeper
if: steps.gate.outputs.configured == 'true'
run: |
curl --fail-with-body --silent --show-error --max-time 120 \
-X POST "$KEEPER_BASE_URL/api/cron/liquidation" \
-H "Authorization: Bearer $CRON_SECRET"

- name: Price oracle
if: steps.gate.outputs.configured == 'true'
run: |
curl --fail-with-body --silent --show-error --max-time 120 \
-X POST "$KEEPER_BASE_URL/api/cron/price-oracle" \
-H "Authorization: Bearer $CRON_SECRET"
Comment on lines +24 to +53
19 changes: 14 additions & 5 deletions __tests__/api/cron/liquidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,25 @@ describe("POST /api/cron/liquidation", () => {
});
});

// ── Acceptance criterion: the worker monitors prices every minute ──────────────
// vercel.json must schedule the liquidation cron on a 1-minute cadence.
// ── Scheduling ─────────────────────────────────────────────────────────────────
// Vercel Hobby rejects any cron that runs more than once a day, so vercel.json
// keeps a daily safety-net schedule and the 5-minute cadence lives in
// .github/workflows/keepers.yml. Both must keep pointing at this route.

describe("vercel.json liquidation schedule", () => {
it("schedules /api/cron/liquidation every minute (* * * * *)", () => {
describe("liquidation cron scheduling", () => {
it("vercel.json schedules /api/cron/liquidation once a day (Hobby-compatible)", () => {
const raw = fs.readFileSync(path.resolve(process.cwd(), "vercel.json"), "utf8");
const crons = (JSON.parse(raw) as { crons: Array<{ path: string; schedule: string }> }).crons;

const liquidationCron = crons.find((c) => c.path === "/api/cron/liquidation");
expect(liquidationCron).toBeDefined();
expect(liquidationCron?.schedule).toBe("* * * * *");
// "m h * * *" — exactly one run per day
expect(liquidationCron?.schedule).toMatch(/^\d{1,2} \d{1,2} \* \* \*$/);
});

it("the GitHub Actions keeper workflow triggers the route every 5 minutes", () => {
const raw = fs.readFileSync(path.resolve(process.cwd(), ".github/workflows/keepers.yml"), "utf8");
expect(raw).toContain(`cron: "*/5 * * * *"`);
expect(raw).toContain("/api/cron/liquidation");
});
});
5 changes: 3 additions & 2 deletions app/api/cron/liquidation/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { loadConfig, runLiquidationKeeper } from "@/scripts/liquidation-keeper";
/**
* POST/GET /api/cron/liquidation
*
* Automated Liquidation Bot (issue #259). Triggered by Vercel Cron every minute
* (`vercel.json`) or any external scheduler (GitHub Actions, cURL, systemd).
* Automated Liquidation Bot (issue #259). Triggered every 5 minutes by
* `.github/workflows/keepers.yml`, once a day by Vercel Cron (`vercel.json`,
* the Hobby-plan ceiling) or any external scheduler (cURL, systemd).
* Loads the keeper configuration from env, scans open loans for
* under-collateralization, and automatically submits `mark_defaulted` for any
* loan whose LTV has crossed the contract's dynamic liquidation threshold.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -881,18 +881,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -856,18 +856,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1055,18 +1055,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down Expand Up @@ -1194,18 +1194,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -834,18 +834,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1050,18 +1050,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down Expand Up @@ -1189,18 +1189,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,18 +323,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -856,18 +856,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -856,18 +856,18 @@
},
{
"key": {
"symbol": "freeze_reason"
"symbol": "flags"
},
"val": {
"string": ""
"u32": 2
}
},
{
"key": {
"symbol": "is_frozen"
"symbol": "freeze_reason"
},
"val": {
"bool": false
"string": ""
}
},
{
Expand Down
9 changes: 5 additions & 4 deletions contracts/usdc_lending_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,11 @@ impl UsdcLendingPool {
panic!("amount must be > 0");
}

// Transfer USDC from depositor → pool contract.
// Transfer USDC from depositor → pool contract. `depositor.require_auth()`
// above covers the token transfer, so no prior allowance is needed.
let usdc = Self::usdc_client(&env);
let contract_addr = env.current_contract_address();
usdc.transfer_from(&contract_addr, &depositor, &contract_addr, &amount);
usdc.transfer(&depositor, &contract_addr, &amount);

let current_ledger = env.ledger().sequence();

Expand Down Expand Up @@ -351,7 +352,7 @@ impl UsdcLendingPool {
// Use u128 intermediary to avoid i128 overflow on large principals.
let numerator = (principal as u128)
.saturating_mul(annual_yield_bps as u128)
.saturating_mul(ledgers_elapsed);
.saturating_mul(ledgers_elapsed as u128);
let denominator = MAX_BPS.saturating_mul(LEDGERS_PER_YEAR);
(numerator / denominator as u128) as i128
}
Expand All @@ -367,7 +368,7 @@ impl UsdcLendingPool {
}

/// Build a typed SEP-41 token client for the USDC contract.
fn usdc_client(env: &Env) -> token::TokenClient {
fn usdc_client(env: &Env) -> token::TokenClient<'_> {
let addr: Address = env
.storage()
.instance()
Expand Down
Loading
Loading