Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 4 additions & 3 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 Down
Loading
Loading