Skip to content

Vaults

A vault is a user-defined savings goal — a target amount in a target currency, with a name, colour, and optional icon. Vaults accumulate value from fractions of holdings: you allocate “25% of my BTC” and “100% of this EUR savings account” to your Emergency fund vault, and the vault’s current amount is the sum of those attributed values. Vaults are lightweight overlays on top of holdings — they don’t change the underlying ledger, just give you a goal-tracking view.

vaults:

ColumnMeaning
iduuid PK.
userIduuid → users.id.
nameUnique per user.
descriptionFree text.
targetAmountDecimal string — what you’re saving toward.
currencyIduuid → tokens.id. The currency of the target.
currentAmountDenormalised sum of attributed values, in the vault’s currency. Updated by the vault-recompute path; kept on the row so the dashboard reads it without a recompute.
colorHex string.
iconNameOptional.
isActive
createdAt / updatedAt

vault_holdings (junction):

ColumnMeaning
iduuid PK.
vaultIduuid → vaults.id.
holdingIduuid → holdings.id.
percentagereal, 1–100. The fraction of the holding attributed to this vault.
createdAt

Uniqueness: (vaultId, holdingId) — a holding can appear in a vault at most once.

A single holding can be split across multiple vaults — 25% to a house deposit, 75% to retirement. The percentages can sum to anything: the system does not enforce that the sum across vaults equals 100% for a given holding. You can over-allocate (sum > 100%) or under-allocate (sum < 100%) deliberately — Scani treats vaults as a UI overlay, not an accounting view.

A single vault can pull from multiple holdings — your Emergency fund draws from a Wise EUR account, a Coinbase USDC position, and 10% of your BTC.

When the vault page loads (or after a relevant balance/price update), the vault service computes:

currentAmount = Σ over vault_holdings {
convert(holding.balance × (percentage/100), holding.token, vault.currency, now)
}

The conversion uses the price graph at the current time. The result is stored on vaults.currentAmount so dashboards render without a recompute.

Because the vault’s currency is configurable and arbitrary, a vault denominated in EUR pulling from a USDC holding will see daily fluctuation from the EUR/USD rate — that’s intentional and matches what users expect from a goal denominated in their target currency.

UI computes progress as currentAmount / targetAmount (clamped 0–1 for visualisation; the raw ratio is also surfaced so over-funded vaults show > 100%).

Both are user-defined organisation. The difference:

AspectVaultsGroups
PurposeGoal tracking — total progress toward a target.Free-form tagging — filtering / categorisation.
Attaches toHoldings only, with percentage split.Holdings and accounts, no percentage.
Computes a financial totalYes — currentAmount.No.
Has a targetYes — targetAmount + currencyId.No.
Has a currencyYes.No.

A holding can belong to multiple vaults (with different percentages) and multiple groups simultaneously, independently.

  • Created via the vaults.create tRPC mutation.
  • Holdings attach via the vaults.attachHolding mutation, which inserts a vault_holdings row with the percentage.
  • currentAmount is refreshed by the vault service after any attach / detach / balance / price update. There’s no separate cron for vault recompute — it’s cheap enough to do on demand.