Integrate two-phase on-chain purchase recovery
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user