feat: serialize trusted mint transactions

This commit is contained in:
2026-08-22 16:55:47 +02:00
parent 82f008b33f
commit d735aca885
2 changed files with 488 additions and 0 deletions
@@ -0,0 +1,287 @@
// Owner-bound trusted-mint transactions under deterministic Web Locks.
// The fake parks every request until the test releases it in FIFO order.
import {beforeEach, describe, expect, it, vi} from 'vitest'
import {linkingPubKeyHex, saveLinkingKey} from './keys'
import {
addTrustedMint,
cacheTrustedMintNodeInfo,
clearTrustedMints,
confirmTrustedMintRekey,
lockTrustedMint,
mergeTrustedMints,
onTrustedMintsChange,
readTrustedMints,
removeTrustedMint,
type TrustedMint,
} from './trustedMints'
import {stubLocalStorage} from './test-utils'
const STORAGE_KEY = 'sattle_trusted_mints'
const LINKING_KEY_A = new Uint8Array(32).fill(7)
const OWNER_A = linkingPubKeyHex(LINKING_KEY_A)
const OWNER_B = linkingPubKeyHex(new Uint8Array(32).fill(9))
const KEY_A = '02' + 'aa'.repeat(32)
const KEY_B = '03' + 'bb'.repeat(32)
const KEY_C = '02' + 'cc'.repeat(32)
const SERVER = 'mint.example'
type LockRequest = {
readonly name: string
readonly callback: () => unknown
readonly resolve: (value: unknown) => void
readonly reject: (reason: unknown) => void
}
class DeferredLocks {
readonly requests: LockRequest[] = []
held = false
readonly request = (name: string, callback: () => unknown): Promise<unknown> =>
new Promise((resolve, reject) => {
this.requests.push({name, callback, resolve, reject})
})
async releaseNext(): Promise<void> {
const request = this.requests.shift()
if (!request) throw new Error('Expected a queued lock request.')
this.held = true
try {
request.resolve(await request.callback())
} catch (error) {
request.reject(error instanceof Error ? error : new Error(String(error)))
} finally {
this.held = false
}
}
}
const mint = (overrides: Partial<TrustedMint> = {}): TrustedMint => ({
server: SERVER,
mintPubkey: KEY_A,
addedAt: 123,
locked: false,
...overrides,
})
const store = (ownerId: string, mints: TrustedMint[]): void => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({version: 1, ownerId, mints}))
}
const installLocks = (): DeferredLocks => {
const locks = new DeferredLocks()
vi.stubGlobal('navigator', {locks})
return locks
}
const waitForRequests = async (locks: DeferredLocks, count: number): Promise<void> => {
await vi.waitFor(() => expect(locks.requests).toHaveLength(count))
}
beforeEach(async () => {
vi.unstubAllGlobals()
stubLocalStorage()
await saveLinkingKey(LINKING_KEY_A)
})
describe('serialized owner-bound mutations', () => {
it('reads current storage instead of retaining a stale snapshot', () => {
store(OWNER_A, [mint({nodeAlias: 'first'})])
expect(readTrustedMints(OWNER_A)[0]?.nodeAlias).toBe('first')
store(OWNER_A, [mint({nodeAlias: 'external update'})])
expect(readTrustedMints(OWNER_A)[0]?.nodeAlias).toBe('external update')
})
it('preserves two queued additions by reading fresh state in FIFO order', async () => {
const locks = installLocks()
const writes = vi.spyOn(localStorage, 'setItem')
const first = addTrustedMint('one.example', KEY_A, {ownerId: OWNER_A})
const second = addTrustedMint('two.example', KEY_B, {ownerId: OWNER_A})
await waitForRequests(locks, 2)
await locks.releaseNext()
await locks.releaseNext()
await expect(first).resolves.toBe('added')
await expect(second).resolves.toBe('added')
expect(readTrustedMints(OWNER_A).map((entry) => entry.server)).toEqual([
'one.example',
'two.example',
])
expect(writes).toHaveBeenCalledTimes(2)
})
it('does not let metadata overwrite a concurrently staged rekey', async () => {
store(OWNER_A, [mint()])
const locks = installLocks()
const rekey = lockTrustedMint(SERVER, KEY_B, OWNER_A)
const metadata = cacheTrustedMintNodeInfo(SERVER, {nodeAlias: 'Fresh alias'}, OWNER_A)
await waitForRequests(locks, 2)
await locks.releaseNext()
await locks.releaseNext()
await Promise.all([rekey, metadata])
expect(readTrustedMints(OWNER_A)[0]).toMatchObject({
mintPubkey: KEY_A,
pendingMintPubkey: KEY_B,
nodeAlias: 'Fresh alias',
})
})
it('keeps a new staged key after queued explicit confirmation', async () => {
store(OWNER_A, [mint({pendingMintPubkey: KEY_B})])
const locks = installLocks()
const confirm = confirmTrustedMintRekey(SERVER, OWNER_A)
const stage = lockTrustedMint(SERVER, KEY_C, OWNER_A)
await waitForRequests(locks, 2)
await locks.releaseNext()
await locks.releaseNext()
await Promise.all([confirm, stage])
expect(readTrustedMints(OWNER_A)[0]).toMatchObject({
mintPubkey: KEY_B,
pendingMintPubkey: KEY_C,
})
})
it('lets a live lock corroborate a queued backup merge', async () => {
const locks = installLocks()
const incoming = mint({locked: true, pendingMintPubkey: KEY_B})
const merge = mergeTrustedMints([incoming], OWNER_A)
const live = lockTrustedMint(SERVER, KEY_A, OWNER_A)
await waitForRequests(locks, 2)
await locks.releaseNext()
await locks.releaseNext()
await Promise.all([merge, live])
expect(readTrustedMints(OWNER_A)[0]).toMatchObject({
mintPubkey: KEY_A,
locked: true,
})
expect(readTrustedMints(OWNER_A)[0]?.unconfirmed).toBeUndefined()
})
it('rejects queued removal after a live operation locks the mint', async () => {
store(OWNER_A, [mint()])
const locks = installLocks()
const live = lockTrustedMint(SERVER, KEY_A, OWNER_A)
const removal = removeTrustedMint(SERVER, OWNER_A)
await waitForRequests(locks, 2)
await locks.releaseNext()
await locks.releaseNext()
await expect(live).resolves.toBe('unchanged')
await expect(removal).rejects.toThrow(/bearer/)
expect(readTrustedMints(OWNER_A)[0]?.locked).toBe(true)
})
it('applies a queued clear after an earlier writer', async () => {
store(OWNER_A, [mint()])
const locks = installLocks()
const add = addTrustedMint('queued.example', KEY_B, {ownerId: OWNER_A})
const clear = clearTrustedMints(OWNER_A)
await waitForRequests(locks, 2)
await locks.releaseNext()
await locks.releaseNext()
await Promise.all([add, clear])
expect(readTrustedMints(OWNER_A)).toEqual([])
})
it('rejects stale, malformed, and foreign-owner mutations', async () => {
store(OWNER_B, [mint()])
await expect(addTrustedMint('stale.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow(
/owner/i,
)
await expect(addTrustedMint('invalid.example', KEY_B, {ownerId: 'invalid'})).rejects.toThrow(
/owner/i,
)
expect(readTrustedMints(OWNER_B)).toEqual([mint()])
})
it('rejects a malformed stored envelope without overwriting it', async () => {
const malformed = JSON.stringify({
version: 1,
ownerId: 'invalid',
mints: [],
})
localStorage.setItem(STORAGE_KEY, malformed)
await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow(
/malformed/i,
)
expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed)
})
it('rejects a malformed mint member without rewriting stored bytes', async () => {
const malformed = JSON.stringify({
version: 1,
ownerId: OWNER_A,
mints: [mint(), mint({server: 'broken.example', mintPubkey: 'not-hex'})],
})
localStorage.setItem(STORAGE_KEY, malformed)
await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow(
/malformed/i,
)
expect(localStorage.getItem(STORAGE_KEY)).toBe(malformed)
})
it('keeps storage and listeners unchanged when the single write fails', async () => {
store(OWNER_A, [mint()])
vi.stubGlobal('navigator', {})
const before = localStorage.getItem(STORAGE_KEY)
const notified = vi.fn()
const unsubscribe = onTrustedMintsChange(notified)
const setItem = localStorage.setItem.bind(localStorage)
localStorage.setItem = (): void => {
throw new Error('QuotaExceededError')
}
try {
await expect(addTrustedMint('new.example', KEY_B, {ownerId: OWNER_A})).rejects.toThrow(
'QuotaExceededError',
)
expect(localStorage.getItem(STORAGE_KEY)).toBe(before)
expect(notified).not.toHaveBeenCalled()
} finally {
localStorage.setItem = setItem
unsubscribe()
}
})
it('notifies listeners only after the storage lock is released', async () => {
const locks = installLocks()
const lockStates: boolean[] = []
const unsubscribe = onTrustedMintsChange(() => lockStates.push(locks.held))
try {
const addition = addTrustedMint(SERVER, KEY_A, {ownerId: OWNER_A})
await waitForRequests(locks, 1)
await locks.releaseNext()
await addition
expect(lockStates).toEqual([false])
} finally {
unsubscribe()
}
})
})
+201
View File
@@ -0,0 +1,201 @@
// Owner-bound trusted-mint persistence. Web Locks serialize writers while an
// IndexedDB commit mirror bridges stale cross-renderer localStorage views.
import {isWalletOwnerId} from './storage/walletOwner'
import {savedKeyOwnerAllows} from './storage/currentOwner'
import {storageLocksAvailable, withStorageLock} from './storageLock'
import type {TrustedMint} from './trustedMints'
import {trustedMintsCommitStore} from './trustedMintsCommitStore'
import {notifyStoredTrustedMintsChange, TRUSTED_MINTS_STORAGE_KEY} from './trustedMintsEvents'
import {
parseLegacyTrustedMintsRegistry,
parseStoredTrustedMintsRegistry,
serializeTrustedMintsRegistry,
type StoredTrustedMintsRegistry,
} from './trustedMintsRegistry'
import type {MintTransition} from './trustedMintTransitions'
const STORAGE_KEY = TRUSTED_MINTS_STORAGE_KEY
type MutationState = {
readonly canonicalRaw: string | null
readonly current: TrustedMint[]
readonly localRaw: string | null
readonly mirrorEnabled: boolean
}
export class InvalidTrustedMintsOwnerError extends Error {
override readonly name = 'InvalidTrustedMintsOwnerError'
constructor() {
super('Trusted-mint mutation requires a valid wallet owner.')
}
}
export class TrustedMintsOwnerMismatchError extends Error {
override readonly name = 'TrustedMintsOwnerMismatchError'
constructor() {
super('Trusted-mint registry belongs to a different wallet owner.')
}
}
export class MalformedTrustedMintsRegistryError extends Error {
override readonly name = 'MalformedTrustedMintsRegistryError'
constructor() {
super('Trusted-mint registry storage is malformed.')
}
}
const requireOwnedRegistry = (
stored: StoredTrustedMintsRegistry,
ownerId: string,
): StoredTrustedMintsRegistry => {
if (stored.kind === 'malformed') throw new MalformedTrustedMintsRegistryError()
if (stored.kind === 'valid' && stored.envelope.ownerId !== ownerId) {
throw new TrustedMintsOwnerMismatchError()
}
return stored
}
const mirrorIsEnabled = (): boolean =>
storageLocksAvailable() && trustedMintsCommitStore.available()
const readMutationState = async (ownerId: string): Promise<MutationState> => {
const localRaw = localStorage.getItem(STORAGE_KEY)
const local = requireOwnedRegistry(parseStoredTrustedMintsRegistry(localRaw), ownerId)
const mirrorEnabled = mirrorIsEnabled()
if (!mirrorEnabled) {
return {
canonicalRaw: localRaw,
current: local.kind === 'valid' ? local.envelope.mints : [],
localRaw,
mirrorEnabled,
}
}
const mirrorRaw = await trustedMintsCommitStore.read()
if (mirrorRaw === null) {
return {
canonicalRaw: localRaw,
current: local.kind === 'valid' ? local.envelope.mints : [],
localRaw,
mirrorEnabled,
}
}
const mirror = requireOwnedRegistry(parseStoredTrustedMintsRegistry(mirrorRaw), ownerId)
if (mirror.kind !== 'valid') throw new MalformedTrustedMintsRegistryError()
return {
canonicalRaw: mirrorRaw,
current: mirror.envelope.mints,
localRaw,
mirrorEnabled,
}
}
const restoreLocalRegistry = (raw: string | null): void => {
if (raw === null) localStorage.removeItem(STORAGE_KEY)
else localStorage.setItem(STORAGE_KEY, raw)
}
const commitRegistry = async (
previousRaw: string | null,
nextRaw: string,
mirrorEnabled: boolean,
): Promise<boolean> => {
const localChanged = previousRaw !== nextRaw
if (localChanged) localStorage.setItem(STORAGE_KEY, nextRaw)
if (!mirrorEnabled) return localChanged
try {
await trustedMintsCommitStore.write(nextRaw)
} catch (error) {
if (localChanged) restoreLocalRegistry(previousRaw)
throw error
}
return localChanged
}
export {onStoredTrustedMintsChange} from './trustedMintsEvents'
export const readOwnedTrustedMints = (ownerId: unknown): TrustedMint[] => {
if (!isWalletOwnerId(ownerId)) return []
const stored = parseStoredTrustedMintsRegistry(localStorage.getItem(STORAGE_KEY))
return stored.kind === 'valid' && stored.envelope.ownerId === ownerId ? stored.envelope.mints : []
}
export const mutateStoredTrustedMints = async <T>(
ownerId: unknown,
transition: (mints: TrustedMint[]) => MintTransition<T>,
): Promise<T> => {
if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError()
const committed = await withStorageLock(STORAGE_KEY, async () => {
if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError()
const state = await readMutationState(ownerId)
const next = transition(state.current)
const nextRaw = next.changed
? serializeTrustedMintsRegistry(ownerId, next.mints)
: state.canonicalRaw
const localChanged =
nextRaw === null ? false : await commitRegistry(state.localRaw, nextRaw, state.mirrorEnabled)
return {localChanged, result: next.result}
})
if (committed.localChanged) notifyStoredTrustedMintsChange()
return committed.result
}
export const adoptLegacyStoredTrustedMints = async (ownerId: unknown): Promise<number> => {
if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError()
const adopted = await withStorageLock(STORAGE_KEY, async () => {
if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError()
const localRaw = localStorage.getItem(STORAGE_KEY)
const legacy = parseLegacyTrustedMintsRegistry(localRaw)
if (legacy === null) {
requireOwnedRegistry(parseStoredTrustedMintsRegistry(localRaw), ownerId)
return null
}
await commitRegistry(
localRaw,
serializeTrustedMintsRegistry(ownerId, legacy),
mirrorIsEnabled(),
)
return legacy
})
if (adopted !== null) notifyStoredTrustedMintsChange()
return adopted?.length ?? 0
}
export const removeStoredTrustedMintsForOwner = async (ownerId: unknown): Promise<void> => {
if (!isWalletOwnerId(ownerId)) throw new InvalidTrustedMintsOwnerError()
const removed = await withStorageLock(STORAGE_KEY, async () => {
if (!savedKeyOwnerAllows(ownerId)) throw new TrustedMintsOwnerMismatchError()
const state = await readMutationState(ownerId)
if (state.canonicalRaw === null) return false
if (state.localRaw !== null) localStorage.removeItem(STORAGE_KEY)
try {
if (state.mirrorEnabled) await trustedMintsCommitStore.clear()
} catch (error) {
if (state.localRaw !== null) restoreLocalRegistry(state.localRaw)
throw error
}
return true
})
if (removed) notifyStoredTrustedMintsChange()
}
export const resetStoredTrustedMints = async (): Promise<void> => {
const removed = await withStorageLock(STORAGE_KEY, async () => {
const localRaw = localStorage.getItem(STORAGE_KEY)
if (localRaw !== null) localStorage.removeItem(STORAGE_KEY)
try {
if (mirrorIsEnabled()) await trustedMintsCommitStore.clear()
} catch (error) {
if (localRaw !== null) restoreLocalRegistry(localRaw)
throw error
}
return localRaw !== null
})
if (removed) notifyStoredTrustedMintsChange()
}