@allorc/a423-sdk 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,15 @@ const ABI = artifact.abi;
6
6
  const TOKEN_ABI = ['function allowance(address,address) view returns(uint256)','function approve(address,uint256) returns(bool)'];
7
7
  const TERMS_TYPES = { Terms: [ ['jobId','bytes32'],['payer','address'],['worker','address'],['verifier','address'],['arbitrator','address'],['amount','uint256'],['workDeadline','uint64'],['reviewDeadline','uint64'],['resolutionDeadline','uint64'],['timeoutWorkerBps','uint16'],['agreementHash','bytes32'] ].map(([name,type])=>({name,type})) };
8
8
  const transitions = {
9
- dispute: {from:['submitted','verified'],roles:['requester','worker','verifier'],to:'disputed',effect:true},
9
+ approve_cancellation: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
10
+ execute_cancellation: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settled',effect:true},
11
+ propose_settlement: {from:['funded','running','submitted','verified'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
12
+ counter_settlement: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
13
+ accept_settlement: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settled',effect:true},
14
+ refund: {from:['funded','running','settlement_negotiation','disputed'],roles:['requester'],to:'refunded',effect:true},
15
+ dispute: {from:['funded','running','submitted','verified','settlement_negotiation'],roles:['requester','worker','verifier'],to:'disputed',effect:true},
10
16
  resolve: {from:['disputed'],roles:['arbitrator'],to:'settled',effect:true},
11
- timeout: {from:['funded','running','submitted','verified','disputed'],roles:['requester','worker','verifier','arbitrator'],to:'settled',effect:true}
17
+ timeout: {from:['funded','running','submitted','verified','disputed','settlement_negotiation'],roles:['requester','worker','verifier','arbitrator'],to:'settled',effect:true}
12
18
  };
13
19
  function check(condition,message) { if(!condition) throw new Error('Escrow: '+message); }
14
20
  function positive(value,name) { check(typeof value==='string' && /^[1-9][0-9]*$/.test(value),name+' must be a positive decimal string');const n=BigInt(value);check(n<(1n<<256n),name+' exceeds uint256');return n; }
@@ -108,12 +114,71 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
108
114
  const event=logs.find(log=>log?.name===eventName && log.args.jobId===t.jobId);
109
115
  check(event,'matching '+eventName+' event missing');
110
116
  const snapshot=await contract.getJob(t.jobId,{blockTag:r.blockNumber});match(snapshot,t);validate(snapshot,event);
111
- return {status:'confirmed',evidence:require('../operations/index.cjs').validateEscrowEvidence({chain_id:String(chainId),contract:config.contractAddress,token:config.tokenAddress,job_id:t.jobId,agreement_hash:t.agreementHash,transaction_hash:r.hash,block_number:r.blockNumber,block_hash:r.blockHash,confirmations,event:eventName,settlement_reason:eventName==='Settled'?Number(event.args.reason):null,reason_hash:eventName==='Disputed'?event.args.reasonHash:null,amount:t.amount.toString(),payer:t.payer,worker:t.worker,verifier:t.verifier,arbitrator:t.arbitrator,result_hash:snapshot.resultHash,worker_paid:snapshot.workerPaid.toString(),state:Number(snapshot.state),observed_at:new Date().toISOString()})};
117
+ return {status:'confirmed',evidence:require('../operations/index.cjs').validateEscrowEvidence({chain_id:String(chainId),contract:config.contractAddress,token:config.tokenAddress,job_id:t.jobId,agreement_hash:t.agreementHash,transaction_hash:r.hash,block_number:r.blockNumber,block_hash:r.blockHash,confirmations,event:eventName,escrow_version:Number(snapshot.proposal.revision),proposal_hash:snapshot.proposal.proposalHash,proposal_worker_amount:snapshot.proposal.workerAmount.toString(),proposal_expires_at:Number(snapshot.proposal.expiresAt),proposal_proposer:snapshot.proposal.proposer,settlement_reason:eventName==='Settled'?Number(event.args.reason):null,reason_hash:eventName==='Disputed'?event.args.reasonHash:null,amount:t.amount.toString(),payer:t.payer,worker:t.worker,verifier:t.verifier,arbitrator:t.arbitrator,result_hash:snapshot.resultHash,worker_paid:snapshot.workerPaid.toString(),state:Number(snapshot.state),observed_at:new Date().toISOString()})};
112
118
  }
113
119
  function adapter(run){return {execute:ctx=>run(ctx,false),reconcile:ctx=>run(ctx,true)};}
114
120
  const state=(s,n)=>check(Number(s.state)===n,'unexpected escrow state');
115
121
  const settled=(s,e,t)=>{check([5,6,7].includes(Number(s.state)),'not settled');check(e.args.workerAmount+e.args.payerAmount===t.amount && e.args.workerAmount===s.workerPaid,'settlement amount mismatch');};
122
+ async function propose(ctx,reconcile){
123
+ const {settlementProposal}=require('./negotiation.cjs');
124
+ const t=await context(ctx.job);let proposal;
125
+ try {proposal=settlementProposal(ctx.job,ctx.input);}catch(error){return {status:'rejected',reason:error.message};}
126
+ return observe(ctx,t,'propose_settlement','proposeSettlement',[t.jobId,ctx.input.expected_revision,BigInt(proposal.worker_amount),proposal.proposal_hash,proposal.expires_at],'SettlementProposed',(s,e)=>{
127
+ check(Number(s.state)===8,'negotiation is not active');
128
+ check(Number(e.args.revision)===proposal.escrow_version && e.args.proposalHash===proposal.proposal_hash && e.args.workerAmount===BigInt(proposal.worker_amount) && e.args.payerAmount===BigInt(proposal.payer_amount) && Number(e.args.expiresAt)===proposal.expires_at && getAddress(e.args.proposer)===getAddress(ctx.settlementActorAddress), 'proposal event mismatch');
129
+ },reconcile);
130
+ }
131
+ const proposeAdapter=adapter(async(ctx,r)=>{ctx.settlementActorAddress=getAddress(await addressFor(ctx.operation.actor));return propose(ctx,r);});
116
132
  return {
133
+ approve_cancellation:adapter(async ctx=>{
134
+ await context(ctx.job);const p=ctx.job.negotiation?.current;
135
+ if(!p||ctx.input.expected_revision!==p.escrow_version||ctx.input.proposal_hash!==p.proposal_hash)return {status:'rejected',reason:'Approve the exact current proposal'};
136
+ const {createCancellationInstruction,verifyCancellationAuthorization}=require('./cancellation.cjs');let instruction;
137
+ try{instruction=createCancellationInstruction(ctx.job,ctx.input);}catch(error){return {status:'rejected',reason:error.message};}
138
+ const verified=await verifyCancellationAuthorization(ctx.job,instruction,{provider,...config});
139
+ if(!verified.valid)return {status:'rejected',reason:verified.reason};
140
+ return {status:'confirmed',evidence:{kind:'a423.cancellation_acceptance.v1',instruction,checked_block:verified.checked_block,funds_moved:false}};
141
+ }),
142
+ execute_cancellation:adapter(async(ctx,r)=>{
143
+ const t=await context(ctx.job);const {getCancellationSubflow,validateCancellationInstruction}=require('../cancellation.cjs');
144
+ const instruction=getCancellationSubflow(ctx.job)?.instruction;
145
+ if(!instruction||ctx.input.instruction_id!==instruction.id)return {status:'rejected',reason:'Accepted cancellation instruction required'};
146
+ try{validateCancellationInstruction(ctx.job,instruction);}catch(error){return {status:'rejected',reason:error.message};}
147
+ const {verifyCancellationAuthorization,cancellationCall}=require('./cancellation.cjs');
148
+ if(!ctx.input.transaction_hash&&!await transactions.get(ctx.operation.id+':execute_cancellation')){
149
+ const verified=await verifyCancellationAuthorization(ctx.job,instruction,{provider,...config});if(!verified.valid)return {status:'rejected',reason:verified.reason};
150
+ }
151
+ const call=cancellationCall(instruction);
152
+ const result=await observe(ctx,t,'execute_cancellation',call.functionName,call.args,'Settled',(s,e)=>{
153
+ settled(s,e,t);check(Number(e.args.reason)===4&&e.args.workerAmount.toString()===instruction.worker.amount&&e.args.payerAmount.toString()===instruction.payer.amount&&Number(s.proposal.revision)===instruction.escrow_version&&s.proposal.proposalHash===instruction.proposal_hash,'Instruction allocation mismatch');
154
+ },r);
155
+ if(result.status==='confirmed'){
156
+ const receipt=await provider.getTransactionReceipt(result.evidence.transaction_hash);
157
+ const accepted=receipt.logs.filter(l=>getAddress(l.address)===config.contractAddress).map(l=>{try{return iface.parseLog(l);}catch{return null;}}).find(e=>e?.name==='SettlementAccepted'&&e.args.jobId===t.jobId);
158
+ check(accepted&&Number(accepted.args.revision)===instruction.escrow_version&&accepted.args.proposalHash===instruction.proposal_hash,'Instruction acceptance event missing');
159
+ result.evidence.instruction_id=instruction.id;
160
+ }
161
+ return result;
162
+ }),
163
+ propose_settlement:proposeAdapter,
164
+ counter_settlement:proposeAdapter,
165
+ accept_settlement:adapter(async(ctx,r)=>{
166
+ const {settlementConsent}=require('./negotiation.cjs');const t=await context(ctx.job);const p=ctx.job.negotiation?.current;
167
+ if(!p || ctx.input.expected_revision!==p.escrow_version || ctx.input.proposal_hash!==p.proposal_hash)return {status:'rejected',reason:'Accept the exact current proposal hash and revision'};
168
+ let consent;try{consent=settlementConsent(ctx.job,config);}catch(error){return {status:'rejected',reason:error.message};}
169
+ let method='acceptSettlement',args=[t.jobId,p.escrow_version,p.proposal_hash];
170
+ if(ctx.input.payer_signature!==undefined || ctx.input.worker_signature!==undefined){
171
+ if(typeof ctx.input.payer_signature!=='string'||typeof ctx.input.worker_signature!=='string')return {status:'rejected',reason:'Both party signatures required for relayed settlement'};
172
+ method='settleByConsent';args=[consent.value,ctx.input.payer_signature,ctx.input.worker_signature];
173
+ }else if(ctx.job.negotiation.current.proposer_id===ctx.operation.actor)return {status:'rejected',reason:'Counterparty must accept; proposer cannot self-accept'};
174
+ const result=await observe(ctx,t,'accept_settlement',method,args,'Settled',(s,e)=>{settled(s,e,t);check(Number(e.args.reason)===4 && e.args.workerAmount===BigInt(p.worker_amount) && e.args.payerAmount===BigInt(p.payer_amount) && Number(s.proposal.revision)===p.escrow_version && s.proposal.proposalHash===p.proposal_hash,'closing allocation mismatch');},r);
175
+ if(result.status==='confirmed'){
176
+ const receipt=await provider.getTransactionReceipt(result.evidence.transaction_hash);
177
+ const accepted=receipt.logs.filter(l=>getAddress(l.address)===config.contractAddress).map(l=>{try{return iface.parseLog(l);}catch{return null;}}).find(e=>e?.name==='SettlementAccepted'&&e.args.jobId===t.jobId);
178
+ check(accepted&&Number(accepted.args.revision)===p.escrow_version&&accepted.args.proposalHash===p.proposal_hash,'matching settlement acceptance missing');
179
+ }
180
+ return result;
181
+ }),
117
182
  fund:adapter(async(ctx,reconcile)=>{
118
183
  const t=await context(ctx.job);
119
184
  check([ctx.job.participants.requester,ctx.job.participants.worker].every(p=>ctx.job.agreement.accepted_by.includes(p)),'both acceptances required');
@@ -142,9 +207,9 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
142
207
  refund:adapter(async(ctx,r)=>{const t=await context(ctx.job);return observe(ctx,t,'refund','refundExpired',[t.jobId],'Settled',(s,e)=>{state(s,6);settled(s,e,t);check(e.args.payerAmount===t.amount && Number(e.args.reason)===1,'not an expiry refund');},r);}),
143
208
  dispute:adapter(async(ctx,r)=>{const t=await context(ctx.job);const reason=bytes32(ctx.input.reason_hash);return observe(ctx,t,'dispute','dispute',[t.jobId,reason],'Disputed',(s,e)=>{check(Number(s.state)>=4,'missing dispute');check(e.args.reasonHash===reason,'reason mismatch');},r);}),
144
209
  resolve:adapter(async(ctx,r)=>{const t=await context(ctx.job);check(typeof ctx.input.worker_amount==='string' && /^(0|[1-9][0-9]*)$/.test(ctx.input.worker_amount),'worker_amount must be decimal string');const amount=BigInt(ctx.input.worker_amount);check(amount<=t.amount,'resolution exceeds escrow');return observe(ctx,t,'resolve','resolve',[t.jobId,amount],'Settled',(s,e)=>{settled(s,e,t);check(e.args.workerAmount===amount && Number(e.args.reason)===2,'resolution mismatch');},r);}),
145
- timeout:adapter(async(ctx,r)=>{const t=await context(ctx.job);const method=['funded','running'].includes(ctx.job.state)?'refundExpired':'timeout';return observe(ctx,t,'timeout',method,[t.jobId],'Settled',(s,e)=>{settled(s,e,t);const expected=method==='refundExpired'?0n:(t.amount*BigInt(t.timeoutWorkerBps))/10000n;check(e.args.workerAmount===expected && Number(e.args.reason)===(method==='refundExpired'?1:3),'timeout allocation mismatch');},r);})
210
+ timeout:adapter(async(ctx,r)=>{const t=await context(ctx.job);const snapshot=await contract.getJob(t.jobId);const method=snapshot.resultHash===ZeroHash?'refundExpired':'timeout';return observe(ctx,t,'timeout',method,[t.jobId],'Settled',(s,e)=>{settled(s,e,t);const expected=method==='refundExpired'?0n:(t.amount*BigInt(t.timeoutWorkerBps))/10000n;check(e.args.workerAmount===expected && Number(e.args.reason)===(method==='refundExpired'?1:3),'timeout allocation mismatch');},r);})
146
211
  };
147
212
  }
148
213
  module.exports={createEscrowAdapters,createMemoryTransactionStore,workerConsent,escrowTerms,escrowTransitions:transitions,ESCROW_ABI:ABI,ESCROW_BYTECODE:artifact.bytecode};
149
214
 
150
- Object.assign(module.exports,require('./services.cjs'));
215
+ Object.assign(module.exports,require('./services.cjs'),require('./negotiation.cjs'),require('./cancellation.cjs'));
@@ -27,7 +27,7 @@ export interface EscrowAdapterOptions extends EscrowConfiguration {
27
27
  confirmations: number;
28
28
  validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
29
29
  }
30
- export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout', EscrowAdapter>;
30
+ export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout'|'propose_settlement'|'counter_settlement'|'accept_settlement'|'approve_cancellation'|'execute_cancellation', EscrowAdapter>;
31
31
  export function createMemoryTransactionStore(): TransactionStore;
32
32
  export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
33
33
  export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
@@ -46,3 +46,10 @@ export function replacePendingTransaction(options: {id: string;transactions: Tra
46
46
  export function createFeeReplacementWorker(options: {transactions: TransactionStore;provider: Provider;signerForRecord(record: TransactionRecord): Promise<Signer>;authorize(context: any): Promise<boolean>;minAgeMs?: number;maxAttempts?: number;maxFeePerGas: bigint;maxPriorityFeePerGas: bigint}): () => Promise<any[]>;
47
47
 
48
48
  export function createEscrowIndexer(options: {provider: Provider;chainId: number;contractAddress: string;confirmations: number;fromBlock: number;kv: import('../storage/index.js').KeyValueStore;onEvent(event: any): Promise<void>;onReorg(event: any): Promise<void>;batchSize?: number}): () => Promise<{indexed: number;through?: number}>;
49
+
50
+ export function settlementProposal(job: WorkflowJob,input: {worker_amount: string;payer_amount?: string;expected_revision: number;expires_at: number;reason: string;claims?: import('../workflow.js').SettlementClaim[]}): import('../workflow.js').SettlementProposal;
51
+ export function settlementConsent(job: WorkflowJob,configuration: EscrowConfiguration): {domain: {name: string;version: string;chainId: number;verifyingContract: string};types: Record<string,TypedDataField[]>;value: {jobId: string;agreementHash: string;escrowVersion: number;workerAmount: bigint;payerAmount: bigint;proposalHash: string;expiresAt: number}};
52
+
53
+ export {createCancellationInstruction,validateCancellationInstruction} from '../workflow.js';
54
+ export function cancellationCall(instruction: import('../workflow.js').CancellationInstruction): {functionName: 'settleByConsent';args: [ReturnType<typeof settlementConsent>['value'],string,string]};
55
+ export function verifyCancellationAuthorization(job: WorkflowJob,instruction: import('../workflow.js').CancellationInstruction,options: EscrowConfiguration & {provider: Provider}): Promise<{valid: boolean;checked_block?: number;reason?: string}>;
@@ -27,7 +27,7 @@ export interface EscrowAdapterOptions extends EscrowConfiguration {
27
27
  confirmations: number;
28
28
  validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
29
29
  }
30
- export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout', EscrowAdapter>;
30
+ export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout'|'propose_settlement'|'counter_settlement'|'accept_settlement'|'approve_cancellation'|'execute_cancellation', EscrowAdapter>;
31
31
  export function createMemoryTransactionStore(): TransactionStore;
32
32
  export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
33
33
  export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
@@ -46,3 +46,10 @@ export function replacePendingTransaction(options: {id: string;transactions: Tra
46
46
  export function createFeeReplacementWorker(options: {transactions: TransactionStore;provider: Provider;signerForRecord(record: TransactionRecord): Promise<Signer>;authorize(context: any): Promise<boolean>;minAgeMs?: number;maxAttempts?: number;maxFeePerGas: bigint;maxPriorityFeePerGas: bigint}): () => Promise<any[]>;
47
47
 
48
48
  export function createEscrowIndexer(options: {provider: Provider;chainId: number;contractAddress: string;confirmations: number;fromBlock: number;kv: import('../storage/index.js').KeyValueStore;onEvent(event: any): Promise<void>;onReorg(event: any): Promise<void>;batchSize?: number}): () => Promise<{indexed: number;through?: number}>;
49
+
50
+ export function settlementProposal(job: WorkflowJob,input: {worker_amount: string;payer_amount?: string;expected_revision: number;expires_at: number;reason: string;claims?: import('../workflow.js').SettlementClaim[]}): import('../workflow.js').SettlementProposal;
51
+ export function settlementConsent(job: WorkflowJob,configuration: EscrowConfiguration): {domain: {name: string;version: string;chainId: number;verifyingContract: string};types: Record<string,TypedDataField[]>;value: {jobId: string;agreementHash: string;escrowVersion: number;workerAmount: bigint;payerAmount: bigint;proposalHash: string;expiresAt: number}};
52
+
53
+ export {createCancellationInstruction,validateCancellationInstruction} from '../workflow.js';
54
+ export function cancellationCall(instruction: import('../workflow.js').CancellationInstruction): {functionName: 'settleByConsent';args: [ReturnType<typeof settlementConsent>['value'],string,string]};
55
+ export function verifyCancellationAuthorization(job: WorkflowJob,instruction: import('../workflow.js').CancellationInstruction,options: EscrowConfiguration & {provider: Provider}): Promise<{valid: boolean;checked_block?: number;reason?: string}>;
@@ -1,2 +1,2 @@
1
1
  import escrow from './index.cjs';
2
- export const { createEscrowAdapters, createMemoryTransactionStore, workerConsent, escrowTerms, escrowTransitions, ESCROW_ABI, ESCROW_BYTECODE, createEscrowRegistry, createEvidenceRevalidator, createManagedSigner, createDelegationAuthorizer, replacePendingTransaction, createFeeReplacementWorker, createEscrowIndexer } = escrow;
2
+ export const { createEscrowAdapters, createMemoryTransactionStore, workerConsent, escrowTerms, escrowTransitions, ESCROW_ABI, ESCROW_BYTECODE, createEscrowRegistry, createEvidenceRevalidator, createManagedSigner, createDelegationAuthorizer, replacePendingTransaction, createFeeReplacementWorker, createEscrowIndexer, settlementProposal, settlementConsent, createCancellationInstruction, validateCancellationInstruction, cancellationCall, verifyCancellationAuthorization } = escrow;
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+ const {getAddress}=require('ethers');
3
+ const TYPES={ClosingSettlement:[['jobId','bytes32'],['agreementHash','bytes32'],['escrowVersion','uint64'],['workerAmount','uint256'],['payerAmount','uint256'],['proposalHash','bytes32'],['expiresAt','uint64']].map(([name,type])=>({name,type}))};
4
+ function check(ok,message){if(!ok)throw new TypeError(message);}
5
+ const {settlementProposal}=require('../closing.cjs');
6
+ function settlementConsent(job,configuration){
7
+ const {escrowTerms}=require('./index.cjs');const t=escrowTerms(job,configuration),p=job.negotiation?.current;
8
+ check(p&&['open','accepted'].includes(job.negotiation.status),'Open settlement proposal required');
9
+ const canonical=settlementProposal(job,{worker_amount:p.worker_amount,payer_amount:p.payer_amount,expected_revision:p.escrow_version-1,expires_at:p.expires_at,reason:p.reason,claims:p.claims});
10
+ check(canonical.proposal_hash===p.proposal_hash,'Stored proposal claims or allocation changed');
11
+ return {domain:{name:'A423Escrow',version:'1',chainId:configuration.chainId,verifyingContract:getAddress(configuration.contractAddress)},types:TYPES,value:{jobId:t.jobId,agreementHash:t.agreementHash,escrowVersion:p.escrow_version,workerAmount:BigInt(p.worker_amount),payerAmount:BigInt(p.payer_amount),proposalHash:p.proposal_hash,expiresAt:p.expires_at}};
12
+ }
13
+ module.exports={settlementProposal,settlementConsent};
@@ -5,7 +5,7 @@ const {validateEscrowEvidence}=require('../operations/index.cjs');
5
5
  function check(ok,message){if(!ok)throw Error(message);}
6
6
  function createEscrowRegistry(configurations){
7
7
  const map=new Map();for(const c of configurations){const key=c.chainId+':'+getAddress(c.contractAddress).toLowerCase();check(!map.has(key),'Duplicate escrow configuration');map.set(key,createEscrowAdapters(c));}
8
- const actions=['fund','start','submit','verify','release','refund','dispute','resolve','timeout'];const result={};for(const action of actions){result[action]={};for(const method of ['execute','reconcile'])result[action][method]=ctx=>{const e=ctx.job.agreement?.terms.escrow;const adapter=map.get(e?.chain_id+':'+getAddress(e?.contract).toLowerCase());check(adapter,'Escrow not registered');return adapter[action][method](ctx);};}return result;
8
+ const actions=['fund','start','submit','verify','release','refund','dispute','resolve','timeout','propose_settlement','counter_settlement','accept_settlement','approve_cancellation','execute_cancellation'];const result={};for(const action of actions){result[action]={};for(const method of ['execute','reconcile'])result[action][method]=ctx=>{const e=ctx.job.agreement?.terms.escrow;const adapter=map.get(e?.chain_id+':'+getAddress(e?.contract).toLowerCase());check(adapter,'Escrow not registered');return adapter[action][method](ctx);};}return result;
9
9
  }
10
10
  /** Fresh historical evidence verification. Returns invalid, insufficient_confirmations, or valid; RPC failures throw. */
11
11
  function createEvidenceRevalidator({provider,chainId,contractAddress,tokenAddress,confirmations}={}){
@@ -31,6 +31,9 @@ function createEvidenceRevalidator({provider,chainId,contractAddress,tokenAddres
31
31
  if(event.name==='Funded'&&(event.args.amount!==t.amount||getAddress(event.args.payer)!==t.payer||getAddress(event.args.worker)!==t.worker||event.args.agreementHash!==t.agreementHash))return invalid('Funding fields mismatch');
32
32
  if(['Submitted','Verified'].includes(event.name)&&event.args.resultHash!==snapshot.resultHash)return invalid('Artifact mismatch');
33
33
  if(event.name==='Disputed'&&event.args.reasonHash!==evidence.reason_hash)return invalid('Dispute reason mismatch');
34
+ if(event.name==='SettlementProposed'&&(Number(event.args.revision)!==evidence.escrow_version||event.args.proposalHash!==evidence.proposal_hash||event.args.workerAmount.toString()!==evidence.proposal_worker_amount||Number(event.args.expiresAt)!==evidence.proposal_expires_at||getAddress(event.args.proposer)!==getAddress(evidence.proposal_proposer)))return invalid('Proposal fields mismatch');
35
+ if(evidence.escrow_version!==undefined && (Number(snapshot.proposal.revision)!==evidence.escrow_version||snapshot.proposal.proposalHash!==evidence.proposal_hash||snapshot.proposal.workerAmount.toString()!==evidence.proposal_worker_amount||Number(snapshot.proposal.expiresAt)!==evidence.proposal_expires_at||getAddress(snapshot.proposal.proposer)!==getAddress(evidence.proposal_proposer)))return invalid('Cached proposal mismatch');
36
+ if(event.name==='Settled'&&Number(event.args.reason)===4){const acceptance=logs.find(l=>l?.name==='SettlementAccepted'&&l.args.jobId===t.jobId);if(!acceptance||Number(acceptance.args.revision)!==evidence.escrow_version||acceptance.args.proposalHash!==evidence.proposal_hash)return invalid('Mutual acceptance missing');}
34
37
  if(event.name==='Settled'&&(event.args.workerAmount!==snapshot.workerPaid||event.args.payerAmount+event.args.workerAmount!==t.amount||Number(event.args.reason)!==evidence.settlement_reason))return invalid('Settlement mismatch');
35
38
  return {status:'valid',transaction_hash:receipt.hash,block_hash:block.hash,confirmations:head.number-block.number+1};
36
39
  };
@@ -8,13 +8,16 @@ function createChatTools({workflow,store,queue,actor}={}){
8
8
  let job=await workflow.create({id,participants:{requester:actor,worker:worker_id,verifier:verifier_id,arbitrator:arbitrator_id},metadata},actor);
9
9
  if(terms)job=await workflow.act(job.id,'quote',{terms},{actor,idempotencyKey:'initial_quote'});return job;
10
10
  },
11
- propose_quote:act('quote'),accept_quote:act('accept'),fund_escrow:act('fund'),submit_job:act('submit'),verify_job:act('verify'),release_escrow:act('release'),refund_escrow:act('refund'),dispute_job:act('dispute'),resolve_job:act('resolve'),timeout_job:act('timeout'),cancel_job:act('cancel'),
11
+ propose_quote:act('quote'),accept_quote:act('accept'),fund_escrow:act('fund'),submit_job:act('submit'),verify_job:act('verify'),release_escrow:act('release'),refund_escrow:act('refund'),dispute_job:act('dispute'),resolve_job:act('resolve'),timeout_job:act('timeout'),propose_settlement:act('propose_settlement'),counter_settlement:act('counter_settlement'),accept_settlement:act('accept_settlement'),accept_cancellation:async({job_id,input={},idempotency_key})=>workflow.acceptCancellation(job_id,input,{actor,idempotencyKey:idempotency_key}),execute_cancellation:act('execute_cancellation'),request_cancellation:act('propose_settlement'),cancel_job:act('cancel'),
12
12
  work_on_job:async(input)=>{const job=await act('start')(input);if(!job.pending&&job.state==='running')await enqueueRunningJobs({store,queue});return job;}
13
13
  };
14
14
  }
15
- function createApplicationHandler({workflow,store,queue,authenticate,revalidate,consentFor,legacyState=async()=>({}),authorityHandler,basePath='/api/a423'}={}){
15
+ function createApplicationHandler({workflow,store,queue,authenticate,revalidate,consentFor,settlementConsentFor,legacyState=async()=>({}),authorityHandler,forwardCancellations=true,basePath='/api/a423'}={}){
16
16
  const {createEventStream,freshReceipts,renderReceiptPdf}=require('./index.cjs');
17
- const protocol=createWorkflowHandler({workflow,authenticate,basePath});const events=createEventStream({store,authenticate});
17
+ const protocol=createWorkflowHandler({workflow,authenticate,basePath,forwardCancellations,revalidateCancellationReceipt:async({job,receipt})=>{
18
+ const {validateCancellationReceipt}=require('../cancellation.cjs');validateCancellationReceipt(job,receipt);
19
+ try{const check=await revalidate(job,receipt.evidence);if(check.status!=='valid')throw Error('Evidence is not valid');}catch{const {A423Error}=require('../index.cjs');throw new A423Error('EVIDENCE_UNAVAILABLE','Cancellation receipt evidence is unavailable or changed',503);}
20
+ }});const events=createEventStream({store,authenticate});
18
21
  return async request=>{
19
22
  const path=new URL(request.url).pathname;
20
23
  if(path.startsWith('/api/a423-auth/')&&authorityHandler)return authorityHandler(request);
@@ -29,6 +32,8 @@ function createApplicationHandler({workflow,store,queue,authenticate,revalidate,
29
32
  }
30
33
  const receipt=/^\/api\/a423-receipts\/([A-Za-z0-9_-]{1,128})(\.pdf)?$/.exec(path);
31
34
  if(receipt){const data=await freshReceipts({workflow,id:receipt[1],actor,revalidate});if(receipt[2])return new Response(Buffer.from(await renderReceiptPdf(data)),{headers:{'content-type':'application/pdf','cache-control':'no-store'}});return Response.json(data,{headers:{'cache-control':'no-store'}});}
35
+ const closing=/^\/api\/a423-settlement-consent\/([A-Za-z0-9_-]{1,128})$/.exec(path);
36
+ if(closing){const job=await workflow.get(closing[1],actor);if(![job.participants.requester,job.participants.worker].includes(actor))return Response.json({error:'settlement party required'},{status:403});if(!settlementConsentFor)throw Error('Settlement consent not configured');const typed=await settlementConsentFor(job);return Response.json(JSON.parse(JSON.stringify(typed,(_,v)=>typeof v==='bigint'?v.toString():v)),{headers:{'cache-control':'no-store'}});}
32
37
  const consent=/^\/api\/a423-worker-consent\/([A-Za-z0-9_-]{1,128})$/.exec(path);
33
38
  if(consent){const job=await workflow.get(consent[1],actor);if(job.participants.worker!==actor)return Response.json({error:'worker required'},{status:403});if(!consentFor)throw Error('Wallet consent not configured');const typed=await consentFor(job);return Response.json(JSON.parse(JSON.stringify(typed,(_,v)=>typeof v==='bigint'?v.toString():v)),{headers:{'cache-control':'no-store'}});}
34
39
  return Response.json({error:'not found'},{status:404});
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
  const {createWorkflow,hashAgreement}=require('../workflow.cjs');
3
3
  const {createCredentialAuthenticator,createAuthorityHandler}=require('../authority/index.cjs');
4
- function createEscrowApplication({storage,authority,escrows,signerFor,addressFor,limitsFor,approveSigning,validateArtifact,execute,keeperEnabled=false,allowAuthorityRequest=async()=>false,requireIndependentArbitrator=true}={}){
5
- const {createEscrowRegistry,createEvidenceRevalidator,escrowTerms,workerConsent,escrowTransitions}=require('../escrow/index.cjs');
4
+ function createEscrowApplication({storage,authority,escrows,signerFor,addressFor,limitsFor,approveSigning,validateArtifact,execute,keeperEnabled=false,forwardCancellations=true,allowAuthorityRequest=async()=>false,requireIndependentArbitrator=true}={}){
5
+ const {createEscrowRegistry,createEvidenceRevalidator,escrowTerms,workerConsent,settlementConsent,escrowTransitions}=require('../escrow/index.cjs');
6
6
  const {validateTask,validateEscrowEvidence,createSpendingAuthorizer,createExpiryAdapter,createApplicationHandler,createChatTools}=require('./index.cjs');
7
7
  if(!storage||!authority||!Array.isArray(escrows)||!escrows.length||typeof approveSigning!=='function'||typeof limitsFor!=='function')throw new TypeError('Explicit storage, authority, escrow configurations and spending/signing approvals required');
8
8
  const registry=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),c]));
@@ -24,7 +24,7 @@ function createEscrowApplication({storage,authority,escrows,signerFor,addressFor
24
24
  const validators=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),createEvidenceRevalidator(c)]));
25
25
  const revalidate=(job,evidence)=>validators.get(String(configFor(job).chainId)+':'+configFor(job).contractAddress.toLowerCase())(job,evidence);
26
26
  const authenticate=createCredentialAuthenticator({authority});
27
- const applicationOptions={workflow,store:storage.jobs,queue:storage.queue,authenticate,revalidate,consentFor:async job=>workerConsent(job,configFor(job)),authorityHandler:createAuthorityHandler({authority,allowRequest:allowAuthorityRequest})};
27
+ const applicationOptions={forwardCancellations,workflow,store:storage.jobs,queue:storage.queue,authenticate,revalidate,consentFor:async job=>workerConsent(job,configFor(job)),settlementConsentFor:async job=>settlementConsent(job,configFor(job)),authorityHandler:createAuthorityHandler({authority,allowRequest:allowAuthorityRequest})};
28
28
  const workerOptions={authority,actorFor:async job=>job.pending?.actor||job.participants.requester,allowExecute:keeperEnabled,
29
29
  execute,beforeExecute:async job=>{await workflow.assertSafe(job.id,job.participants.worker);if(!await authority.isActive(job.participants.worker))return false;const result=await adapters.start.execute({job,input:{},operation:{actor:job.participants.worker}});return result.status==='confirmed';}
30
30
  };
@@ -16,8 +16,8 @@ function validateEscrowEvidence(e){
16
16
  for(const key of ['contract','token','payer','worker','verifier','arbitrator'])assert(/^0x[0-9a-fA-F]{40}$/.test(e[key]),'Invalid evidence '+key);
17
17
  for(const key of ['job_id','agreement_hash','transaction_hash','block_hash','result_hash'])assert(/^0x[0-9a-fA-F]{64}$/.test(e[key]),'Invalid evidence '+key);
18
18
  assert(Number.isSafeInteger(e.block_number)&&e.block_number>=0&&Number.isSafeInteger(e.confirmations)&&e.confirmations>0,'Invalid evidence block');
19
- assert(['Funded','Submitted','Verified','Disputed','Settled'].includes(e.event),'Invalid evidence event');
20
- assert(Number.isInteger(e.state)&&e.state>=1&&e.state<=7&&BigInt(e.worker_paid)<=BigInt(e.amount),'Invalid evidence allocation/state');
19
+ assert(['Funded','Submitted','Verified','Disputed','Settled','SettlementProposed'].includes(e.event),'Invalid evidence event');
20
+ assert(Number.isInteger(e.state)&&e.state>=1&&e.state<=8&&BigInt(e.worker_paid)<=BigInt(e.amount),'Invalid evidence allocation/state');
21
21
  return structuredClone(e);
22
22
  }
23
23
  function createSpendingAuthorizer({spending,limitsFor,authorize}={}){
@@ -39,9 +39,12 @@ async function runMaintenanceOnce({workflow,store,actorFor,allowExecute=false,no
39
39
  const actor=await actorFor(job);if(!actor)continue;
40
40
  let result=job;
41
41
  if(job.pending){result=await workflow.reconcile(job.id,actor);if(result.pending&&allowExecute)result=await workflow.resume(job.id,actor);}
42
+ else if((job.subflows||[]).some(s=>s.kind==='cancellation'&&s.status==='accepted')&&allowExecute){
43
+ const subflow=job.subflows.find(s=>s.kind==='cancellation');result=await workflow.act(job.id,'execute_cancellation',{instruction_id:subflow.instruction.id},{actor,idempotencyKey:'cancel_forward_'+hash(JSON.stringify(subflow.instruction.id))});
44
+ }
42
45
  else if(job.agreement){const e=job.agreement.terms.escrow;const seconds=Math.floor(now/1000);let action;
43
- if(e&&['funded','running'].includes(job.state)&&seconds>e.work_deadline)action='timeout';
44
- if(e&&['submitted','verified','disputed'].includes(job.state)&&seconds>e.resolution_deadline)action='timeout';
46
+ if(e&&(['funded','running'].includes(job.state)||(['settlement_negotiation','disputed'].includes(job.state)&&!job.submission))&&seconds>e.work_deadline)action='timeout';
47
+ if(e&&['submitted','verified','disputed','settlement_negotiation'].includes(job.state)&&seconds>e.resolution_deadline)action='timeout';
45
48
  const expiry=job.agreement.terms.expires_at;
46
49
  if(['quoted','accepted'].includes(job.state)&&Number.isSafeInteger(expiry)&&seconds>expiry)action='expire';
47
50
  if(action&&allowExecute)result=await workflow.act(job.id,action,{}, {actor,idempotencyKey:'keeper_'+hash(job.id+':'+action)});
@@ -58,10 +61,11 @@ function createExpiryAdapter({now=()=>Date.now()}={}){const run=async({job})=>{
58
61
  };return {execute:run,reconcile:run};}
59
62
  async function runExecutionOnce({workflow,queue,execute,beforeExecute,leaseMs=30000}={}){
60
63
  const item=await queue.claim({kind:'execution',leaseMs});if(!item)return null;
61
- const heartbeat=setInterval(()=>{queue.heartbeat(item.id,item.lease,{leaseMs}).catch(()=>{});},Math.max(1,Math.floor(leaseMs/3)));
64
+ const controller=new AbortController();
65
+ const heartbeat=setInterval(async()=>{try{if(!await queue.heartbeat(item.id,item.lease,{leaseMs}))throw Error('Execution lease lost');const current=await workflow.assertSafe(item.payload.job_id,item.payload.actor);if(current.state!=='running')throw Error('Work paused');}catch(error){controller.abort(error);}},Math.max(1,Math.min(1000,Math.floor(leaseMs/3))));
62
66
  try{
63
67
  let result=item.result;
64
- if(!result){const job=await workflow.assertSafe(item.payload.job_id,item.payload.actor);assert(job.state==='running','Work requires running state');validateTask(job.agreement.terms.task);assert(typeof beforeExecute==='function'&&await beforeExecute(job)===true,'Execution eligibility check required');result=await execute({job,idempotencyKey:item.id});assert(await queue.checkpoint(item.id,item.lease,result),'Execution lease lost');}
68
+ if(!result){const job=await workflow.assertSafe(item.payload.job_id,item.payload.actor);assert(job.state==='running','Work requires running state');validateTask(job.agreement.terms.task);assert(typeof beforeExecute==='function'&&await beforeExecute(job)===true,'Execution eligibility check required');result=await execute({job,idempotencyKey:item.id,signal:controller.signal});assert(await queue.checkpoint(item.id,item.lease,result),'Execution lease lost');}
65
69
  assert(typeof result.result_hash==='string','Execution must return artifact commitment');
66
70
  const job=await workflow.act(item.payload.job_id,'submit',result,{actor:item.payload.actor,idempotencyKey:'execution_'+hash(item.id)});
67
71
  assert(await queue.complete(item.id,item.lease,{job_id:job.id,state:job.state}),'Execution lease lost');return job;
@@ -91,7 +95,7 @@ function createWebhookDispatcher({queue,kv,subscriptions,secretFor,fetch:fetchIm
91
95
  }
92
96
  async function watchEvidenceOnce({store,revalidate}){const results=[];let after='';while(true){const jobs=await store.list({after,limit:100});if(!jobs.length)break;after=jobs.at(-1).id;for(const job of jobs){if(!job.evidence.length)continue;const checks=[];for(const item of job.evidence)if(item.evidence.transaction_hash){try{checks.push(await revalidate(job,item.evidence));}catch(error){checks.push({status:'unavailable',message:'Evidence provider unavailable'});}}
93
97
  const blocked=checks.some(c=>c.status!=='valid');const safety={blocked,checks,checked_at:new Date().toISOString()};await store.setSafety(job.id,safety);results.push({id:job.id,...safety});}}return results;}
94
- async function freshReceipts({workflow,id,actor,revalidate}){const job=await workflow.get(id,actor);const checks=[];for(const item of job.evidence)if(item.evidence.transaction_hash){const result=await revalidate(job,item.evidence);assert(result.status==='valid','Receipt evidence is not currently valid: '+result.status);checks.push(result);}return {...await workflow.receipts(id,actor),revalidated_at:new Date().toISOString(),checks};}
98
+ async function freshReceipts({workflow,id,actor,revalidate}){const job=await workflow.get(id,actor);for(const p of job.negotiation?.history||[]){const canonical=require('../closing.cjs').settlementProposal(job,{worker_amount:p.worker_amount,payer_amount:p.payer_amount,expected_revision:p.escrow_version-1,expires_at:p.expires_at,reason:p.reason,claims:p.claims});assert(canonical.proposal_hash===p.proposal_hash,'Stored settlement claims changed');}const checks=[];for(const item of job.evidence)if(item.evidence.transaction_hash){const result=await revalidate(job,item.evidence);assert(result.status==='valid','Receipt evidence is not currently valid: '+result.status);checks.push(result);}return {...await workflow.receipts(id,actor),revalidated_at:new Date().toISOString(),checks};}
95
99
  async function renderReceiptPdf(receipt){const {PDFDocument,StandardFonts}=require('pdf-lib');const pdf=await PDFDocument.create(),font=await pdf.embedFont(StandardFonts.Courier);let page,y;const newPage=()=>{page=pdf.addPage([595,842]);y=800;};newPage();const text=JSON.stringify(receipt,null,2).replace(/[^\x20-\x7e\n]/g,'?');for(const line of ['A423 Evidence Receipt',...text.split('\n')])for(let i=0;i<Math.max(1,line.length);i+=88){if(y<40)newPage();page.drawText(line.slice(i,i+88),{x:32,y,size:9,font});y-=12;}return pdf.save();}
96
100
  function planLegacyMigration(records){return records.map(record=>({id:record.id,state:record.state,automatic:false,reason:['funded','running','submitted','verified','paid','settlement_pending','completed'].includes(String(record.state).toLowerCase())?'Existing custody/evidence must stay with original contract; do not relabel':'Requires reviewed participant and accepted-terms mapping; create a new job with provenance'}));}
97
101
  module.exports={validateTask,validateEscrowEvidence,createSpendingAuthorizer,runMaintenanceOnce,createExpiryAdapter,runExecutionOnce,enqueueRunningJobs,createEventStream,createWebhookDispatcher,watchEvidenceOnce,freshReceipts,renderReceiptPdf,planLegacyMigration};
@@ -103,3 +107,10 @@ module.exports.migrateUnfundedLegacy=migrateUnfundedLegacy;
103
107
  Object.assign(module.exports,require('./application.cjs'));
104
108
 
105
109
  Object.assign(module.exports,require('./factory.cjs'));
110
+
111
+ async function freshCancellationReceipt({workflow,id,actor,revalidate}){
112
+ const job=await workflow.get(id,actor),receipt=await workflow.cancellationReceipt(id,actor);
113
+ require('../cancellation.cjs').validateCancellationReceipt(job,receipt);
114
+ const check=await revalidate(job,receipt.evidence);assert(check.status==='valid','Cancellation receipt evidence is not currently valid');return receipt;
115
+ }
116
+ module.exports.freshCancellationReceipt=freshCancellationReceipt;
@@ -15,7 +15,7 @@ export function createSpendingAuthorizer(options: {spending: SpendingStore;limit
15
15
  export function runMaintenanceOnce(options: {workflow: Workflow;store: SqliteJobStore;actorFor(job: WorkflowJob): Promise<string | null>;allowExecute?: boolean;now?: number}): Promise<Array<{id: string;state?: string;pending?: boolean;error?: string}>>;
16
16
  export function createExpiryAdapter(options?: {now?(): number}): {execute(context: {job: WorkflowJob}): Promise<any>;reconcile(context: {job: WorkflowJob}): Promise<any>};
17
17
  export function enqueueRunningJobs(options: {store: SqliteJobStore;queue: DurableQueue}): Promise<number>;
18
- export function runExecutionOnce(options: {workflow: Workflow;queue: DurableQueue;execute(context: {job: WorkflowJob;idempotencyKey: string}): Promise<{result_hash: string;[key: string]: Json}>;beforeExecute(job: WorkflowJob): Promise<boolean>;leaseMs?: number}): Promise<WorkflowJob | null>;
18
+ export function runExecutionOnce(options: {workflow: Workflow;queue: DurableQueue;execute(context: {job: WorkflowJob;idempotencyKey: string;signal: AbortSignal}): Promise<{result_hash: string;[key: string]: Json}>;beforeExecute(job: WorkflowJob): Promise<boolean>;leaseMs?: number}): Promise<WorkflowJob | null>;
19
19
  export function createEventStream(options: {store: SqliteJobStore;authenticate(request: Request): Promise<string | null>;pollMs?: number;maxDurationMs?: number}): (request: Request) => Promise<Response>;
20
20
  export function createWebhookDispatcher(options: {queue: DurableQueue;kv: KeyValueStore;subscriptions: Array<{id: string;actor: string;url: string;after?: number}>;secretFor(id: string): Promise<string>;fetch?: typeof fetch;timeoutMs?: number}): {project(store: SqliteJobStore): Promise<void>;deliverOne(): Promise<any>};
21
21
  export function watchEvidenceOnce(options: {store: SqliteJobStore;revalidate: Revalidator}): Promise<any[]>;
@@ -24,6 +24,8 @@ export function renderReceiptPdf(receipt: object): Promise<Uint8Array>;
24
24
  export function planLegacyMigration(records: Array<{id: string;state: string}>): Array<{id: string;state: string;automatic: false;reason: string}>;
25
25
  export function migrateUnfundedLegacy(options: {records: Array<{id: string;state: string}>;workflow: Workflow;actor: string;map(source: any): Promise<Parameters<Workflow['create']>[0]>}): Promise<WorkflowJob[]>;
26
26
  export function createChatTools(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;actor: string}): Record<string,(input: any) => Promise<WorkflowJob>>;
27
- export function createApplicationHandler(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;authenticate(request: Request): Promise<string | null>;revalidate: Revalidator;consentFor?(job: WorkflowJob): Promise<object>;legacyState?(actor: string): Promise<object>;authorityHandler?(request: Request): Promise<Response>;basePath?: string}): (request: Request) => Promise<Response>;
27
+ export function createApplicationHandler(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;authenticate(request: Request): Promise<string | null>;revalidate: Revalidator;consentFor?(job: WorkflowJob): Promise<object>;settlementConsentFor?(job: WorkflowJob): Promise<object>;legacyState?(actor: string): Promise<object>;authorityHandler?(request: Request): Promise<Response>;forwardCancellations?: boolean;basePath?: string}): (request: Request) => Promise<Response>;
28
28
 
29
- export function createEscrowApplication(options: {storage: import('../storage/index.js').SqliteStorage;authority: import('../authority/index.js').Authority;escrows: Array<any>;signerFor(actor: string): Promise<any>;addressFor(actor: string): Promise<string>;limitsFor(context: SigningContext): Promise<{perTransaction: string;daily: string;total: string}>;approveSigning(context: SigningContext): Promise<boolean>;validateArtifact(context: {job: WorkflowJob;resultHash: string}): Promise<boolean>;execute?(context: {job: WorkflowJob;idempotencyKey: string}): Promise<{result_hash: string;[key: string]: Json}>;keeperEnabled?: boolean;allowAuthorityRequest?(request: Request): Promise<boolean>;requireIndependentArbitrator?: boolean}): {workflow: Workflow;applicationOptions: Parameters<typeof createApplicationHandler>[0];workerOptions: any;handler: (request: Request) => Promise<Response>;chatTools(actor: string): ReturnType<typeof createChatTools>};
29
+ export function createEscrowApplication(options: {storage: import('../storage/index.js').SqliteStorage;authority: import('../authority/index.js').Authority;escrows: Array<any>;signerFor(actor: string): Promise<any>;addressFor(actor: string): Promise<string>;limitsFor(context: SigningContext): Promise<{perTransaction: string;daily: string;total: string}>;approveSigning(context: SigningContext): Promise<boolean>;validateArtifact(context: {job: WorkflowJob;resultHash: string}): Promise<boolean>;execute?(context: {job: WorkflowJob;idempotencyKey: string;signal: AbortSignal}): Promise<{result_hash: string;[key: string]: Json}>;keeperEnabled?: boolean;forwardCancellations?: boolean;allowAuthorityRequest?(request: Request): Promise<boolean>;requireIndependentArbitrator?: boolean}): {workflow: Workflow;applicationOptions: Parameters<typeof createApplicationHandler>[0];workerOptions: any;handler: (request: Request) => Promise<Response>;chatTools(actor: string): ReturnType<typeof createChatTools>};
30
+
31
+ export function freshCancellationReceipt(options: {workflow: Workflow;id: string;actor: string;revalidate: Revalidator}): Promise<import('../workflow.js').CancellationReceipt>;
@@ -1,2 +1,2 @@
1
1
  import api from './index.cjs';
2
- export const { validateTask, validateEscrowEvidence, createSpendingAuthorizer, runMaintenanceOnce, createExpiryAdapter, runExecutionOnce, enqueueRunningJobs, createEventStream, createWebhookDispatcher, watchEvidenceOnce, freshReceipts, renderReceiptPdf, planLegacyMigration, migrateUnfundedLegacy, createChatTools, createApplicationHandler, createEscrowApplication } = api;
2
+ export const { validateTask, validateEscrowEvidence, createSpendingAuthorizer, runMaintenanceOnce, createExpiryAdapter, runExecutionOnce, enqueueRunningJobs, createEventStream, createWebhookDispatcher, watchEvidenceOnce, freshReceipts, renderReceiptPdf, planLegacyMigration, migrateUnfundedLegacy, createChatTools, createApplicationHandler, createEscrowApplication, freshCancellationReceipt } = api;
package/src/workflow.cjs CHANGED
@@ -62,11 +62,13 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
62
62
  canonical(result);
63
63
  if (result.status === 'pending') {
64
64
  const next = copy(job); next.pending.observation = copy(result);
65
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'pending',evidence:result,at:stamp()});
65
66
  return save(job, next, pending.action + '.pending', pending.actor, result);
66
67
  }
67
68
  if (result.status === 'rejected') {
68
69
  const next = copy(job); next.pending = null;
69
70
  next.operations[pending.key] = { request_hash: pending.request_hash, status: 'rejected', result: copy(result) };
71
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'rejected',error:result.reason,at:stamp()});
70
72
  return save(job, next, pending.action + '.rejected', pending.actor, result);
71
73
  }
72
74
  if (!result.evidence || typeof result.evidence !== 'object' || Array.isArray(result.evidence)) error('INVALID_ADAPTER_RESULT', 'Confirmed effects require structured evidence', 502);
@@ -75,7 +77,37 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
75
77
  next.operations[pending.key] = { request_hash: pending.request_hash, status: 'confirmed', result: copy(result) };
76
78
  next.evidence.push({ action: pending.action, operation_id: pending.id, evidence: copy(result.evidence) });
77
79
  if (pending.action === 'submit') next.submission = copy(pending.input);
78
- return save(job, next, pending.action + '.confirmed', pending.actor, result.evidence);
80
+ let eventData=copy(result.evidence);
81
+ if(['propose_settlement','counter_settlement'].includes(pending.action)){
82
+ const proposal=require('./closing.cjs').settlementProposal(job,pending.input);
83
+ if(result.evidence.proposal_hash!==proposal.proposal_hash || result.evidence.escrow_version!==proposal.escrow_version)error('INVALID_ADAPTER_RESULT','Confirmed proposal must match exact claimed terms',502);
84
+ const record={...proposal,proposer_id:pending.actor,at:stamp()};
85
+ next.negotiation={status:'open',origin_state:job.negotiation?.origin_state||job.state,current:record,history:[...(job.negotiation?.history||[]),record]};
86
+ next.work_status='paused';eventData={...eventData,proposal:record};
87
+ }
88
+ if(pending.action==='approve_cancellation'){
89
+ const instruction=require('./cancellation.cjs').validateCancellationInstruction(job,result.evidence.instruction);
90
+ next.negotiation={...copy(job.negotiation),status:'accepted',acceptance_basis:'dual_wallet_signatures'};next.work_status='paused';
91
+ eventData={...eventData,instruction_id:instruction.id};
92
+ }
93
+ if(['accept_settlement','execute_cancellation'].includes(pending.action)){
94
+ const p=job.negotiation?.current;
95
+ if(!p || result.evidence.settlement_reason!==4 || result.evidence.worker_paid!==p.worker_amount || result.evidence.proposal_hash!==p.proposal_hash || result.evidence.escrow_version!==p.escrow_version)error('INVALID_ADAPTER_RESULT','Closing settlement evidence mismatch',502);
96
+ next.negotiation={...copy(job.negotiation),status:'settled',accepted_by:pending.actor,settled_at:stamp()};next.work_status='stopped';
97
+ }
98
+ if(pending.action==='dispute'){next.work_status='paused';if(next.negotiation)next.negotiation.status='disputed';}
99
+ if(['release','refund','resolve','timeout'].includes(pending.action)){
100
+ next.work_status=pending.action==='release'?'completed':'stopped';
101
+ if(next.negotiation)next.negotiation={...next.negotiation,status:'settled',settled_at:stamp()};
102
+ }
103
+ if(['start','submit','verify'].includes(pending.action))next.work_status={start:'running',submit:'submitted',verify:'verified'}[pending.action];
104
+ const e=result.evidence;
105
+ if(typeof e.amount==='string'&&typeof e.worker_paid==='string'&&Number.isInteger(e.state)){
106
+ const closed=[5,6,7].includes(e.state);
107
+ next.escrow_ledger={funded_amount:e.amount,worker_paid:e.worker_paid,payer_refunded:closed?(BigInt(e.amount)-BigInt(e.worker_paid)).toString():'0',locked_amount:closed?'0':e.amount,escrow_version:e.escrow_version??0};
108
+ }
109
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action:pending.action,actor:pending.actor,phase:'confirmed',evidence:result.evidence,instruction:result.evidence.instruction,at:stamp()});
110
+ return save(job, next, pending.action + '.confirmed', pending.actor, eventData);
79
111
  }
80
112
  async function execute(job, reconcile = false) {
81
113
  const pending = job.pending;
@@ -86,7 +118,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
86
118
  const result = await fn({ job: copy(job), input: copy(pending.input), operation: copy(pending) });
87
119
  return complete(job, result);
88
120
  }
89
- return {
121
+ const workflow={
90
122
  async create({ id = randomUUID(), participants, metadata = {} }, actor) {
91
123
  identifier(id); identifier(actor);
92
124
  if (!participants || !participants.requester || !participants.worker || !participants.verifier) throw new TypeError('Supply requester, worker and verifier participants');
@@ -94,13 +126,13 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
94
126
  if (participants.requester === participants.worker || actor !== participants.requester) error('FORBIDDEN', 'Requester must create the job and differ from worker', 403);
95
127
  canonical(metadata); canonical(participants);
96
128
  const at = stamp();
97
- const job = { id, protocol: 'a423.workflow.v1', version: 1, state: 'draft', participants: copy(participants), metadata: copy(metadata), agreement: null, submission: null, pending: null, operations: {}, evidence: [], events: [{ sequence: 1, type: 'created', actor_id: actor, at, data: {} }], created_at: at, updated_at: at };
129
+ const job = { subflows:[],active_subflow_id:null,work_status:'not_started',negotiation:null,escrow_ledger:null,id, protocol: 'a423.workflow.v1', version: 1, state: 'draft', participants: copy(participants), metadata: copy(metadata), agreement: null, submission: null, pending: null, operations: {}, evidence: [], events: [{ sequence: 1, type: 'created', actor_id: actor, at, data: {} }], created_at: at, updated_at: at };
98
130
  if (authorize && await authorize({ job: copy(job), action: 'create', actor, input: {} }) !== true) error('FORBIDDEN', 'Application policy rejected creation', 403);
99
131
  if (!await store.create(job)) error('CONFLICT', 'Job ID already exists');
100
132
  return copy(job);
101
133
  },
102
134
  get,
103
- async assertSafe(id,actor){const job=await get(id,actor);const safety=store.getSafety?await store.getSafety(id):null;if(safety?.blocked)error('SAFETY_HOLD','Evidence requires reconciliation',409);return job;},
135
+ async assertSafe(id,actor){const job=await get(id,actor);const safety=store.getSafety?await store.getSafety(id):null;if(safety?.blocked)error('SAFETY_HOLD','Evidence requires reconciliation',409);if(['propose_settlement','counter_settlement','dispute'].includes(job.pending?.action)||job.state==='settlement_negotiation')error('WORK_PAUSED','Settlement negotiation pauses work',409);return job;},
104
136
  async act(id, action, input, { actor, idempotencyKey } = {}) {
105
137
  identifier(actor); identifier(idempotencyKey); identifier(action); canonical(input);
106
138
  let job = await get(id, actor);
@@ -119,6 +151,9 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
119
151
  if (job.pending.key !== key || job.pending.request_hash !== requestHash) error('OPERATION_PENDING', 'Reconcile or retry the pending operation first');
120
152
  return execute(job);
121
153
  }
154
+ const cancellation=require('./cancellation.cjs').getCancellationSubflow(job);
155
+ if(['propose_settlement','counter_settlement'].includes(action)&&cancellation?.instruction)error('CANCELLATION_ACCEPTED','An accepted instruction must be executed or resolved before renegotiating',409);
156
+ if(action==='approve_cancellation'&&cancellation?.instruction){const candidate=require('./cancellation.cjs').createCancellationInstruction(job,input);if(input.expected_revision!==job.negotiation.current.escrow_version||input.proposal_hash!==job.negotiation.current.proposal_hash||hashAgreement(candidate)!==hashAgreement(cancellation.instruction))error('CANCELLATION_CONFLICT','Another instruction is already accepted',409);return job;}
122
157
  if (!rule.from.includes(job.state)) error('INVALID_STATE', `Cannot ${action} from ${job.state}`);
123
158
  let next = copy(job);
124
159
  if (action === 'quote') {
@@ -133,6 +168,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
133
168
  if (rule.effect) {
134
169
  if (!adapters[action]) error('ADAPTER_REQUIRED', `Configure ${action} adapter`, 501);
135
170
  next.pending = { id: `a423:${id}:${key}`, key, action, actor, input: copy(input), request_hash: requestHash, target: rule.to, created_at: stamp(), observation: null };
171
+ require('./cancellation.cjs').updateCancellationSubflow(job,next,{action,actor,phase:'requested',input,at:stamp()});
136
172
  job = await save(job, next, action + '.requested', actor);
137
173
  return execute(job);
138
174
  }
@@ -154,13 +190,25 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, valida
154
190
  await allowed(job, job.pending.action, actor, job.pending.input, rules[job.pending.action]);
155
191
  return execute(job, true);
156
192
  },
193
+ async cancellation(id,actor){return require('./cancellation.cjs').getCancellationSubflow(await get(id,actor));},
194
+ async cancellationInstruction(id,actor){const job=await get(id,actor),subflow=require('./cancellation.cjs').getCancellationSubflow(job);if(!subflow?.instruction)error('INSTRUCTION_UNAVAILABLE','No accepted cancellation instruction',409);return require('./cancellation.cjs').validateCancellationInstruction(job,subflow.instruction);},
195
+ async cancellationReceipt(id,actor){const job=await get(id,actor),subflow=require('./cancellation.cjs').getCancellationSubflow(job);if(subflow?.status!=='completed'||!subflow.receipt)error('RECEIPT_UNAVAILABLE','Cancellation payment is not confirmed',409);return require('./cancellation.cjs').validateCancellationReceipt(job,subflow.receipt);},
196
+ async acceptCancellation(id,input,{actor,idempotencyKey,forward=true}={}){
197
+ if(typeof forward!=='boolean')throw new TypeError('forward must be a boolean');
198
+ let job=await workflow.act(id,'approve_cancellation',input,{actor,idempotencyKey});
199
+ const subflow=require('./cancellation.cjs').getCancellationSubflow(job);
200
+ if(!forward||job.pending||!subflow?.instruction||subflow.status!=='accepted')return job;
201
+ return workflow.act(id,'execute_cancellation',{instruction_id:subflow.instruction.id},{actor,idempotencyKey:'cancel_forward_'+digest(subflow.instruction.id)});
202
+ },
157
203
  async receipts(id, actor) {
158
204
  const job = await get(id, actor);
159
- return { protocol: job.protocol, job_id: id, state: job.state, agreement: job.agreement, submission: job.submission, evidence: job.evidence, events: job.events, pending: job.pending };
205
+ return { protocol: job.protocol, job_id: id, state: job.state, agreement: job.agreement, submission: job.submission, subflows:job.subflows||[],active_subflow_id:job.active_subflow_id||null,work_status:job.work_status,negotiation:job.negotiation,escrow_ledger:job.escrow_ledger,evidence: job.evidence, events: job.events, pending: job.pending };
160
206
  }
161
207
  };
208
+ return workflow;
162
209
  }
163
- function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError } = {}) {
210
+ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError, forwardCancellations = true, revalidateCancellationReceipt } = {}) {
211
+ if(typeof forwardCancellations!=='boolean')throw new TypeError('forwardCancellations must be a boolean');
164
212
  if (!workflow || typeof authenticate !== 'function' || !/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(basePath)) throw new TypeError('Supply workflow, authenticate and valid basePath');
165
213
  if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || maxBodyBytes > 1048576) throw new TypeError('Invalid maxBodyBytes');
166
214
  const respond = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
@@ -168,10 +216,10 @@ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423',
168
216
  try {
169
217
  const path = new URL(request.url).pathname;
170
218
  if (!path.startsWith(basePath + '/')) error('NOT_FOUND', 'Route not found', 404);
171
- const match = /^\/jobs(?:\/([A-Za-z0-9_-]{1,128})(?:\/(actions|reconcile|receipts))?)?$/.exec(path.slice(basePath.length));
219
+ const match = /^\/jobs(?:\/([A-Za-z0-9_-]{1,128})(?:\/(actions|reconcile|receipts|subflows|cancellation(?:\/(?:accept|execute|instruction|receipt))?))?)?$/.exec(path.slice(basePath.length));
172
220
  if (!match) error('NOT_FOUND', 'Route not found', 404);
173
221
  const [, id, action] = match;
174
- const method = !id || action === 'actions' || action === 'reconcile' ? 'POST' : 'GET';
222
+ const method = !id || ['actions','reconcile','cancellation/accept','cancellation/execute'].includes(action) ? 'POST' : 'GET';
175
223
  if (request.method !== method) { const r = respond({ error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method}` } }, 405); r.headers.set('allow', method); return r; }
176
224
  const actor = await authenticate(request);
177
225
  if (!actor) error('UNAUTHENTICATED', 'Authentication required', 401);
@@ -184,6 +232,16 @@ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423',
184
232
  try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { error('INVALID_INPUT', 'Invalid JSON', 400); }
185
233
  }
186
234
  if (!id) return respond({ job: await workflow.create(body, actor) }, 201);
235
+ if(action==='subflows'){const job=await workflow.get(id,actor);return respond({job_id:id,subflows:job.subflows||[],active_subflow_id:job.active_subflow_id||null});}
236
+ if(action==='cancellation')return respond({job_id:id,subflow:await workflow.cancellation(id,actor)});
237
+ if(action==='cancellation/instruction')return respond({instruction:await workflow.cancellationInstruction(id,actor)});
238
+ if(action==='cancellation/receipt'){
239
+ const receipt=await workflow.cancellationReceipt(id,actor);
240
+ if(revalidateCancellationReceipt)await revalidateCancellationReceipt({job:await workflow.get(id,actor),receipt,actor});
241
+ return respond({receipt});
242
+ }
243
+ if(action==='cancellation/accept')return respond({job:await workflow.acceptCancellation(id,body,{actor,idempotencyKey:request.headers.get('idempotency-key'),forward:forwardCancellations})});
244
+ if(action==='cancellation/execute')return respond({job:await workflow.act(id,'execute_cancellation',body,{actor,idempotencyKey:request.headers.get('idempotency-key')})});
187
245
  if (action === 'actions') return respond({ job: await workflow.act(id, body.action, body.input ?? {}, { actor, idempotencyKey: request.headers.get('idempotency-key') }) });
188
246
  if (action === 'reconcile') return respond({ job: await workflow.reconcile(id, actor) });
189
247
  if (action === 'receipts') return respond(await workflow.receipts(id, actor));
@@ -216,12 +274,24 @@ function createWorkflowClient({ baseUrl, headers, fetch: fetchImpl = globalThis.
216
274
  return data;
217
275
  }
218
276
  const path = id => '/jobs/' + identifier(id);
219
- return {
277
+ const workflow={
220
278
  async create(input) { return (await request('/jobs',input)).job; },
221
279
  async get(id) { return (await request(path(id))).job; },
222
280
  async act(id,action,input,{idempotencyKey}={}) { identifier(action);identifier(idempotencyKey);return (await request(path(id)+'/actions',{action,input},idempotencyKey)).job; },
223
281
  async reconcile(id) { return (await request(path(id)+'/reconcile',{})).job; },
282
+ async cancellation(id){return (await request(path(id)+'/cancellation')).subflow;},
283
+ async cancellationInstruction(id){return (await request(path(id)+'/cancellation/instruction')).instruction;},
284
+ async cancellationReceipt(id){return (await request(path(id)+'/cancellation/receipt')).receipt;},
285
+ async subflows(id){return (await request(path(id)+'/subflows')).subflows;},
286
+ async acceptCancellation(id,input,{idempotencyKey}={}){identifier(idempotencyKey);return (await request(path(id)+'/cancellation/accept',input,idempotencyKey)).job;},
287
+ async executeCancellation(id,input,{idempotencyKey}={}){identifier(idempotencyKey);return (await request(path(id)+'/cancellation/execute',input,idempotencyKey)).job;},
224
288
  async receipts(id) { return request(path(id)+'/receipts'); }
225
289
  };
290
+ return workflow;
226
291
  }
227
292
  module.exports.createWorkflowClient = createWorkflowClient;
293
+
294
+ module.exports.getCancellationSubflow=(job)=>require('./cancellation.cjs').getCancellationSubflow(job);
295
+ module.exports.createCancellationInstruction=(job,proofs)=>require('./cancellation.cjs').createCancellationInstruction(job,proofs);
296
+ module.exports.validateCancellationInstruction=(job,instruction)=>require('./cancellation.cjs').validateCancellationInstruction(job,instruction);
297
+ module.exports.validateCancellationReceipt=(job,receipt)=>require('./cancellation.cjs').validateCancellationReceipt(job,receipt);