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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,38 @@ above.
| Redeem (request) | `request_redeem` | [`d6c02ee0…7524`](https://stellar.expert/explorer/testnet/tx/d6c02ee0868235fd12c280ba8c4e17fd6d63f4a117e9c48a897185c72dc97524) |
| Withdraw (claim cash) | `claim_redeem` | [`27a81778…db64`](https://stellar.expert/explorer/testnet/tx/27a8177877ecac5a4c87c1b4bed12e5f8664ca387f13fd9c05f8062ec68bdb64) |

## Redemption queue

The notice-period queue on testnet, against the contract IDs above, run by the
harness scenario [`redemption-queue.test.ts`](./scripts/harness/redemption-queue.test.ts)
on 2026-11-02. Repeat it on a seeded deployment:

```sh
HARNESS_NETWORK=testnet npm run seed # once: holders hold shares
HARNESS_NETWORK=testnet npm run test:queue -w scripts/harness # prints `tx <label> <hash>`
```

The treasury deploys the free reserve first, so the redemption that follows is
larger than the cash left in the vault. All steps are in epoch 3.

| Step | Account | Entrypoint | Transaction |
| --- | --- | --- | --- |
| Deploy the free reserve to the custodian (5,499 USDC) | treasury | `deploy_to_custodian` | [`93f5fad4…3c59`](https://stellar.expert/explorer/testnet/tx/93f5fad45b33751197ee31de6062a70dbff058d69a98fc176aa8f193a5c93c59) |
| Request a redemption (10 shares) | holder-2 | `request_redeem` | [`3c507d84…ee38`](https://stellar.expert/explorer/testnet/tx/3c507d84b1b5bc40d1fd4fc231731f28ccef7aaaaa126a3ec07e16a9315aee38) |
| Cancel it before the epoch closes | holder-2 | `cancel_redeem` | [`bd35fdcd…15c6`](https://stellar.expert/explorer/testnet/tx/bd35fdcd76cae0938b753926c1b2094a660c49b0199cbccab4479678ef6d15c6) |
| Request again (1,001 shares) | holder-2 | `request_redeem` | [`0b72ab37…4681`](https://stellar.expert/explorer/testnet/tx/0b72ab37c7057aae3fe5466d4dc906a14bf54db20a900535d7a3be24066e4681) |
| Seal the epoch | manager | `close_epoch` | [`d1f52849…7ec3`](https://stellar.expert/explorer/testnet/tx/d1f528498e377777bf3dc941c137dbaf6653cc0ff4821b9563fffa7797a77ec3) |
| Attest the NAV | attester | `attest` | [`14daff3d…3543`](https://stellar.expert/explorer/testnet/tx/14daff3d640ebb5b84ca71cc2ba9326b43a57d3f50855adb70bf657b710e3543) |
| Price the epoch | any account | `fulfill_epoch` | [`2f3b4564…9902`](https://stellar.expert/explorer/testnet/tx/2f3b4564afd92014d0db369a707e9f82eb0d16cd107e7c41a0b1afea9c689902) |
| Return the capital | custodian | `fund` | [`074cc4be…24fd`](https://stellar.expert/explorer/testnet/tx/074cc4be2972a3ff27f3ea6ea332fa3a97da04bd460ef39eaf8c12abb71924fd) |
| Claim the cash (1,001 USDC) | holder-2 | `claim_redeem` | [`8d169333…0eee`](https://stellar.expert/explorer/testnet/tx/8d169333d36a929616df933d64aa67b8e075a886d07b40d007b0d8696a180eee) |

Once the epoch is priced the vault owes 1,001 USDC more against 1,000 USDC of
liquid reserve, so `uncovered()` is above zero. The claim at that point is
refused with `ClaimNotCovered`. It fails at simulation and leaves no
transaction, so the evidence of the short reserve is the pricing transaction
before it and the `fund` that clears it.

## Vault figures

The [live dApp](https://strata-vault-kit-app.vercel.app) shows Withdrawable and
Expand Down
4 changes: 4 additions & 0 deletions scripts/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ CI runs these in the `js` job through the root `npm run test`.
# Run test suites
npm run test:happy-path
npm run test:guards

# The redemption queue on a seeded deployment (`npm run seed`); prints each
# transaction as `tx <label> <hash>`
npm run test:queue
```

`HARNESS_NETWORK` picks the network: `local` (the default) or `testnet`.
Expand Down
19 changes: 16 additions & 3 deletions scripts/harness/lib/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,29 @@ export const complianceContract = (c: Connected, as: Keypair) =>

interface Sendable<T> {
simulation?: unknown
signAndSend(): Promise<{ result: T }>
signAndSend(): Promise<{
result: T
sendTransactionResponse?: { hash: string }
}>
}

export async function send<T>(tx: Promise<Sendable<T>>): Promise<T> {
// A label makes the run print `tx <label> <hash>`, so a scenario can be
// linked to an explorer afterwards.
export async function send<T>(
tx: Promise<Sendable<T>>,
label?: string,
): Promise<T> {
const awaited = await tx
const simulation = awaited.simulation as { error?: string } | undefined
if (simulation?.error !== undefined) {
throw new Error(`simulation failed: ${simulation.error}`)
}
return (await awaited.signAndSend()).result
const sent = await awaited.signAndSend()
const hash = sent.sendTransactionResponse?.hash
if (label !== undefined && hash !== undefined) {
console.log(`tx ${label} ${hash}`)
}
return sent.result
}

export function usdcBalance(c: Connected, address: string): Promise<bigint> {
Expand Down
8 changes: 6 additions & 2 deletions scripts/harness/lib/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ export function navInput(nav: bigint): NavInput {
}

// The cooldown is ledger time, so the second attestation of a run waits it out.
export async function attest(c: Connected, nav: bigint): Promise<void> {
export async function attest(
c: Connected,
nav: bigint,
label?: string,
): Promise<void> {
const attester = c.keys.attester
const client = oracle(c, attester)
const deadline = Date.now() + (c.net.oracle.cooldown_secs + 60) * 1_000
Expand All @@ -46,7 +50,7 @@ export async function attest(c: Connected, nav: bigint): Promise<void> {
})
const result = outcome(tx, client)
if (result === "accepted") {
await send(Promise.resolve(tx))
await send(Promise.resolve(tx), label)
return
}
if (result !== "refused: CooldownActive") throw new Error(result)
Expand Down
1 change: 1 addition & 0 deletions scripts/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:happy-path": "vitest run happy-path.test.ts",
"test:guards": "vitest run guards.test.ts",
"test:wind-down": "vitest run wind-down.test.ts",
"test:queue": "vitest run redemption-queue.test.ts",
"seed": "tsx seed.ts"
},
"dependencies": {
Expand Down
212 changes: 212 additions & 0 deletions scripts/harness/redemption-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { beforeAll, describe, expect, it } from "vitest"

import { assetsFor, price, sharesFor, units } from "./lib/amounts.js"
import {
connect,
oracle,
send,
shareToken,
usdcBalance,
vault,
type Connected,
} from "./lib/clients.js"
import { waitForNotice } from "./lib/notice.js"
import { attest } from "./lib/oracle.js"
import { expectRefused } from "./lib/refused.js"

// The redemption queue on a seeded deployment (`npm run seed`): holder-2
// already holds shares and has no request in the open epoch. Every transaction
// prints `tx <label> <hash>`.
const c: Connected = connect()
const holder = c.keys["holder-2"]
const { custodian, manager, treasury, outsider } = c.keys

let decimals = 7
const cancelled = () => units(10, decimals)

const reader = vault(c, outsider)
const vaultId = c.deployment.contracts.async_vault
const shares = async (who: string) =>
(await shareToken(c, outsider).balance({ account: who })).result
const epochStatus = async (id: bigint) =>
(await reader.get_epoch({ epoch_id: id })).result?.status.tag
const request = async (who: string, id: bigint) =>
(await reader.get_redeem_request({ epoch_id: id, holder: who })).result

let nav = price(1)
let epoch = 0n
let deployed = 0n
let redeemed = 0n
let owed = 0n

beforeAll(async () => {
decimals = (await shareToken(c, outsider).decimals()).result
// The vault prices at the oracle's standing valuation, so the run attests it
// again rather than a price the deviation cap could refuse.
try {
nav = (await oracle(c, outsider).latest()).result.nav_per_share
} catch {
nav = price(1)
}
})

// A claim is refused only when the liquid reserve is below that claim, and
// the reserve already holds what earlier exits are owed. So the treasury
// deploys the free reserve first, and the redemption is sized above what is
// left. Pricing an epoch releases the deposits it holds into the reserve, so
// the open epoch must hold none.
describe("a reserve that cannot cover an exit", () => {
it("the treasury deploys the whole free reserve", async () => {
expect((await reader.uncovered()).result).toBe(0n)
expect(
(await reader.cancellable_escrow()).result,
"the open epoch holds deposits: seal and price it first",
).toBe(0n)
deployed = (await reader.free_reserve()).result
expect(deployed).toBeGreaterThan(0n)
await send(
vault(c, treasury).deploy_to_custodian({
caller: treasury.publicKey(),
assets: deployed,
}),
"deploy_to_custodian treasury",
)
expect((await reader.free_reserve()).result).toBe(0n)
})
})

describe("request and cancel", () => {
it("the holder has the shares and no request in the open epoch", async () => {
epoch = (await reader.current_epoch()).result
const liquid = (await reader.liquid_reserve()).result
redeemed = sharesFor(liquid, nav) + units(1, decimals)
expect(
await shares(holder.publicKey()),
`holder-2 needs ${redeemed + cancelled()} shares: run npm run seed first`,
).toBeGreaterThanOrEqual(redeemed + cancelled())
expect(await request(holder.publicKey(), epoch)).toBeFalsy()
})

it("the holder requests a redemption in the open epoch", async () => {
const before = await shares(holder.publicKey())
const vaultBefore = await shares(vaultId)
expect(
await send(
vault(c, holder).request_redeem({
holder: holder.publicKey(),
shares: cancelled(),
}),
"request_redeem holder-2",
),
).toBe(epoch)
expect(await shares(holder.publicKey())).toBe(before - cancelled())
expect(await shares(vaultId)).toBe(vaultBefore + cancelled())
})

it("the holder cancels before the epoch closes and the shares return", async () => {
const before = await shares(holder.publicKey())
expect(
await send(
vault(c, holder).cancel_redeem({
holder: holder.publicKey(),
epoch_id: epoch,
}),
"cancel_redeem holder-2",
),
).toBe(cancelled())
expect(await shares(holder.publicKey())).toBe(before + cancelled())
expect(await request(holder.publicKey(), epoch)).toBeFalsy()
})

it("refuses to cancel the same request twice", async () => {
const v = vault(c, holder)
expectRefused(
await v.cancel_redeem({ holder: holder.publicKey(), epoch_id: epoch }),
v,
"RequestNotFound",
)
})

it("the holder requests again, for more than the vault has left in cash", async () => {
const before = await shares(holder.publicKey())
expect(
await send(
vault(c, holder).request_redeem({
holder: holder.publicKey(),
shares: redeemed,
}),
"request_redeem holder-2 again",
),
).toBe(epoch)
expect(await shares(holder.publicKey())).toBe(before - redeemed)
})
})

describe("pricing", () => {
it("the manager seals the epoch", async () => {
expect(
await send(
vault(c, manager).close_epoch({ caller: manager.publicKey() }),
"close_epoch manager",
),
).toBe(epoch)
expect(await epochStatus(epoch)).toBe("Pending")
})

it("the attester prices it and an account with no role fulfils it", async () => {
await attest(c, nav, "attest attester")
await waitForNotice(c, reader, epoch)
expect(
await send(
vault(c, outsider).fulfill_epoch({ epoch_id: epoch }),
"fulfill_epoch outsider",
),
).toBe(nav)
expect(await epochStatus(epoch)).toBe("Fulfilled")
owed = assetsFor(redeemed, nav)
})
})

describe("reserve short", () => {
it("the redemption is owed but the vault cannot cover it", async () => {
const committed = (await reader.committed()).result
const liquid = (await reader.liquid_reserve()).result
expect(committed).toBeGreaterThanOrEqual(owed)
expect(liquid).toBeLessThan(owed)
expect((await reader.uncovered()).result).toBe(committed - liquid)
expect((await reader.uncovered()).result).toBeGreaterThan(0n)
})

it("the claim is refused, with nothing paid", async () => {
const v = vault(c, holder)
expectRefused(
await v.claim_redeem({ holder: holder.publicKey(), epoch_id: epoch }),
v,
"ClaimNotCovered",
)
})

it("fund returns the capital and uncovered falls to zero", async () => {
await send(
vault(c, custodian).fund({
caller: custodian.publicKey(),
assets: deployed,
}),
"fund custodian",
)
expect((await reader.uncovered()).result).toBe(0n)
})

it("the holder claims the cash", async () => {
const before = await usdcBalance(c, holder.publicKey())
const paid = await send(
vault(c, holder).claim_redeem({
holder: holder.publicKey(),
epoch_id: epoch,
}),
"claim_redeem holder-2",
)
expect(paid).toBe(owed)
expect(await usdcBalance(c, holder.publicKey())).toBe(before + paid)
})
})
Loading