Motivation
Guardian already coordinates per-account state and authenticates the account's cosigners. What it doesn't offer is a place to durably store arbitrary private data that has no on-chain representation and can't be rebuilt from chain — and that the operator must not be able to read.
Many integrations need this. A wallet needs to persist contacts, trusted dApps, settings, and portfolio history. A dApp or bridge integration may need to stash per-account preferences, session metadata, or app-specific state. Today each of these has to stand up its own backend, even though Guardian is already the per-account, authenticated service they talk to.
Rather than solve this once per integration, add a general storage primitive to Guardian: encrypted, opaque, per-account. Wallet metadata is the first concrete consumer, not the scope.
Proposal
A small opaque, namespaced blob store scoped per account. Clients store named, versioned blobs; Guardian only ever sees ciphertext. The client encrypts before PUT and decrypts after GET with a key it derives and holds locally — Guardian never receives it and never interprets the bytes.
This is the same architectural move as #266: after that change Guardian treats proposals as opaque blobs with no server-side parsing. Storage is opaque by design — Guardian never indexes, validates, or type-checks contents. It's a dumb, encrypted, per-account key-value store. (Related: #255 META - Privacy.)
Example use case — Bread Wallet private metadata. The wallet writes namespaces like contacts, trusted_apps, settings, portfolio_history, encrypted under a key derived from the user's recovery phrase, so a new device restores and decrypts them during recovery. Guardian sees only opaque blobs.
Proposed API
Reuses the existing auth headers (x-pubkey / x-signature / x-timestamp), validated against the account's authorized cosigner list — no new auth.
GET /storage -> [{ key, version }] # cheap sync check
GET /storage/:key -> { version, value } # value = opaque ciphertext
PUT /storage/:key -> body { value }, header If-Match: <version>
# 201 on write, 409 on version mismatch (CAS)
DELETE /storage/:key -> tombstone
Data model
One additive table, opaque to the server (mirrors states / deltas):
CREATE TABLE account_storage (
account_id VARCHAR(64) NOT NULL,
key VARCHAR(64) NOT NULL, -- caller-defined namespace
version BIGINT NOT NULL, -- monotonic per (account_id, key)
value BYTEA NOT NULL, -- opaque bytes; never read server-side
updated_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (account_id, key)
);
- Conflicts: optimistic concurrency via
If-Match: <version> — consistent with the nonce "higher wins" rule already used for deltas.
- Encryption is the client's responsibility and out of scope for the server; Guardian stores whatever bytes it's handed. Documenting only that the server must remain content-agnostic.
What this needs from Guardian
- The
account_storage table + the endpoints above, kept fully opaque (no parsing/validation of contents). Small extension to the existing storage backend.
- SDK client methods to
get / put / list / delete entries (analogous to the client-API work already discussed for delta application).
Deliberately low-lift: no policy engine, no server-side crypto — opaque storage plus CRUD.
Open questions
- Per-account quota / value-size cap, and whether storage shares the delta rate-limit / body-size budgets?
- On
switch_guardian, is copying entries to the new Guardian sufficient, or should the SDK orchestrate migration?
- Latest-only, or keep a short version history for multi-device conflict forensics?
- Naming: is
account_storage / /storage the right primitive name, or something more specific (e.g. /kv)?
Motivation
Guardian already coordinates per-account state and authenticates the account's cosigners. What it doesn't offer is a place to durably store arbitrary private data that has no on-chain representation and can't be rebuilt from chain — and that the operator must not be able to read.
Many integrations need this. A wallet needs to persist contacts, trusted dApps, settings, and portfolio history. A dApp or bridge integration may need to stash per-account preferences, session metadata, or app-specific state. Today each of these has to stand up its own backend, even though Guardian is already the per-account, authenticated service they talk to.
Rather than solve this once per integration, add a general storage primitive to Guardian: encrypted, opaque, per-account. Wallet metadata is the first concrete consumer, not the scope.
Proposal
A small opaque, namespaced blob store scoped per account. Clients store named, versioned blobs; Guardian only ever sees ciphertext. The client encrypts before
PUTand decrypts afterGETwith a key it derives and holds locally — Guardian never receives it and never interprets the bytes.This is the same architectural move as #266: after that change Guardian treats proposals as opaque blobs with no server-side parsing. Storage is opaque by design — Guardian never indexes, validates, or type-checks contents. It's a dumb, encrypted, per-account key-value store. (Related: #255 META - Privacy.)
Example use case — Bread Wallet private metadata. The wallet writes namespaces like
contacts,trusted_apps,settings,portfolio_history, encrypted under a key derived from the user's recovery phrase, so a new device restores and decrypts them during recovery. Guardian sees only opaque blobs.Proposed API
Reuses the existing auth headers (
x-pubkey/x-signature/x-timestamp), validated against the account's authorized cosigner list — no new auth.Data model
One additive table, opaque to the server (mirrors
states/deltas):If-Match: <version>— consistent with the nonce "higher wins" rule already used for deltas.What this needs from Guardian
account_storagetable + the endpoints above, kept fully opaque (no parsing/validation of contents). Small extension to the existing storage backend.get/put/list/deleteentries (analogous to the client-API work already discussed for delta application).Deliberately low-lift: no policy engine, no server-side crypto — opaque storage plus CRUD.
Open questions
switch_guardian, is copying entries to the new Guardian sufficient, or should the SDK orchestrate migration?account_storage//storagethe right primitive name, or something more specific (e.g./kv)?