Integrate two-phase on-chain purchase recovery

This commit is contained in:
archipelago
2026-10-07 07:49:02 -04:00
parent 558f097fd6
commit 6547ae05fa
18 changed files with 6084 additions and 113 deletions
@@ -0,0 +1,27 @@
import { describe,it,expect } from 'vitest'
import { parseOnchainAttempt } from '../peerOnchainPurchase'
const original={operation_id:'11111111-1111-4111-8111-111111111111',price_sats:546,phase:'template_prepared',network:'mainnet',external_exposure:false,address:null,fee_sats:142,max_fee_sats:1000,template_sha256:'a'.repeat(64),plan_sha256:null,txid:null,paid:false,change_allocation_ambiguous:false,can_switch_method:false,retired_unallocated:false}
describe('durable on-chain owner state',()=>{
it('preserves original amount and fee for explicit confirmation',()=>expect(parseOnchainAttempt(original)).toEqual(original))
it('rejects unknown state and incoherent exposure or fee metadata',()=>{
for(const value of [{},{...original,can_switch_method:true},{...original,address:'bc1hidden'},{...original,external_exposure:true},{...original,fee_sats:1001},{...original,price_sats:545},{...original,template_sha256:'bad'},{...original,phase:'failed'}])expect(()=>parseOnchainAttempt(value)).toThrow()
})
it('does not equate allocation ambiguity with permission to switch',()=>{
const result=parseOnchainAttempt({...original,phase:'quoted',fee_sats:null,max_fee_sats:null,template_sha256:null,change_allocation_ambiguous:true})
expect(result.can_switch_method).toBe(false);expect(result.change_allocation_ambiguous).toBe(true)
})
})
it('accepts only a coherent terminal unallocated acknowledgement for switching',()=>{
const retired={...original,phase:'address_requested',network:null,fee_sats:null,max_fee_sats:null,template_sha256:null,retired_unallocated:true,can_switch_method:true}
expect(parseOnchainAttempt(retired).can_switch_method).toBe(true)
for(const value of [{...retired,address:'bc1issued'},{...retired,external_exposure:true},{...retired,paid:true},{...retired,change_allocation_ambiguous:true},{...retired,phase:'funded'},{...retired,txid:'b'.repeat(64)}])expect(()=>parseOnchainAttempt(value)).toThrow()
})
it('requires original plan confirmation and permits only unallocated plan retirement',()=>{
const plan={...original,phase:'plan_prepared',template_sha256:null,plan_sha256:'c'.repeat(64)}
expect(parseOnchainAttempt(plan).plan_sha256).toBe(plan.plan_sha256)
expect(()=>parseOnchainAttempt({...plan,plan_sha256:null})).toThrow()
expect(parseOnchainAttempt({...plan,can_switch_method:true,retired_unallocated:true}).can_switch_method).toBe(true)
expect(()=>parseOnchainAttempt({...plan,phase:'plan_lease_dispatched',can_switch_method:true,retired_unallocated:true})).toThrow()
})
@@ -0,0 +1,38 @@
/** Durable owner-node state; never infer an unpaid address from a browser timeout. */
export interface OnchainAttempt {
operation_id: string
price_sats: number
phase: 'address_requested' | 'offer_prepared' | 'plan_prepared' | 'plan_lease_dispatched' | 'inputs_leased' | 'address_allocation_dispatched' | 'quoted' | 'template_prepared' | 'lease_dispatched' | 'funding_dispatched' | 'funded' | 'signing_dispatched' | 'signed' | 'broadcast_dispatched' | 'published'
network: 'mainnet' | 'testnet' | 'signet' | 'regtest' | null
external_exposure: boolean
address: string | null
fee_sats: number | null
max_fee_sats: number | null
template_sha256: string | null
plan_sha256: string | null
txid: string | null
paid: boolean
change_allocation_ambiguous: boolean
can_switch_method: boolean
retired_unallocated: boolean
}
const phases = new Set(['address_requested','offer_prepared','plan_prepared','plan_lease_dispatched','inputs_leased','address_allocation_dispatched','quoted','template_prepared','lease_dispatched','funding_dispatched','funded','signing_dispatched','signed','broadcast_dispatched','published'])
export function parseOnchainAttempt(value: unknown): OnchainAttempt {
if (!value || typeof value !== 'object') throw Error('Original on-chain state is unavailable; do not pay again')
const v = { plan_sha256: null, ...value } as Record<string,unknown>
const integer = (n:unknown) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
const hex = (s:unknown) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s)
if (typeof v.operation_id !== 'string' || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(v.operation_id)
|| !integer(v.price_sats) || Number(v.price_sats)<546 || !phases.has(String(v.phase))
|| ![null,'mainnet','testnet','signet','regtest'].includes(v.network as string|null)
|| typeof v.external_exposure !== 'boolean' || typeof v.paid !== 'boolean'
|| typeof v.change_allocation_ambiguous !== 'boolean' || typeof v.retired_unallocated !== 'boolean' || v.can_switch_method !== v.retired_unallocated
|| !(v.address === null || typeof v.address === 'string' && v.address.length>0)
|| !(v.fee_sats === null || integer(v.fee_sats)) || !(v.max_fee_sats === null || integer(v.max_fee_sats))
|| !(v.plan_sha256 === null || hex(v.plan_sha256)) || !(v.template_sha256 === null || hex(v.template_sha256)) || !(v.txid === null || hex(v.txid))) throw Error('Original on-chain state is invalid; do not pay again')
if (v.external_exposure && !v.address || !v.external_exposure && v.address !== null
|| (v.template_sha256 !== null || v.plan_sha256 !== null) && (v.fee_sats === null || v.max_fee_sats === null || Number(v.fee_sats)>Number(v.max_fee_sats))) throw Error('Original on-chain payment terms changed')
if (['plan_prepared','plan_lease_dispatched','inputs_leased'].includes(String(v.phase)) && (v.plan_sha256 === null || v.network === null || v.external_exposure || v.address !== null)) throw Error('Original funding plan is incomplete')
if (v.retired_unallocated && (!['address_requested','offer_prepared','plan_prepared'].includes(String(v.phase)) || v.external_exposure || v.paid || v.address !== null || v.template_sha256 !== null || v.txid !== null || v.change_allocation_ambiguous)) throw Error('Retired on-chain operation contains payment liability')
return v as unknown as OnchainAttempt
}