Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Public testnet-only keys for the e2e runbook accounts
scripts/harness/people.testnet.json:generic-api-key:4
scripts/harness/people.testnet.json:generic-api-key:9
scripts/harness/people.testnet.json:generic-api-key:14
scripts/harness/people.testnet.json:generic-api-key:19
92 changes: 92 additions & 0 deletions E2E-TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# E2E testing on testnet

A fresh deploy of `main` (`45e0e23`) on Stellar testnet, with one account per person to run the runbook by hand.

These keys are **testnet only**. They have no real value and are public on purpose. Never use them on mainnet.

- Network: Stellar testnet (`https://soroban-testnet.stellar.org`, passphrase `Test SDF Network ; September 2015`)
- Deploy: 2026-10-20T19:02:22.970Z (ledger 5299871)

## People

Import the secret into a wallet (for example Freighter, on testnet). Each account has about 10,000 XLM, a USDC trustline and 100,000 USDC, and is on the allowlist, so it can also act as a holder.

| Person | Roles | Public key | Secret key |
| --- | --- | --- | --- |
| **Maxi** | governance (admin of the vault, the oracle and the share token)<br>USDC admin (can mint) | `GD4E4AI7LVHIRX7QXAHQGRPABC6NFWFKHKJ6ZUAQNIGELVVN7MSFJWLF` | `SDHNHV6MEJP26WLCHC6F27XRLJ6H34R33KGKTQFECS7PYVQVB3RW2CE4` |
| **Pablo** | manager<br>guardian (vault and oracle) | `GBE43ELIMD3CUR6WMAMTCMI5UWXNFWX3GWJ3RSSIKUGJGTSRHF5X645V` | `SBW54VWPHJTS4O2VVTU4PTV5KNJZR4BXM4QOMFJAKSAC4BQM2GRQ2MDG` |
| **Luciano** | attester<br>treasury<br>custodian | `GCBLIOHMFFYK3GORQL2JIILW2YOGIGCDM6SUKX6RVF4U66E2XTXX42KW` | `SAOAAERXFPPJP4L7EUVECIHLOXIIVALC2CDD7OMIERCKWMQJ3247XUQT` |
| **Iñaky** | compliance (admin of the identity verifier, compliance authority of the share token)<br>outsider | `GCFUDBW2N26PBMMAMI3RVTA5O2WOGJO7GCH6E4PNFAVQNRVW2YEOELXE` | `SBD4TKZWYGHJDUS237S53UEJB45QO6WUOXRABUUNUYHLKNMCYTDLCK5G` |

Luciano is both treasury and custodian: `deploy_to_custodian` sends the USDC to Luciano's own account.

## Who signs what

| Action | Signer |
| --- | --- |
| `request_deposit`, `request_redeem`, `claim_*`, cancellations | anyone (every person is a holder) |
| `close_epoch` | Pablo |
| Attest the NAV on the oracle | Luciano |
| `fulfill_epoch` | anyone (no permission needed); Iñaky as outsider |
| `deploy_to_custodian` | Luciano (treasury) |
| `fund` (return USDC to the vault) | Luciano as custodian; in fact anyone can |
| Pause / unpause the vault or the oracle | Pablo |
| Allowlist, freeze, forced transfers | Iñaky |
| Parameters, roles, custodian, upgrades (timelock), wind-down | Maxi |
| Mint USDC (`mint` on the USDC contract) | Maxi |

## Initial state

- Epoch #1 **open**, with no requests.
- No NAV attested on the oracle yet.
- Vault custodian: Luciano.
- The vault holds no funds.

## Contracts

| Contract | Address |
| --- | --- |
| `compliance` | `CCCA6XDCXBZWWTIDYGLTL5GEHROCZTGYAIRAFA2KBO2JAWOJFU3TMZCB` |
| `identity_verifier` | `CDMV7DGGLGIWDWI4ERIBZ5YS2FZA3QVZPVUWCAWR5VBLVJMLFYCW6DML` |
| `share_token` | `CCB23VF4CSI7QNWNDOAAAVOKOLWO5EEQFZIODWEXHS6OB3MMHGFZ3VA6` |
| `nav_oracle` | `CDDRBLB3GHZGKHGFATZBKFSXC6XRVFLHNPVWHTIE67FVNDYHU2RRRNRO` |
| `async_vault` | `CAGHFSATIJHNDXQA577APGG6GF2L7Q7KP6H2IUXVW6IT3CJ2OBE3QCEB` |
| `asset` (USDC SAC) | `CAU7LDCUCLAWSJZS72B7VIPIE7FSN3T63D7MZMSSXTNLKYDYECVGH522` |

USDC: code `USDC`, issuer `GCUYFZRTNQ6WIIN5XBEYWVENKNVCBPK5BRXK4PDBCOGCYA4BZIRGMVIN`. The issuer handed the USDC contract's admin to Maxi, so Maxi is the one who mints.

If your wallet shows two USDC assets, use this issuer's. The other one is from an earlier deploy.

## Harness accounts

The scripts create them. The holders work as extra investors in the runbook.

| Account | Purpose | Public key | Secret key |
| --- | --- | --- | --- |
| deployer | Deployed the contracts. No role afterwards. | `GC7UTBTYBYIXKBX3DWURKQQJNDKM35FEBIHJN63NT5OZIJRWFUP3VEC5` | `SBTOLGY2AMHDJSDTZRMSWOVOFN236M56YO57ZIA5WTRA7QCSIJEVWUQF` |
| issuer | Issuer of the test USDC. No longer the contract's admin; Maxi mints. | `GCUYFZRTNQ6WIIN5XBEYWVENKNVCBPK5BRXK4PDBCOGCYA4BZIRGMVIN` | `SBKDWFFTWAXKKZEIBG3XREEZ7KFHIUGKXNMOKLAJIW44HUUEB46GAYZA` |
| holder-1 | Holder: 100,000 USDC, trustline and allowlist. | `GB6GENHQQWI3PRD6MVDIRO72VMIJCWENMHRSJU3Z53R372K5ZPHT7ILL` | `SB5RVYBJXKNOWMNBBXGWUHYJRKW75PY7PSX56GHPKXTOB7NW22MXYMHD` |
| holder-2 | Holder: 100,000 USDC, trustline and allowlist. | `GDJ7VTRMJ5XGALSIJDV4V6PBZRQEZZ6NUQOFUDW6TNYOMZMSL536XNAK` | `SASZ5BFK2W6LOJR4UNVUHPICW4VRDYSATCQQAOF7ISPO3OZSKTTF6NVQ` |
| holder-3 | Holder: 100,000 USDC, trustline and allowlist. | `GA6PAYUT232UAQKUYJ7QWF27JQWTKOVNRUHAF6B4CI3QT3XFKQAKSTXD` | `SAR27ZG7NEIKNBIC3U2ZFEP2MUKOOLJAPFZJXT4R2ZKIGOM5GO6T3EFX` |

## Redeploy

The roles come from `scripts/harness/people.testnet.json`. If it exists, `deploy` uses those keys for the roles it lists, and new keys for the rest.

```sh
stellar contract build
cd scripts/harness
HARNESS_NETWORK=testnet npm run deploy # new contracts, the same people's keys
HARNESS_NETWORK=testnet npm run onboard # trustline, USDC and allowlist for each person; custodian
```

For Maxi to mint, the issuer hands over the USDC contract's admin (the values come from `deployed.testnet.json` and `people.testnet.json`):

```sh
stellar contract invoke --network testnet --id <asset> --source-account <issuer secret> -- set_admin --new_admin <Maxi public>
stellar contract invoke --network testnet --id <asset> --source-account <Maxi secret> -- mint --to <holder> --amount 1000000000000 # 100,000 USDC (7 decimals)
```

holder-1..3 need a trustline and the allowlist before the mint.

The contracts change on every deploy, so update this file's contract table afterwards.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ deploy from `environments.toml` → `[staging.contracts]`, which is the record.
<!-- deployed-addresses:start -->
| Contract | Address |
| --- | --- |
| `async_vault` | `CAXGUAAIZOCM6FIQ6PRTQEBBZG62GMN422TNT6N6UMIYY4HGFLJDUFCV` |
| `nav_oracle` | `CBYVXBRLCKJTH56FVIZFBX7XUIBOLJOROP2DM4NYA4V6FOTTFZO7VJPL` |
| `share_token` | `CCBANVMWNZXZ46WMV7N7EDGBPW3LIFOXZLILIOTRDTE63H4QXBM7XVYF` |
| `identity_verifier` | `CBLCGV3YT5AM7ZQNFEIEX3FLA3ZY3XDVW3U2YQO4ZWSJVI47763LLL5T` |
| `compliance` | `CA45ZSCHMNUMJEF2UDUT4UMCKHHMEG3LYYIB7QBK6LG2NIC7JYZ7XKRA` |
| `asset` | `CCTSSUV5XYKFEMT44SJBS2GUNBEMMRUQXLK422RAJMPTGLF7WV6ALDKR` |
| `async_vault` | `CAGHFSATIJHNDXQA577APGG6GF2L7Q7KP6H2IUXVW6IT3CJ2OBE3QCEB` |
| `nav_oracle` | `CDDRBLB3GHZGKHGFATZBKFSXC6XRVFLHNPVWHTIE67FVNDYHU2RRRNRO` |
| `share_token` | `CCB23VF4CSI7QNWNDOAAAVOKOLWO5EEQFZIODWEXHS6OB3MMHGFZ3VA6` |
| `identity_verifier` | `CDMV7DGGLGIWDWI4ERIBZ5YS2FZA3QVZPVUWCAWR5VBLVJMLFYCW6DML` |
| `compliance` | `CCCA6XDCXBZWWTIDYGLTL5GEHROCZTGYAIRAFA2KBO2JAWOJFU3TMZCB` |
| `asset` | `CAU7LDCUCLAWSJZS72B7VIPIE7FSN3T63D7MZMSSXTNLKYDYECVGH522` |
<!-- deployed-addresses:end -->

## Testnet flow verification
Expand Down
12 changes: 6 additions & 6 deletions environments.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,12 @@ default = true
# underlying Stellar Asset Contract and has no crate here; it still gets a
# generated client, because a SAC carries its own spec.
[staging.contracts]
async_vault = { id = "CAXGUAAIZOCM6FIQ6PRTQEBBZG62GMN422TNT6N6UMIYY4HGFLJDUFCV" }
nav_oracle = { id = "CBYVXBRLCKJTH56FVIZFBX7XUIBOLJOROP2DM4NYA4V6FOTTFZO7VJPL" }
share_token = { id = "CCBANVMWNZXZ46WMV7N7EDGBPW3LIFOXZLILIOTRDTE63H4QXBM7XVYF" }
identity_verifier = { id = "CBLCGV3YT5AM7ZQNFEIEX3FLA3ZY3XDVW3U2YQO4ZWSJVI47763LLL5T" }
compliance = { id = "CA45ZSCHMNUMJEF2UDUT4UMCKHHMEG3LYYIB7QBK6LG2NIC7JYZ7XKRA" }
asset = { id = "CCTSSUV5XYKFEMT44SJBS2GUNBEMMRUQXLK422RAJMPTGLF7WV6ALDKR" }
async_vault = { id = "CAGHFSATIJHNDXQA577APGG6GF2L7Q7KP6H2IUXVW6IT3CJ2OBE3QCEB" }
nav_oracle = { id = "CDDRBLB3GHZGKHGFATZBKFSXC6XRVFLHNPVWHTIE67FVNDYHU2RRRNRO" }
share_token = { id = "CCB23VF4CSI7QNWNDOAAAVOKOLWO5EEQFZIODWEXHS6OB3MMHGFZ3VA6" }
identity_verifier = { id = "CDMV7DGGLGIWDWI4ERIBZ5YS2FZA3QVZPVUWCAWR5VBLVJMLFYCW6DML" }
compliance = { id = "CCCA6XDCXBZWWTIDYGLTL5GEHROCZTGYAIRAFA2KBO2JAWOJFU3TMZCB" }
asset = { id = "CAU7LDCUCLAWSJZS72B7VIPIE7FSN3T63D7MZMSSXTNLKYDYECVGH522" }

### Production environment configuration
# Commented out on purpose. Mainnet needs an external audit and an audited
Expand Down
15 changes: 12 additions & 3 deletions scripts/harness/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type Deployment,
} from "./lib/deployment.js"
import { network, type Network } from "./lib/network.js"
import { assignedKeys, loadPeople } from "./lib/people.js"

const wasmOf = (name: ContractName): string =>
join(REPO_ROOT, "target", "wasm32v1-none", "release", `${name}.wasm`)
Expand Down Expand Up @@ -198,9 +199,17 @@ async function main(): Promise<void> {
const server = new rpc.Server(net.rpcUrl, { allowHttp: net.name === "local" })
console.log(`deploying to ${net.name} (${net.rpcUrl})`)

const accounts = freshAccounts()
await fund(net, server, Object.values(accounts))
console.log(`funded ${Object.keys(accounts).length} accounts`)
const accounts: Accounts = {
...freshAccounts(),
...assignedKeys(loadPeople(net.name)),
}
const distinct = [
...new Map(
Object.values(accounts).map((key) => [key.publicKey(), key]),
).values(),
]
await fund(net, server, distinct)
console.log(`funded ${distinct.length} accounts`)

const asset = new Asset("USDC", accounts.issuer.publicKey())
const assetContract = await deployAssetContract(net, asset, accounts.issuer)
Expand Down
103 changes: 103 additions & 0 deletions scripts/harness/lib/holders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { type Keypair } from "@stellar/stellar-sdk"

import { pay, trustline } from "./accounts.js"
import { units } from "./amounts.js"
import {
identityVerifier,
send,
usdcBalance,
type Connected,
} from "./clients.js"

export function formatAmount(amount: bigint, decimals: number): string {
const factor = 10n ** BigInt(decimals)
const whole = amount / factor
const fraction = amount % factor
return `${whole}.${fraction.toString().padStart(decimals, "0").slice(0, 4)}`
}

async function hasTrustline(c: Connected, address: string): Promise<boolean> {
try {
await c.server.getAssetBalance(address, c.asset, c.net.passphrase)
return true
} catch (error) {
if (
error instanceof Error &&
/Trustline for .* not found for/.test(error.message)
) {
return false
}
throw error
}
}

export async function ensureTrustline(
c: Connected,
holder: Keypair,
name: string,
): Promise<void> {
const exists = await hasTrustline(c, holder.publicKey())
if (!exists) {
console.log(` + establishing USDC trustline for ${name}...`)
await trustline(c.net, c.server, holder, c.asset)
console.log(` ✓ established trustline for ${name}`)
} else {
console.log(` • ${name} trustline verified`)
}
}

export async function ensureFunding(
c: Connected,
holder: Keypair,
name: string,
minUsdcWhole: number,
topUpUsdcWhole: number,
decimals: number,
): Promise<void> {
const minBalance = units(minUsdcWhole, decimals)
const current = await usdcBalance(c, holder.publicKey())
if (current < minBalance) {
console.log(
` + funding ${name} with ${topUpUsdcWhole} USDC (current: ${formatAmount(current, decimals)})...`,
)
await pay(
c.net,
c.server,
c.keys.issuer,
holder.publicKey(),
c.asset,
topUpUsdcWhole.toString(),
)
const updated = await usdcBalance(c, holder.publicKey())
console.log(
` ✓ funded ${name}, new balance: ${formatAmount(updated, decimals)} USDC`,
)
} else {
console.log(
` • ${name} has sufficient USDC: ${formatAmount(current, decimals)}`,
)
}
}

export async function ensureAllowlist(
c: Connected,
holder: Keypair,
name: string,
): Promise<void> {
const verifier = identityVerifier(c, c.keys.compliance)
const allowed = (await verifier.is_allowed({ account: holder.publicKey() }))
.result
if (!allowed) {
console.log(` + allowlisting ${name}...`)
await send(
verifier.allow({
account: holder.publicKey(),
allowed: true,
caller: c.keys.compliance.publicKey(),
}),
)
console.log(` ✓ ${name} allowlisted`)
} else {
console.log(` • ${name} is already allowlisted`)
}
}
48 changes: 48 additions & 0 deletions scripts/harness/lib/people.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"

import { Keypair } from "@stellar/stellar-sdk"

import { ACCOUNT_NAMES, type AccountName } from "./accounts.js"
import { PACKAGE_ROOT } from "./deployment.js"
import { type NetworkName } from "./network.js"

export interface Person {
public: string
secret: string
roles: AccountName[]
}

export type People = Record<string, Person>

export function peopleFile(network: NetworkName): string {
return join(PACKAGE_ROOT, `people.${network}.json`)
}

export function loadPeople(network: NetworkName): People {
const file = peopleFile(network)
if (!existsSync(file)) return {}
const people = JSON.parse(readFileSync(file, "utf8")) as People
const taken = new Map<string, string>()
for (const [name, person] of Object.entries(people)) {
for (const role of person.roles) {
if (!ACCOUNT_NAMES.includes(role)) {
throw new Error(`${name} has unknown role ${role}`)
}
const holder = taken.get(role)
if (holder) throw new Error(`${role} given to both ${holder} and ${name}`)
taken.set(role, name)
}
}
return people
}

export function assignedKeys(
people: People,
): Partial<Record<AccountName, Keypair>> {
return Object.fromEntries(
Object.values(people).flatMap((person) =>
person.roles.map((role) => [role, Keypair.fromSecret(person.secret)]),
),
)
}
46 changes: 46 additions & 0 deletions scripts/harness/onboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Keypair } from "@stellar/stellar-sdk"

import { connect, send, shareToken, vault } from "./lib/clients.js"
import {
ensureAllowlist,
ensureFunding,
ensureTrustline,
} from "./lib/holders.js"
import { loadPeople, peopleFile } from "./lib/people.js"

const MIN_USDC = 10_000
const TOP_UP_USDC = 50_000

async function main(): Promise<void> {
const c = connect()
const people = loadPeople(c.net.name)
if (Object.keys(people).length === 0) {
throw new Error(`no people in ${peopleFile(c.net.name)}`)
}
const decimals = (await shareToken(c, c.keys.outsider).decimals()).result

for (const [name, person] of Object.entries(people)) {
const key = Keypair.fromSecret(person.secret)
console.log(`${name} (${person.roles.join(", ")})`)
await ensureTrustline(c, key, name)
await ensureFunding(c, key, name, MIN_USDC, TOP_UP_USDC, decimals)
await ensureAllowlist(c, key, name)
}

const reader = vault(c, c.keys.outsider)
const wanted = c.keys.custodian.publicKey()
if ((await reader.custodian()).result !== wanted) {
await send(
vault(c, c.keys.governance).set_custodian({
custodian: wanted,
caller: c.keys.governance.publicKey(),
}),
)
}
console.log(`vault custodian: ${wanted}`)
}

main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
3 changes: 2 additions & 1 deletion scripts/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"typecheck": "tsc --noEmit",
"test:happy-path": "vitest run happy-path.test.ts",
"test:guards": "vitest run guards.test.ts",
"seed": "tsx seed.ts"
"seed": "tsx seed.ts",
"onboard": "tsx onboard.ts"
},
"dependencies": {
"@stellar/stellar-sdk": "16.1.0"
Expand Down
22 changes: 22 additions & 0 deletions scripts/harness/people.testnet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"Maxi": {
"public": "GD4E4AI7LVHIRX7QXAHQGRPABC6NFWFKHKJ6ZUAQNIGELVVN7MSFJWLF",
"secret": "SDHNHV6MEJP26WLCHC6F27XRLJ6H34R33KGKTQFECS7PYVQVB3RW2CE4",
"roles": ["governance"]
},
"Pablo": {
"public": "GBE43ELIMD3CUR6WMAMTCMI5UWXNFWX3GWJ3RSSIKUGJGTSRHF5X645V",
"secret": "SBW54VWPHJTS4O2VVTU4PTV5KNJZR4BXM4QOMFJAKSAC4BQM2GRQ2MDG",
"roles": ["manager", "guardian"]
},
"Luciano": {
"public": "GCBLIOHMFFYK3GORQL2JIILW2YOGIGCDM6SUKX6RVF4U66E2XTXX42KW",
"secret": "SAOAAERXFPPJP4L7EUVECIHLOXIIVALC2CDD7OMIERCKWMQJ3247XUQT",
"roles": ["attester", "treasury", "custodian"]
},
"Iñaky": {
"public": "GCFUDBW2N26PBMMAMI3RVTA5O2WOGJO7GCH6E4PNFAVQNRVW2YEOELXE",
"secret": "SBD4TKZWYGHJDUS237S53UEJB45QO6WUOXRABUUNUYHLKNMCYTDLCK5G",
"roles": ["compliance", "outsider"]
}
}
Loading
Loading