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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message

## [Unreleased]

## [0.3.1] - 2026-09-16

### Fixed

- **A venue being read no longer reports itself as holding nothing.** For the seconds a venue takes to answer — a wallet spread over nine chains is the slow one — the status line said `0 positions` and the `/` menu said `0 tokens`. Both now say the venue is being read, and say `not read` with the command to run if the read failed rather than leaving a spinner over nothing. The same gap covered a venue connected after the shell opened: the store held it at once, the book did not, and every surface read the difference as an empty account.
- **The line that says what is being read counts its parts.** `reading wallet` sat unchanged for the whole read, which is what a hang looks like; it now reads `reading wallet · 4 of 9 chains`, counting a chain that failed as one no longer being waited for.
- **`/shock`'s asset list is offered again to anyone holding a key for a venue this build dropped.** It answered "nothing is read yet" for the rest of the session, and the `/refresh` it suggested could never make it true.
- **The model is never handed a date for a book it has not read.** Asked before the first read, `fetched_at` stated the epoch as a fact.

## [0.3.0] - 2026-09-16

### Added
Expand Down Expand Up @@ -344,7 +353,8 @@ what breaks first.
- `KeyScope` is tri-state. Kraken exposes no endpoint reporting a key's permissions, and every endpoint gated on trade permission mutates an order, so `canTrade` is `unknown` rather than guessed at. Withdraw scope is provable, and is proven.
- Kraken margin and open orders are not read yet, so on a margin account this is not a complete Kraken picture.

[Unreleased]: https://github.com/hsnice16/tula/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/hsnice16/tula/compare/v0.3.1...HEAD
[0.3.1]: https://github.com/hsnice16/tula/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/hsnice16/tula/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/hsnice16/tula/compare/v0.1.3...v0.2.0
[0.1.3]: https://github.com/hsnice16/tula/compare/v0.1.2...v0.1.3
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ the sigstore-backed attestation proving this repository's release workflow built
it wherever the GitHub CLI can — saying so either way. Check one by hand:

```bash
gh attestation verify tula-v0.3.0-darwin-arm64.tar.gz --repo hsnice16/tula \
gh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula \
--signer-workflow hsnice16/tula/.github/workflows/release.yml
```

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ of them mentioned in no file at all.
| What | Where |
|---|---|
| Aave V4 — the Hubs on Ethereum | [`breadth/08`](./tasks/breadth/08-aave-v4.md) |
| The Safety Module, isolation mode, stable-rate debt | [`breadth/09`](./tasks/breadth/09-aave-depth.md) |
| Umbrella and the legacy Safety Module, isolation mode | [`breadth/09`](./tasks/breadth/09-aave-depth.md) |
| Staking and yield-bearing tokens, NFTs | [`breadth/11`](./tasks/breadth/11-wallet-depth.md) |
| The EVM chains outside the nine, HyperEVM, Solana | [`breadth/12`](./tasks/breadth/12-chain-reach.md) |
| Binance's cross-margin liquidation level, COIN-M and Portfolio Margin, earn products, held balances, sub-accounts | [`breadth/13`](./tasks/breadth/13-binance-depth.md) |
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ keyless, so there is no signing key for this project to generate, publish, rotat
or lose.

```bash
gh attestation verify tula-v0.3.0-darwin-arm64.tar.gz --repo hsnice16/tula \
gh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula \
--signer-workflow hsnice16/tula/.github/workflows/release.yml
```

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hsnice16/tula",
"version": "0.3.0",
"version": "0.3.1",
"description": "Your true exposure, what breaks first, and more, across every venue at once.",
"license": "MIT",
"type": "module",
Expand Down
23 changes: 23 additions & 0 deletions scripts/conformance.live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,29 @@ async function aave(): Promise<Finding[]> {
],
})

// The stable-rate gap was retired in 0.3.1, and `breadth/09` says a retirement
// needs the check that proves it rather than a reading of a changelog. Aave
// v3.2 removed every Pool function for stable-rate mode and stopped
// instantiating a stable debt token on a new listing, so the proof is that the
// interface itself no longer carries one. `IPool.sol` is the file that would
// have to grow it back.
const poolApi = await request(`${ADDRESS_BOOK_RAW}/../lib/aave-v3-origin/src/contracts/interfaces/IPool.sol`, followed, DEADLINE_MS)
const poolSource = poolApi.ok ? await poolApi.text() : ''
const stableFns = ['swapBorrowRateMode', 'rebalanceStableBorrowRate'].filter((fn) => poolSource.includes(fn))
findings.push({
verdict: poolSource === '' ? 'unreachable' : stableFns.length === 0 ? 'holds' : 'contradicted',
belief: 'Aave states no stable-rate borrowing, so there is no gap left to declare about it',
lines: [
poolSource === ''
? 'Could not read IPool.sol; nothing here checked the retirement.'
: stableFns.length === 0
? 'IPool carries neither swapBorrowRateMode nor rebalanceStableBorrowRate: the mode is gone ' +
'from the interface, and a gap naming it would describe a product the venue does not offer.'
: `IPool still carries ${stableFns.join(' and ')}. Stable-rate borrowing is reachable again — ` +
'restore the declared gap in aave.ts before the next release.',
],
})

// One check per chain tula actually reads, because "the pool moved" and "there
// is a market here we never call" are different failures and only the second
// scales with the chain list. `DEPLOYMENTS` is the connector's own list, so
Expand Down
2 changes: 1 addition & 1 deletion scripts/guard-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ command -v bun >/dev/null 2>&1 || {
fail=1
}
if command -v bun >/dev/null 2>&1; then
awk '{print} /^ fetched_at: at\(f.loadedAt\),$/ {print " probe_unmarked: f.failures[0] ?? null,"}' \
awk '{print} /^ failed_venues: f.failures.map\(untrusted\),$/ {print " probe_unmarked: f.failures[0] ?? null,"}' \
"$TOOLS_SAVED" >"$TOOLS"
if ! grep -qF 'probe_unmarked' "$TOOLS"; then
# The anchor moved, so the probe planted nothing and the check below would
Expand Down
2 changes: 1 addition & 1 deletion site/app/install/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ export default function Page() {
</div>
<Terminal title="verify">
{
"curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.3.0/tula-v0.3.0-darwin-arm64.tar.gz\ngh attestation verify tula-v0.3.0-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml"
"curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.3.1/tula-v0.3.1-darwin-arm64.tar.gz\ngh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml"
}
</Terminal>
<p className="mt-4 text-dim">
Expand Down
2 changes: 1 addition & 1 deletion site/app/kraken/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Terminal } from '@/components/Terminal'
const PATH = '/kraken'
const TITLE = 'Kraken read-only API key for portfolio tracking'
const SUMMARY =
'Which Kraken API key permissions to turn on so a tool can read balances but never withdraw, and what tula refuses.'
'Which Kraken API key permissions to turn on so a tool can read balances but never trade or withdraw, and what tula refuses.'

export const metadata = guideMetadata(PATH, TITLE, SUMMARY)

Expand Down
2 changes: 1 addition & 1 deletion site/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const PEERLIST = {
* them without handing their taps to the next.
*/
const LINK =
'flex min-h-11 items-center text-dim underline decoration-rule decoration-dotted underline-offset-4 hover:text-accent'
'flex min-h-8 items-center text-dim underline decoration-rule decoration-dotted underline-offset-4 hover:text-accent'

function Column({
title,
Expand Down
2 changes: 1 addition & 1 deletion site/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const NAME = 'tula'
* the two disagree and `release-cut.sh` bumps this with them: a frame offered
* as the tool's own output cannot print a release that was never cut.
*/
export const VERSION = '0.3.0'
export const VERSION = '0.3.1'

/**
* GA4, for the site alone. Held here rather than read from `process.env`: an
Expand Down
3 changes: 2 additions & 1 deletion src/agent/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@ export interface RiskEngine {
*/
coverage(): Disclosure
venues(): VenueStatus[]
freshness(): { oldest: Date | null; loadedAt: Date; failures: string[]; priceError: string | null }
/** `loadedAt` is null before any load: the model quotes figures verbatim, so an unread book must not carry a date. */
freshness(): { oldest: Date | null; loadedAt: Date | null; failures: string[]; priceError: string | null }
}
2 changes: 1 addition & 1 deletion src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ export function executeTool(engine: RiskEngine, name: string, input: unknown): u
return seal({
venues,
oldest_data: f.oldest === null ? null : at(f.oldest),
fetched_at: at(f.loadedAt),
fetched_at: f.loadedAt === null ? null : at(f.loadedAt),
failed_venues: f.failures.map(untrusted),
price_error: marked(f.priceError),
// The whole of what no other result carries. Three different
Expand Down
5 changes: 4 additions & 1 deletion src/cli/engine-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ export function riskEngineFor(session: Session): RiskEngine {

freshness: () => ({
oldest: session.stalest(),
loadedAt: session.current.loadedAt,
// `EMPTY.loadedAt` is the epoch, and the model quotes a figure verbatim —
// read cold it would state the epoch's age as a fact. Unknown is a value
// the tool result already carries.
loadedAt: session.isLoaded ? session.current.loadedAt : null,
failures: session.current.failures,
priceError: session.current.priceError,
}),
Expand Down
55 changes: 53 additions & 2 deletions src/cli/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export interface LoadResult {
export type LoadStep =
/** `account` only where the venue holds more than one, so the wait names the
* address being read rather than repeating a label nothing else could be. */
| { kind: 'venue'; venue: string; account?: string }
| { kind: 'venue'; venue: string; account?: string; done?: number; total?: number }
| { kind: 'prices'; assets: number }

const EMPTY: LoadResult = {
Expand Down Expand Up @@ -217,6 +217,7 @@ function attributed(p: Position, account: { id: string; label: string } | undefi
export class Session {
private cached: LoadResult = EMPTY
private hasLoaded = false
private inFlight = false

/**
* Told what the load is on: venues are read in turn, each behind a 15s
Expand Down Expand Up @@ -260,12 +261,43 @@ export class Session {
return this.hasLoaded
}

/**
* Whether a read is running now. `covers()` being false has two causes that
* look identical on screen and are not: a read in flight, and a read that
* threw. Drawn as the first, the second is a spinner over nothing, with no
* command offered — the disclosure rule inverted.
*/
get isLoading(): boolean {
return this.inFlight
}

/**
* Whether the cache answers for this venue. `isLoaded` is a fact about the
* session — that some load finished — not about the venue set the cache was
* built from, and the two came apart wherever a venue was connected after the
* shell opened: the store gained it at once, the cache did not, and every
* surface read the gap as a venue holding nothing.
*/
covers(venueId: string): boolean {
return this.hasLoaded && this.cached.connected.includes(venueId)
}

/**
* The same question for a whole set: any venue it does not answer for makes a
* count unknowable. Vacuous over an empty set — the caller holds the list, and
* a caller with nothing stored already has its own answer to give.
*/
coversAll(venueIds: readonly string[]): boolean {
return this.hasLoaded && venueIds.every((id) => this.cached.connected.includes(id))
}

async ensureLoaded(): Promise<LoadResult> {
if (this.hasLoaded) return this.cached
return this.refresh()
}

async refresh(): Promise<LoadResult> {
this.inFlight = true
try {
const positions: Position[] = []
const failures: string[] = []
Expand Down Expand Up @@ -329,9 +361,27 @@ export class Session {
`${venueId}: ${account ? `${label} — ` : ''}${unprefixed(text, connector.venue.name, venueId)}`,
)
}
// Caught here rather than at each call site: this runs inside
// `.finally()` on the chain's own promise, and a promise derived from
// `finally` rejects if the callback throws — discarding the fulfilled
// value. A listener that threw would delete that chain's positions and
// report the node that answered as the one that failed.
const step = (done: number, total: number) => {
try {
this.onProgress?.({
kind: 'venue',
venue: venueId,
...(account ? { account: label } : {}),
done,
total,
})
} catch {
// A label is not worth a row.
}
}
let read: readonly Position[] = []
try {
read = await connector.fetchPositions(held.credentials, scope)
read = await connector.fetchPositions(held.credentials, scope, step)
} catch (err) {
// A venue spread over several chains has several independent ways to
// fail, and catching per connector made the whole book hostage to
Expand Down Expand Up @@ -426,6 +476,7 @@ export class Session {
this.hasLoaded = true
return this.cached
} finally {
this.inFlight = false
// Cleared however the load ends, or a label outlives the work it named.
this.onProgress?.(null)
}
Expand Down
87 changes: 87 additions & 0 deletions src/cli/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,93 @@ describe('load progress', () => {
null,
])
})

/**
* A venue spread over several chains held one unchanging label for the whole
* read, and an unchanging label is what a hang looks like. The count is
* reported per part settled — including a part that failed, because a reader
* is no longer waiting for that one either.
*/
test('a venue that reads in parts counts them, failures included', async () => {
const parts: Connector = {
...testConnector,
venue: { id: 'parts', kind: 'cex', name: 'Parts' },
async fetchPositions(_creds, _refresh, onPart) {
onPart?.(1, 3)
onPart?.(2, 3)
onPart?.(3, 3)
return []
},
}
const session = await sessionOf(new Map([['parts', parts]]))
const steps: (LoadStep | null)[] = []
session.onProgress = (step) => steps.push(step)
await session.refresh()
expect(steps.filter((s) => s?.kind === 'venue')).toEqual([
{ kind: 'venue', venue: 'parts' },
{ kind: 'venue', venue: 'parts', done: 1, total: 3 },
{ kind: 'venue', venue: 'parts', done: 2, total: 3 },
{ kind: 'venue', venue: 'parts', done: 3, total: 3 },
])
})
})

describe('what the cache answers for', () => {
/**
* `isLoaded` says a load finished; it says nothing about which venues that
* load was built from. The two came apart wherever a venue was connected
* after the shell opened: the store gained it at once, the cache did not, and
* every surface read the gap as a venue holding nothing.
*/
test('a venue connected after a load is not covered by it', async () => {
const session = await freshSession()
await session.refresh()
expect(session.isLoaded).toBe(true)
expect(session.covers('testvenue')).toBe(true)

await secrets.put('emptyvenue', { apiKey: 'k' })
// The load is still finished, and still answers for nothing about this one.
expect(session.isLoaded).toBe(true)
expect(session.covers('emptyvenue')).toBe(false)
expect(session.coversAll(['testvenue', 'emptyvenue'])).toBe(false)

await session.refresh()
expect(session.covers('emptyvenue')).toBe(true)
expect(session.coversAll(['testvenue', 'emptyvenue'])).toBe(true)
})

test('nothing is covered before the first load', async () => {
const session = await freshSession()
expect(session.isLoaded).toBe(false)
expect(session.covers('testvenue')).toBe(false)
expect(session.coversAll(['testvenue'])).toBe(false)
expect(session.coversAll([])).toBe(false)
})

test('an empty set is covered once a load has happened', async () => {
const session = await freshSession()
await session.refresh()
expect(session.coversAll([])).toBe(true)
})

/**
* A venue this build dropped is skipped before `refresh` records it, so it can
* never appear in the cache — and a caller that asked about the whole store got
* an answer that could not become true. `/shock`'s asset list went dark for the
* rest of the session for anyone still holding a retired venue's key, and the
* remedy it offered was the `/refresh` that could not fix it.
*/
test('a retired venue in the store never becomes covered, so callers must ask about the build', async () => {
const session = await freshSession()
await secrets.put('circle', { apiKey: 'k' })
await session.refresh()
const stored = await secrets.listVenues()
expect(stored).toContain('circle')
expect(session.covers('circle')).toBe(false)
expect(session.coversAll(stored)).toBe(false)
// The build's own list is the answerable question, and it is answered.
expect(session.coversAll([...CONNECTORS.keys()].filter((id) => stored.includes(id)))).toBe(true)
})
})

describe('dispatchCommand', () => {
Expand Down
13 changes: 10 additions & 3 deletions src/connectors/aave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
words,
} from './evm.js'
import { assetOn } from './symbols.js'
import { PartialRead, type Connector, type ConnectorCredentials, type KeyScope } from './types.js'
import { PartialRead, type Connector, type ConnectorCredentials, type KeyScope, type PartProgress, type Refresh } from './types.js'
import { plural } from '../core/format.js'

export const AAVE: Venue = {
Expand Down Expand Up @@ -627,7 +627,11 @@ export const aaveConnector: Connector = {
return { canRead: true, canTrade: false, canWithdraw: false }
},

async fetchPositions(creds: ConnectorCredentials): Promise<Position[]> {
async fetchPositions(
creds: ConnectorCredentials,
_refresh?: Refresh,
onPart?: PartProgress,
): Promise<Position[]> {
const address = creds['address']
if (!address) throw new TulaError('Aave needs a public address.')

Expand All @@ -638,8 +642,11 @@ export const aaveConnector: Connector = {
instances: INSTANCES.filter((i) => i.chain.id === chain.id),
})).filter((g) => g.instances.length > 0)

let settled = 0
const read = await Promise.allSettled(
byChain.map((g) => readMarkets(g.chain, g.instances, address)),
byChain.map((g) =>
readMarkets(g.chain, g.instances, address).finally(() => onPart?.(++settled, byChain.length)),
),
)

const positions = read.flatMap((r) => (r.status === 'fulfilled' ? r.value : []))
Expand Down
Loading