Skip to content
Open
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
253 changes: 253 additions & 0 deletions docs/plans/account-state-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
# Account State Lifecycle Spec

## Summary

Add a `state` column to `wallet.accounts` supporting the lifecycle `active -> expired -> deleted` (soft delete). This enables automatic expiry of offer accounts when their keyset's `expires_at` passes, soft delete so expired/deleted accounts don't block creation of future accounts at the same mint, and a uniqueness constraint scoped only to active accounts.

## Design Decisions

### 1. Where to filter `deleted` accounts

**Decision: RLS (restrictive SELECT policy).**

A restrictive RLS policy makes `deleted` accounts invisible for SELECT. Every caller -- `getAll()`, `get()`, realtime subscriptions -- automatically excludes deleted rows without any app-layer change. The `enforce_accounts_limit` trigger must also be updated to exclude `deleted` accounts from its count.

### 2. Where to filter `expired` accounts

**Decision: App layer.**

Expired accounts remain visible in `getAll()` -- the RLS policy does not hide them. The existing `useActiveOffers()` filter in `gift-cards.tsx` is simplified from the `expiresAt > now()` check to `account.state === 'active'`.

### 3. Auto-expiry mechanism

**Decision: Two layers — eager on user assertion, pg_cron as background cleanup.**

**Eager (on login):** `upsert_user_with_accounts` expires stale accounts before returning them. When a user opens the app, any account with `state = 'active'` and `expires_at <= now()` is transitioned to `expired` within the same transaction. The client gets correct state on first load — no stale-then-update flicker.

**Background (pg_cron, hourly):** A cron job catches accounts for users who haven't opened the app. This keeps the DB consistent for realtime broadcasts and prevents stale `active` accounts from accumulating. pg_cron is already installed and used for 8 daily cleanup jobs — no new infrastructure.

### 4. Soft delete

**Decision: Client-initiated app-layer mutation.**

A new `wallet.soft_delete_account(p_account_id uuid)` DB function sets `state = 'deleted'` and bumps `version`. The `ACCOUNT_UPDATED` realtime event fires; the client removes the account from the cache.

### 5. Transitions are one-way

Valid: `active -> expired`, `active -> deleted`, `expired -> deleted`. No reactivation. An expired offer account's keyset has expired at the Cashu protocol level -- reactivating it would be misleading. New ecash at the same mint creates a new `active` account (the updated unique index allows this).

Enforced by construction: each DB function's WHERE clause only matches valid source states. No trigger needed — `upsert_user_with_accounts` only transitions `active → expired`, and `soft_delete_account` only transitions `active/expired → deleted`.

### 6. Realtime handling for deleted accounts

The `ACCOUNT_UPDATED` handler must detect `state === 'deleted'` in the broadcast payload and call `accountCache.remove(id)` rather than `accountCache.update(account)`.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this makes sense because of what claude said in 1, but I'll look more when I build


## DB Migration

**File:** `supabase/migrations/20260325120000_add_account_state.sql`

### New enum + column

```sql
create type "wallet"."account_state" as enum ('active', 'expired', 'deleted');

alter table "wallet"."accounts"
add column "state" "wallet"."account_state" not null default 'active';
```

### Index changes

```sql
drop index "wallet"."cashu_accounts_user_currency_mint_url_unique";

create unique index "cashu_accounts_active_user_currency_mint_url_unique"
on "wallet"."accounts" using btree (
"user_id",
"currency",
(("details" ->> 'mint_url'::text))
)
where ("type" = 'cashu' and "state" = 'active');

-- Supporting index for the cron job (index on the cast expression so Postgres can use it)
create index "idx_accounts_active_expires_at"
on "wallet"."accounts" using btree ((("details" ->> 'expires_at')::timestamptz))
where ("state" = 'active' and ("details" ->> 'expires_at') is not null);
```

### RLS: hide deleted accounts

```sql
create policy "Exclude deleted accounts from select"
on "wallet"."accounts"
as restrictive
for select
to authenticated
using (state != 'deleted'::wallet.account_state);
```

### enforce_accounts_limit (deferred)

The current trigger counts all accounts regardless of state. Deleted accounts will count toward the 200-account quota. Changing this limit is a separate discussion — the limit exists for a reason and adjusting what counts toward it has implications beyond this feature. For now, soft-deleted accounts are rare (only offer accounts) and won't meaningfully impact the quota.

### Soft delete DB function

```sql
create or replace function "wallet"."soft_delete_account"(p_account_id uuid)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it be called soft delete or just delete?

returns void
language plpgsql
security invoker
set search_path = ''
as $function$
begin
update wallet.accounts
set state = 'deleted', version = version + 1
where id = p_account_id
and state != 'deleted';

if not found then
raise exception
using
hint = 'NOT_FOUND',
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if not found because account is already deleted should this return a different error message that its already deleted?

message = format('Account with id %s not found.', p_account_id);
end if;
end;
$function$;
```

### Eager expiry in upsert_user_with_accounts

Add an UPDATE before the account fetch in `upsert_user_with_accounts` to transition stale accounts:

```sql
-- Expire stale accounts before returning them to the client
update wallet.accounts
set state = 'expired', version = version + 1
where
user_id = p_user_id
and state = 'active'
and (details ->> 'expires_at') is not null
and (details ->> 'expires_at')::timestamptz <= now();
```

This runs inside the existing transaction, before the `accounts_with_proofs` CTE that fetches accounts. The client receives already-expired accounts with `state = 'expired'` — no second round-trip needed.

### pg_cron job for auto-expiry
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking we should keep this around for when the user doesn't open the app for a long time, but do you think its needed


```sql
select cron.schedule('expire-offer-accounts', '0 * * * *', $$
update wallet.accounts
set
state = 'expired',
version = version + 1
where
state = 'active'
and (details ->> 'expires_at') is not null
and (details ->> 'expires_at')::timestamptz <= now();
$$);
```

## App Code Changes

### `account.ts` -- Add state to type

```typescript
export type AccountState = 'active' | 'expired' | 'deleted';

// Add to Account base type:
state: AccountState;
```

### `account-repository.ts` -- Map state, add delete

Map `state` in `toAccount()` commonData. Add `deleteAccount(id)` calling `soft_delete_account` RPC.

### `account-hooks.ts` -- Cache removal + realtime handling

- Add `AccountsCache.remove(id)` method
- Update `ACCOUNT_UPDATED` handler: if `payload.state === 'deleted'`, call `remove` instead of `update`
- Add `useDeleteAccount` hook

### `gift-cards.tsx` -- Simplify filter

```typescript
function useActiveOffers() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is from code I haven't committed yet btw

const { data: offerAccounts } = useAccounts({ purpose: 'offer' });
return offerAccounts.filter((account) => account.state === 'active');
}
```

### Files requiring no changes

- `account-service.ts` -- New accounts default to `active` via DB column default
- `offer-details.tsx` -- Already handles missing offer gracefully
- `all-accounts.tsx` -- Filters by `purpose: 'transactional'`, unaffected
- All DB quote functions -- Operate on specific account IDs, no state awareness needed
- `to_account_with_proofs` -- Uses `select *`, state included automatically

## Data Flow

### active -> expired (on login, eager)

```
User opens app
-> upsert_user_with_accounts(...)
-> UPDATE stale accounts to state='expired', version+1 (same transaction)
-> accounts returned already have state='expired'
-> client renders correct state immediately, no flicker
```

### active -> expired (background cleanup, hourly)

```
pg_cron -> UPDATE state='expired', version+1 (for users who haven't logged in)
-> broadcast_accounts_changes_trigger fires
-> realtime ACCOUNT_UPDATED to connected clients
-> accountCache.update(account) [version higher, accepted]
-> useActiveOffers() re-renders, filtered by state === 'active'
```

### active/expired -> deleted (user-initiated)

```
useDeleteAccount()(accountId)
-> db.rpc('soft_delete_account', { p_account_id: id })
-> broadcast ACCOUNT_UPDATED with state='deleted'
-> client: accountCache.remove(id)
-> account gone from all UI
```

### New offer after prior expiry

```
User receives new offer token for same mint
-> INSERT (state defaults to 'active')
-> unique index only covers WHERE state='active'
-> no conflict with expired/deleted account
-> new active account created
```

## Implementation Phases

### Phase 1: DB Migration
- [ ] Write migration file
- [ ] Ask user to apply
- [ ] Run `bun run db:generate-types`

### Phase 2: Types and Repository
- [ ] Add `AccountState` type and `state` field to `account.ts`
- [ ] Map `data.state` in `AccountRepository.toAccount()`
- [ ] Add `AccountRepository.deleteAccount(id)` calling RPC
- [ ] Add `AccountsCache.remove(id)`
- [ ] Update `ACCOUNT_UPDATED` handler for deleted state
- [ ] Add `useDeleteAccount` hook
- [ ] Run `bun run fix:all`

### Phase 3: UI
- [ ] Update `useActiveOffers()` to filter by `state === 'active'`
- [ ] Run `bun run fix:all`

## Open Questions

- **Delete UI placement**: The hook is specced; UX (which screen, what confirmation) is a separate decision.
- **Expired balance recovery**: Proofs may still be swappable depending on mint's keyset expiry enforcement. Separate feature.
- **Offer re-use on receive**: When a user receives a new offer token for a mint that already has an `active` offer account, existing behavior routes proofs to the existing account. Unchanged by this migration.
Loading