@allorc/a423-sdk 0.2.0 → 0.4.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.
- package/CHANGELOG.md +18 -0
- package/ESCROW.md +162 -0
- package/INTEGRATION.md +14 -2
- package/LEGACY.md +251 -0
- package/NOTICE +24 -0
- package/OPERATIONS.md +182 -0
- package/README.md +65 -311
- package/SECURITY.md +11 -5
- package/contracts/A423Escrow.sol +135 -0
- package/contracts/standard-input.json +80 -0
- package/examples/operations-dashboard.html +16 -0
- package/examples/operations-server.mjs +19 -0
- package/package.json +67 -5
- package/scripts/deploy-escrow.mjs +24 -0
- package/scripts/operations-worker.mjs +22 -0
- package/src/authority/index.cjs +81 -0
- package/src/authority/index.d.ts +24 -0
- package/src/authority/index.mjs +2 -0
- package/src/escrow/artifact.json +661 -0
- package/src/escrow/index.cjs +150 -0
- package/src/escrow/index.d.mts +48 -0
- package/src/escrow/index.d.ts +48 -0
- package/src/escrow/index.mjs +2 -0
- package/src/escrow/services.cjs +119 -0
- package/src/operations/application.cjs +38 -0
- package/src/operations/factory.cjs +33 -0
- package/src/operations/index.cjs +105 -0
- package/src/operations/index.d.ts +29 -0
- package/src/operations/index.mjs +2 -0
- package/src/storage/index.cjs +83 -0
- package/src/storage/index.d.ts +37 -0
- package/src/storage/index.mjs +2 -0
- package/src/workflow.cjs +14 -2
- package/src/workflow.d.ts +3 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Contract, Interface, TypedDataEncoder, Transaction, getAddress, sha256, toUtf8Bytes, ZeroHash } = require('ethers');
|
|
3
|
+
const { hashAgreement } = require('../workflow.cjs');
|
|
4
|
+
const artifact = require('./artifact.json');
|
|
5
|
+
const ABI = artifact.abi;
|
|
6
|
+
const TOKEN_ABI = ['function allowance(address,address) view returns(uint256)','function approve(address,uint256) returns(bool)'];
|
|
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
|
+
const transitions = {
|
|
9
|
+
dispute: {from:['submitted','verified'],roles:['requester','worker','verifier'],to:'disputed',effect:true},
|
|
10
|
+
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}
|
|
12
|
+
};
|
|
13
|
+
function check(condition,message) { if(!condition) throw new Error('Escrow: '+message); }
|
|
14
|
+
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; }
|
|
15
|
+
function bytes32(value) { check(typeof value==='string' && /^0x[0-9a-fA-F]{64}$/.test(value) && value!==ZeroHash,'nonzero bytes32 required');return value; }
|
|
16
|
+
function escrowTerms(job,{chainId,contractAddress,tokenAddress}) {
|
|
17
|
+
check(job.agreement && hashAgreement(job.agreement.terms)===job.agreement.hash,'agreement hash mismatch');
|
|
18
|
+
const e=job.agreement.terms.escrow;
|
|
19
|
+
check(e && e.chain_id===String(chainId) && getAddress(e.contract)===getAddress(contractAddress) && getAddress(e.token)===getAddress(tokenAddress),'chain, contract or token mismatch');
|
|
20
|
+
const integer=(value,name)=>{check(Number.isSafeInteger(value)&&value>0,name+' must be positive integer seconds');return value;};
|
|
21
|
+
const t={jobId:sha256(toUtf8Bytes('a423:escrow:v1:'+job.id)),payer:getAddress(e.payer),worker:getAddress(e.worker),verifier:getAddress(e.verifier),arbitrator:getAddress(e.arbitrator),amount:positive(e.amount,'amount'),workDeadline:integer(e.work_deadline,'work_deadline'),reviewDeadline:integer(e.review_deadline,'review_deadline'),resolutionDeadline:integer(e.resolution_deadline,'resolution_deadline'),timeoutWorkerBps:e.timeout_worker_bps,agreementHash:bytes32('0x'+job.agreement.hash)};
|
|
22
|
+
check(t.workDeadline<t.reviewDeadline && t.reviewDeadline<t.resolutionDeadline,'deadlines must be increasing');
|
|
23
|
+
check(Number.isInteger(t.timeoutWorkerBps)&&t.timeoutWorkerBps>=0&&t.timeoutWorkerBps<=10000,'invalid timeout allocation');
|
|
24
|
+
check(t.payer!==t.worker && ![t.payer,t.worker].includes(t.arbitrator),'invalid party separation');
|
|
25
|
+
return t;
|
|
26
|
+
}
|
|
27
|
+
function workerConsent(job,configuration) { const terms=escrowTerms(job,configuration);return {domain:{name:'A423Escrow',version:'1',chainId:configuration.chainId,verifyingContract:getAddress(configuration.contractAddress)},types:TERMS_TYPES,value:terms}; }
|
|
28
|
+
/** Test-only journal. Production must persist records and reserve nonces atomically across instances. */
|
|
29
|
+
function createMemoryTransactionStore() {
|
|
30
|
+
const records=new Map(),nonces=new Map(),reservations=new Map();
|
|
31
|
+
return {
|
|
32
|
+
async get(id){return records.has(id)?structuredClone(records.get(id)):null;},
|
|
33
|
+
async create(id,record){if(records.has(id))return false;records.set(id,structuredClone(record));return true;},
|
|
34
|
+
async list(){return [...records].map(([id,record])=>({id,...structuredClone(record)}));},
|
|
35
|
+
async appendReplacement(id,expectedHash,replacement){const record=records.get(id);check(record&&(record.replacements?.at(-1)?.hash||record.hash)===expectedHash,'Replacement conflict');record.replacements=[...(record.replacements||[]),structuredClone(replacement)];},
|
|
36
|
+
async reserveNonce(scope,minimum,operationId){const key=scope+':'+operationId;if(reservations.has(key))return reservations.get(key);const nonce=Math.max(minimum,nonces.get(scope)||0);nonces.set(scope,nonce+1);reservations.set(key,nonce);return nonce;}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,signerFor,addressFor,transactions,confirmations,validateArtifact,authorizeTransaction}={}) {
|
|
40
|
+
check(typeof authorizeTransaction==='function','authorizeTransaction is required before any signing or broadcast');
|
|
41
|
+
check(provider && typeof signerFor==='function' && typeof addressFor==='function','provider, signerFor and addressFor required');
|
|
42
|
+
check(Number.isSafeInteger(chainId)&&chainId>0 && Number.isSafeInteger(confirmations)&&confirmations>0,'explicit chain ID and positive confirmations required');
|
|
43
|
+
check(transactions && ['get','create','reserveNonce'].every(k=>typeof transactions[k]==='function'),'durable transaction journal required');
|
|
44
|
+
const config={chainId,contractAddress:getAddress(contractAddress),tokenAddress:getAddress(tokenAddress)};
|
|
45
|
+
const contract=new Contract(config.contractAddress,ABI,provider), iface=new Interface(ABI), token=new Contract(config.tokenAddress,TOKEN_ABI,provider);
|
|
46
|
+
async function context(job) {
|
|
47
|
+
check(Number((await provider.getNetwork()).chainId)===chainId,'RPC chain mismatch');
|
|
48
|
+
check(getAddress(await contract.token())===config.tokenAddress,'contract token mismatch');
|
|
49
|
+
const t=escrowTerms(job,config);
|
|
50
|
+
for(const [role,field] of [['requester','payer'],['worker','worker'],['verifier','verifier'],['arbitrator','arbitrator']]) check(job.participants[role] && getAddress(await addressFor(job.participants[role]))===t[field],role+' wallet mismatch');
|
|
51
|
+
return t;
|
|
52
|
+
}
|
|
53
|
+
function match(snapshot,t) {
|
|
54
|
+
for(const key of Object.keys(t)) check(String(snapshot.terms[key]).toLowerCase()===String(t[key]).toLowerCase(),'on-chain '+key+' mismatch');
|
|
55
|
+
}
|
|
56
|
+
async function receipt(hash) {
|
|
57
|
+
const r=await provider.getTransactionReceipt(hash);
|
|
58
|
+
if(!r)return null;
|
|
59
|
+
const head=await provider.getBlock('latest');
|
|
60
|
+
const block=await provider.getBlock(r.blockNumber);
|
|
61
|
+
if(!block || block.hash!==r.blockHash || head.number-r.blockNumber+1<confirmations)return null;
|
|
62
|
+
return r;
|
|
63
|
+
}
|
|
64
|
+
async function send(ctx,suffix,to,data,reconcile) {
|
|
65
|
+
const id=ctx.operation.id+':'+suffix;
|
|
66
|
+
let record=await transactions.get(id);
|
|
67
|
+
if(!record && reconcile)return {status:'pending',reference:id};
|
|
68
|
+
if(!record) {
|
|
69
|
+
if(await authorizeTransaction({job:structuredClone(ctx.job),operation:structuredClone(ctx.operation),actor:ctx.operation.actor,step:suffix,to:getAddress(to),data,phase:'sign'})!==true)return {status:'rejected',reason:'Signing policy denied before signing'};
|
|
70
|
+
const signer=await signerFor(ctx.operation.actor);
|
|
71
|
+
const address=getAddress(await signer.getAddress());
|
|
72
|
+
check(address===getAddress(await addressFor(ctx.operation.actor)),'signer ownership mismatch');
|
|
73
|
+
const minimum=await provider.getTransactionCount(address,'pending');
|
|
74
|
+
let request;
|
|
75
|
+
try {request=await signer.populateTransaction({to,data,nonce:minimum,chainId});}
|
|
76
|
+
catch(err) {if(err.code==='CALL_EXCEPTION' || err.code==='INSUFFICIENT_FUNDS')return {status:'rejected',reason:'Transaction preflight rejected before signing'};throw err;}
|
|
77
|
+
const nonce=await transactions.reserveNonce(`${chainId}:${address}`,minimum,id);
|
|
78
|
+
request.nonce=nonce;
|
|
79
|
+
const raw=await signer.signTransaction(request);
|
|
80
|
+
const tx=Transaction.from(raw);
|
|
81
|
+
check(tx.from===address && Number(tx.chainId)===chainId && tx.to===getAddress(to) && tx.data===data && tx.nonce===nonce && tx.value===0n,'signed transaction mismatch');
|
|
82
|
+
await transactions.create(id,{hash:tx.hash,raw,to:getAddress(to),data,from:address,created_at:new Date().toISOString()});
|
|
83
|
+
record=await transactions.get(id);
|
|
84
|
+
}
|
|
85
|
+
check(record.to===getAddress(to) && record.data===data && record.from===getAddress(await addressFor(ctx.operation.actor)),'journal request mismatch');
|
|
86
|
+
const attempts=[record,...(record.replacements||[])];
|
|
87
|
+
let active=attempts.at(-1),r=null;
|
|
88
|
+
for(const attempt of attempts){const observed=await receipt(attempt.hash);if(observed){r=observed;active=attempt;break;}}
|
|
89
|
+
if(!r && !reconcile) {
|
|
90
|
+
check(await authorizeTransaction({job:structuredClone(ctx.job),operation:structuredClone(ctx.operation),actor:ctx.operation.actor,step:suffix,to:getAddress(to),data,phase:'broadcast'})===true,'broadcast policy denied');
|
|
91
|
+
try {await provider.broadcastTransaction(active.raw);} catch(err) {if(!await provider.getTransaction(active.hash))throw err;}
|
|
92
|
+
r=await receipt(active.hash);
|
|
93
|
+
}
|
|
94
|
+
if(!r)return {status:'pending',reference:active.hash};
|
|
95
|
+
if(r.status!==1)return {status:'rejected',reason:'Transaction reverted',reference:active.hash};
|
|
96
|
+
return {receipt:r};
|
|
97
|
+
}
|
|
98
|
+
async function observe(ctx,t,suffix,method,args,eventName,validate,reconcile) {
|
|
99
|
+
let sent;
|
|
100
|
+
if(ctx.input.transaction_hash) {
|
|
101
|
+
check(/^0x[0-9a-fA-F]{64}$/.test(ctx.input.transaction_hash),'invalid transaction hash');
|
|
102
|
+
const r=await receipt(ctx.input.transaction_hash);
|
|
103
|
+
sent=!r?{status:'pending',reference:ctx.input.transaction_hash}:r.status===1?{receipt:r}:{status:'rejected',reason:'Transaction reverted',reference:ctx.input.transaction_hash};
|
|
104
|
+
} else sent=await send(ctx,suffix,config.contractAddress,iface.encodeFunctionData(method,args),reconcile);
|
|
105
|
+
if(!sent.receipt)return sent;
|
|
106
|
+
const r=sent.receipt;
|
|
107
|
+
const logs=r.logs.filter(log=>getAddress(log.address)===config.contractAddress).map(log=>{try{return iface.parseLog(log);}catch{return null;}});
|
|
108
|
+
const event=logs.find(log=>log?.name===eventName && log.args.jobId===t.jobId);
|
|
109
|
+
check(event,'matching '+eventName+' event missing');
|
|
110
|
+
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()})};
|
|
112
|
+
}
|
|
113
|
+
function adapter(run){return {execute:ctx=>run(ctx,false),reconcile:ctx=>run(ctx,true)};}
|
|
114
|
+
const state=(s,n)=>check(Number(s.state)===n,'unexpected escrow state');
|
|
115
|
+
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');};
|
|
116
|
+
return {
|
|
117
|
+
fund:adapter(async(ctx,reconcile)=>{
|
|
118
|
+
const t=await context(ctx.job);
|
|
119
|
+
check([ctx.job.participants.requester,ctx.job.participants.worker].every(p=>ctx.job.agreement.accepted_by.includes(p)),'both acceptances required');
|
|
120
|
+
if(typeof ctx.input.worker_signature!=='string')return {status:'rejected',reason:'worker_signature required'};
|
|
121
|
+
// Consent is checked locally as well as by the contract before moving tokens.
|
|
122
|
+
const {verifyTypedData}=require('ethers');const c=workerConsent(ctx.job,config);
|
|
123
|
+
const digest=TypedDataEncoder.hash(c.domain,c.types,c.value);
|
|
124
|
+
if(await provider.getCode(t.worker)!=='0x'){
|
|
125
|
+
const smart=new Contract(t.worker,['function isValidSignature(bytes32,bytes) view returns(bytes4)'],provider);
|
|
126
|
+
try {if(await smart.isValidSignature(digest,ctx.input.worker_signature)!=='0x1626ba7e')return {status:'rejected',reason:'Invalid smart-account worker consent'};}catch(err){if(err.code==='CALL_EXCEPTION')return {status:'rejected',reason:'Smart-account consent reverted'};throw err;}
|
|
127
|
+
}else {try {if(getAddress(verifyTypedData(c.domain,c.types,c.value,ctx.input.worker_signature))!==t.worker)return {status:'rejected',reason:'Invalid worker consent'};} catch {return {status:'rejected',reason:'Invalid worker consent'};}}
|
|
128
|
+
// Never send further approvals if the funding transaction already exists in the journal.
|
|
129
|
+
if(!ctx.input.transaction_hash && !await transactions.get(ctx.operation.id+':fund')) {
|
|
130
|
+
let allowance=await token.allowance(t.payer,config.contractAddress);
|
|
131
|
+
if(allowance<t.amount) {
|
|
132
|
+
if(allowance>0n){const reset=await send(ctx,'approve_zero',config.tokenAddress,token.interface.encodeFunctionData('approve',[config.contractAddress,0]),reconcile);if(!reset.receipt)return reset;}
|
|
133
|
+
const approval=await send(ctx,'approve',config.tokenAddress,token.interface.encodeFunctionData('approve',[config.contractAddress,t.amount]),reconcile);if(!approval.receipt)return approval;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return observe(ctx,t,'fund','fund',[t,ctx.input.worker_signature],'Funded',(s,e)=>{check(Number(s.state)>0,'missing funded job');check(e.args.amount===t.amount && getAddress(e.args.payer)===t.payer && getAddress(e.args.worker)===t.worker && e.args.agreementHash===t.agreementHash,'funding event mismatch');},reconcile);
|
|
137
|
+
}),
|
|
138
|
+
start:adapter(async ctx=>{const t=await context(ctx.job);const block=await provider.getBlock('latest');const safeNumber=block.number-confirmations+1;check(safeNumber>=0,'insufficient confirmation depth');const safe=await provider.getBlock(safeNumber);const confirmed=await contract.getJob(t.jobId,{blockTag:safeNumber});match(confirmed,t);state(confirmed,1);const s=await contract.getJob(t.jobId,{blockTag:block.number});match(s,t);state(s,1);check(block.timestamp<=t.workDeadline,'work deadline passed');return {status:'confirmed',evidence:{chain_id:String(chainId),contract:config.contractAddress,job_id:t.jobId,block_number:safe.number,block_hash:safe.hash,confirmations,amount:t.amount.toString(),agreement_hash:t.agreementHash}};}),
|
|
139
|
+
submit:adapter(async(ctx,r)=>{const t=await context(ctx.job);const hash=bytes32(ctx.input.result_hash);return observe(ctx,t,'submit','submit',[t.jobId,hash],'Submitted',(s,e)=>{check(Number(s.state)>=2,'missing submission');check(s.resultHash===hash && e.args.resultHash===hash,'artifact commitment mismatch');},r);}),
|
|
140
|
+
verify:adapter(async(ctx,r)=>{const t=await context(ctx.job);const hash=bytes32(ctx.job.submission?.result_hash);if(!r && !await transactions.get(ctx.operation.id+':verify')){check(typeof validateArtifact==='function','validateArtifact callback required');if(await validateArtifact({job:structuredClone(ctx.job),resultHash:hash})!==true)return {status:'rejected',reason:'Artifact verification failed'};}return observe(ctx,t,'verify','verify',[t.jobId,hash],'Verified',(s,e)=>{check(Number(s.state)>=3,'missing verification');check(s.resultHash===hash && e.args.resultHash===hash,'verified artifact mismatch');},r);}),
|
|
141
|
+
release:adapter(async(ctx,r)=>{const t=await context(ctx.job);return observe(ctx,t,'release','release',[t.jobId],'Settled',(s,e)=>{state(s,5);settled(s,e,t);check(e.args.workerAmount===t.amount && Number(e.args.reason)===0,'not a full release');},r);}),
|
|
142
|
+
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
|
+
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
|
+
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);})
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
module.exports={createEscrowAdapters,createMemoryTransactionStore,workerConsent,escrowTerms,escrowTransitions:transitions,ESCROW_ABI:ABI,ESCROW_BYTECODE:artifact.bytecode};
|
|
149
|
+
|
|
150
|
+
Object.assign(module.exports,require('./services.cjs'));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Provider, Signer, TypedDataField, InterfaceAbi } from 'ethers';
|
|
2
|
+
import type { WorkflowJob, AdapterContext, AdapterResult, Transition } from '../workflow.js';
|
|
3
|
+
export interface EscrowConfiguration { chainId: number; contractAddress: string; tokenAddress: string }
|
|
4
|
+
export interface ContractTerms {
|
|
5
|
+
jobId: string; payer: string; worker: string; verifier: string; arbitrator: string;
|
|
6
|
+
amount: bigint; workDeadline: number; reviewDeadline: number; resolutionDeadline: number;
|
|
7
|
+
timeoutWorkerBps: number; agreementHash: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TransactionRecord { created_at?: string; replacements?: TransactionRecord[]; hash: string; raw: string; to: string; data: string; from: string }
|
|
10
|
+
export interface TransactionStore {
|
|
11
|
+
get(id: string): Promise<TransactionRecord | null>;
|
|
12
|
+
list(): Promise<Array<TransactionRecord & {id: string}>>;
|
|
13
|
+
appendReplacement(id: string, expectedHash: string, replacement: TransactionRecord): Promise<void>;
|
|
14
|
+
/** Atomic insert if absent; caller reads winner before broadcasting. */
|
|
15
|
+
create(id: string, record: TransactionRecord): Promise<boolean>;
|
|
16
|
+
/** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
|
|
17
|
+
reserveNonce(scope: string, minimum: number, operationId: string): Promise<number>;
|
|
18
|
+
}
|
|
19
|
+
export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult> }
|
|
20
|
+
export interface EscrowAdapterOptions extends EscrowConfiguration {
|
|
21
|
+
provider: Provider;
|
|
22
|
+
/** Requires populateTransaction + offline signTransaction support; no silent wallet approval. */
|
|
23
|
+
signerFor(actor: string): Promise<Signer>;
|
|
24
|
+
addressFor(actor: string): Promise<string>;
|
|
25
|
+
transactions: TransactionStore;
|
|
26
|
+
authorizeTransaction(context: { job: WorkflowJob; operation: AdapterContext['operation']; actor: string; step: string; to: string; data: string; phase: 'sign' | 'broadcast' }): Promise<boolean>;
|
|
27
|
+
confirmations: number;
|
|
28
|
+
validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout', EscrowAdapter>;
|
|
31
|
+
export function createMemoryTransactionStore(): TransactionStore;
|
|
32
|
+
export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
|
|
33
|
+
export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
|
|
34
|
+
domain: { name: string; version: string; chainId: number; verifyingContract: string };
|
|
35
|
+
types: Record<string, TypedDataField[]>; value: ContractTerms;
|
|
36
|
+
};
|
|
37
|
+
export const escrowTransitions: Record<string, Transition>;
|
|
38
|
+
export const ESCROW_ABI: InterfaceAbi;
|
|
39
|
+
export const ESCROW_BYTECODE: string;
|
|
40
|
+
|
|
41
|
+
export function createEscrowRegistry(configurations: EscrowAdapterOptions[]): ReturnType<typeof createEscrowAdapters>;
|
|
42
|
+
export function createEvidenceRevalidator(options: EscrowConfiguration & {provider: Provider;confirmations: number}): (job: WorkflowJob,evidence: any) => Promise<{status: 'valid' | 'invalid' | 'insufficient_confirmations';[key: string]: unknown}>;
|
|
43
|
+
export function createManagedSigner(options: {address: string;provider: Provider;signDigest(digest: string,context: any): Promise<string>;authorize(context: any): Promise<boolean>}): Signer;
|
|
44
|
+
export function createDelegationAuthorizer(options: {expiresAt: number;chainIds: number[];contracts: string[];selectors: string[];maxValue?: string;maxFeePerGas: string;maxGasLimit: string;now?(): number}): (context: any) => Promise<boolean>;
|
|
45
|
+
export function replacePendingTransaction(options: {id: string;transactions: TransactionStore;provider: Provider;signer: Signer;authorize(context: any): Promise<boolean>;maxFeePerGas: bigint;maxPriorityFeePerGas: bigint}): Promise<{hash: string;raw: string}>;
|
|
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
|
+
|
|
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}>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Provider, Signer, TypedDataField, InterfaceAbi } from 'ethers';
|
|
2
|
+
import type { WorkflowJob, AdapterContext, AdapterResult, Transition } from '../workflow.js';
|
|
3
|
+
export interface EscrowConfiguration { chainId: number; contractAddress: string; tokenAddress: string }
|
|
4
|
+
export interface ContractTerms {
|
|
5
|
+
jobId: string; payer: string; worker: string; verifier: string; arbitrator: string;
|
|
6
|
+
amount: bigint; workDeadline: number; reviewDeadline: number; resolutionDeadline: number;
|
|
7
|
+
timeoutWorkerBps: number; agreementHash: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TransactionRecord { created_at?: string; replacements?: TransactionRecord[]; hash: string; raw: string; to: string; data: string; from: string }
|
|
10
|
+
export interface TransactionStore {
|
|
11
|
+
get(id: string): Promise<TransactionRecord | null>;
|
|
12
|
+
list(): Promise<Array<TransactionRecord & {id: string}>>;
|
|
13
|
+
appendReplacement(id: string, expectedHash: string, replacement: TransactionRecord): Promise<void>;
|
|
14
|
+
/** Atomic insert if absent; caller reads winner before broadcasting. */
|
|
15
|
+
create(id: string, record: TransactionRecord): Promise<boolean>;
|
|
16
|
+
/** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
|
|
17
|
+
reserveNonce(scope: string, minimum: number, operationId: string): Promise<number>;
|
|
18
|
+
}
|
|
19
|
+
export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult> }
|
|
20
|
+
export interface EscrowAdapterOptions extends EscrowConfiguration {
|
|
21
|
+
provider: Provider;
|
|
22
|
+
/** Requires populateTransaction + offline signTransaction support; no silent wallet approval. */
|
|
23
|
+
signerFor(actor: string): Promise<Signer>;
|
|
24
|
+
addressFor(actor: string): Promise<string>;
|
|
25
|
+
transactions: TransactionStore;
|
|
26
|
+
authorizeTransaction(context: { job: WorkflowJob; operation: AdapterContext['operation']; actor: string; step: string; to: string; data: string; phase: 'sign' | 'broadcast' }): Promise<boolean>;
|
|
27
|
+
confirmations: number;
|
|
28
|
+
validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout', EscrowAdapter>;
|
|
31
|
+
export function createMemoryTransactionStore(): TransactionStore;
|
|
32
|
+
export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
|
|
33
|
+
export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
|
|
34
|
+
domain: { name: string; version: string; chainId: number; verifyingContract: string };
|
|
35
|
+
types: Record<string, TypedDataField[]>; value: ContractTerms;
|
|
36
|
+
};
|
|
37
|
+
export const escrowTransitions: Record<string, Transition>;
|
|
38
|
+
export const ESCROW_ABI: InterfaceAbi;
|
|
39
|
+
export const ESCROW_BYTECODE: string;
|
|
40
|
+
|
|
41
|
+
export function createEscrowRegistry(configurations: EscrowAdapterOptions[]): ReturnType<typeof createEscrowAdapters>;
|
|
42
|
+
export function createEvidenceRevalidator(options: EscrowConfiguration & {provider: Provider;confirmations: number}): (job: WorkflowJob,evidence: any) => Promise<{status: 'valid' | 'invalid' | 'insufficient_confirmations';[key: string]: unknown}>;
|
|
43
|
+
export function createManagedSigner(options: {address: string;provider: Provider;signDigest(digest: string,context: any): Promise<string>;authorize(context: any): Promise<boolean>}): Signer;
|
|
44
|
+
export function createDelegationAuthorizer(options: {expiresAt: number;chainIds: number[];contracts: string[];selectors: string[];maxValue?: string;maxFeePerGas: string;maxGasLimit: string;now?(): number}): (context: any) => Promise<boolean>;
|
|
45
|
+
export function replacePendingTransaction(options: {id: string;transactions: TransactionStore;provider: Provider;signer: Signer;authorize(context: any): Promise<boolean>;maxFeePerGas: bigint;maxPriorityFeePerGas: bigint}): Promise<{hash: string;raw: string}>;
|
|
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
|
+
|
|
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}>;
|
|
@@ -0,0 +1,2 @@
|
|
|
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;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {Contract,Interface,AbstractSigner,Transaction,Signature,verifyMessage,verifyTypedData,recoverAddress,hashMessage,TypedDataEncoder,getAddress,ZeroHash}=require('ethers');
|
|
3
|
+
const {ESCROW_ABI,escrowTerms,createEscrowAdapters}=require('./index.cjs');
|
|
4
|
+
const {validateEscrowEvidence}=require('../operations/index.cjs');
|
|
5
|
+
function check(ok,message){if(!ok)throw Error(message);}
|
|
6
|
+
function createEscrowRegistry(configurations){
|
|
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;
|
|
9
|
+
}
|
|
10
|
+
/** Fresh historical evidence verification. Returns invalid, insufficient_confirmations, or valid; RPC failures throw. */
|
|
11
|
+
function createEvidenceRevalidator({provider,chainId,contractAddress,tokenAddress,confirmations}={}){
|
|
12
|
+
const config={chainId,contractAddress:getAddress(contractAddress),tokenAddress:getAddress(tokenAddress)},contract=new Contract(config.contractAddress,ESCROW_ABI,provider),iface=new Interface(ESCROW_ABI);
|
|
13
|
+
check(Number.isSafeInteger(confirmations)&&confirmations>0,'Explicit confirmations required');
|
|
14
|
+
return async(job,evidence)=>{
|
|
15
|
+
validateEscrowEvidence(evidence);const t=escrowTerms(job,config);
|
|
16
|
+
if(Number((await provider.getNetwork()).chainId)!==chainId)throw Error('RPC chain mismatch');
|
|
17
|
+
if(getAddress(await contract.token())!==config.tokenAddress)throw Error('Escrow token mismatch');
|
|
18
|
+
const invalid=message=>({status:'invalid',message});
|
|
19
|
+
if(evidence.chain_id!==String(chainId)||getAddress(evidence.contract)!==config.contractAddress||getAddress(evidence.token)!==config.tokenAddress)return invalid('Evidence destination mismatch');
|
|
20
|
+
const receipt=await provider.getTransactionReceipt(evidence.transaction_hash);
|
|
21
|
+
if(!receipt||receipt.status!==1||receipt.blockHash!==evidence.block_hash||receipt.blockNumber!==evidence.block_number)return invalid('Receipt missing, reverted or changed');
|
|
22
|
+
const block=await provider.getBlock(receipt.blockNumber),head=await provider.getBlock('latest');
|
|
23
|
+
if(!block||block.hash!==receipt.blockHash)return invalid('Orphaned receipt');
|
|
24
|
+
if(head.number-block.number+1<confirmations)return {status:'insufficient_confirmations'};
|
|
25
|
+
const logs=receipt.logs.filter(l=>getAddress(l.address)===config.contractAddress).map(l=>{try{return iface.parseLog(l);}catch{return null;}});
|
|
26
|
+
const event=logs.find(l=>l?.name===evidence.event&&l.args.jobId===t.jobId);if(!event)return invalid('Event absent');
|
|
27
|
+
const snapshot=await contract.getJob(t.jobId,{blockTag:receipt.blockNumber});
|
|
28
|
+
for(const [key,value] of Object.entries(t))if(String(snapshot.terms[key]).toLowerCase()!==String(value).toLowerCase())return invalid('Agreement '+key+' mismatch');
|
|
29
|
+
const expected={amount:t.amount.toString(),payer:t.payer,worker:t.worker,verifier:t.verifier,arbitrator:t.arbitrator,job_id:t.jobId,agreement_hash:t.agreementHash,result_hash:snapshot.resultHash,worker_paid:snapshot.workerPaid.toString(),state:Number(snapshot.state)};
|
|
30
|
+
for(const [key,value] of Object.entries(expected))if(String(evidence[key]).toLowerCase()!==String(value).toLowerCase())return invalid('Cached '+key+' mismatch');
|
|
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
|
+
if(['Submitted','Verified'].includes(event.name)&&event.args.resultHash!==snapshot.resultHash)return invalid('Artifact mismatch');
|
|
33
|
+
if(event.name==='Disputed'&&event.args.reasonHash!==evidence.reason_hash)return invalid('Dispute reason mismatch');
|
|
34
|
+
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
|
+
return {status:'valid',transaction_hash:receipt.hash,block_hash:block.hash,confirmations:head.number-block.number+1};
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Noncustodial integration surface: the injected digest signer may call a KMS/HSM or remote signer. */
|
|
39
|
+
function createManagedSigner({address,provider,signDigest,authorize}={}){
|
|
40
|
+
address=getAddress(address);check(typeof signDigest==='function'&&typeof authorize==='function','Digest signer and authorization required');
|
|
41
|
+
class ManagedSigner extends AbstractSigner {
|
|
42
|
+
async getAddress(){return address;}
|
|
43
|
+
connect(next){return createManagedSigner({address,provider:next,signDigest,authorize});}
|
|
44
|
+
async signature(digest,context){check(await authorize(context)===true,'Managed signer authorization denied');const signature=Signature.from(await signDigest(digest,context));check(getAddress(recoverAddress(digest,signature))===address,'Remote signer returned wrong key');return signature;}
|
|
45
|
+
async signTransaction(request){const copy={...request};if(copy.from){check(getAddress(copy.from)===address,'Wrong signing account');delete copy.from;}const transaction=Transaction.from(copy);const signature=await this.signature(transaction.unsignedHash,{kind:'transaction',transaction:copy});transaction.signature=signature;return transaction.serialized;}
|
|
46
|
+
async signMessage(message){return (await this.signature(hashMessage(message),{kind:'message',message})).serialized;}
|
|
47
|
+
async signTypedData(domain,types,value){return (await this.signature(TypedDataEncoder.hash(domain,types,value),{kind:'typed_data',domain,types,value})).serialized;}
|
|
48
|
+
}
|
|
49
|
+
return new ManagedSigner(provider);
|
|
50
|
+
}
|
|
51
|
+
/** Explicit bounded same-nonce replacement; never change recipient/calldata/value. Journal preserves lineage. */
|
|
52
|
+
async function replacePendingTransaction({id,transactions,provider,signer,authorize,maxFeePerGas,maxPriorityFeePerGas}={}){
|
|
53
|
+
const record=await transactions.get(id);check(record,'Unknown transaction');
|
|
54
|
+
for(const attempt of [record,...(record.replacements||[])])check(!await provider.getTransactionReceipt(attempt.hash),'An attempt is already mined; reconcile instead');
|
|
55
|
+
const last=record.replacements?.at(-1)||record;const old=Transaction.from(last.raw);
|
|
56
|
+
check(old.type===2,'Only EIP-1559 replacements supported');
|
|
57
|
+
const fee=BigInt(maxFeePerGas),tip=BigInt(maxPriorityFeePerGas);
|
|
58
|
+
check(fee>=(old.maxFeePerGas*125n+99n)/100n&&tip>=(old.maxPriorityFeePerGas*125n+99n)/100n&&tip<=fee,'Replacement must raise both fees at least 25%');
|
|
59
|
+
check(await authorize({record,fee,tip})===true,'Replacement not authorized');
|
|
60
|
+
check(getAddress(await signer.getAddress())===old.from,'Wrong replacement signer');
|
|
61
|
+
const request={chainId:old.chainId,nonce:old.nonce,to:old.to,data:old.data,value:old.value,gasLimit:old.gasLimit,type:2,accessList:old.accessList,maxFeePerGas:fee,maxPriorityFeePerGas:tip};
|
|
62
|
+
const raw=await signer.signTransaction(request),tx=Transaction.from(raw);check(tx.from===old.from&&tx.to===old.to&&tx.data===old.data&&tx.value===old.value&&tx.nonce===old.nonce&&tx.chainId===old.chainId&&tx.gasLimit===old.gasLimit&&tx.maxFeePerGas===fee&&tx.maxPriorityFeePerGas===tip,'Replacement signer changed intent');
|
|
63
|
+
check(typeof transactions.appendReplacement==='function','Durable replacement journal required');
|
|
64
|
+
await transactions.appendReplacement(id,last.hash,{hash:tx.hash,raw,to:record.to,data:record.data,from:record.from,created_at:new Date().toISOString()});
|
|
65
|
+
return {hash:tx.hash,raw}; // persisted first; adapter resume broadcasts all known attempts safely.
|
|
66
|
+
}
|
|
67
|
+
module.exports={createEscrowRegistry,createEvidenceRevalidator,createManagedSigner,replacePendingTransaction};
|
|
68
|
+
/** Opt-in bounded fee replacement planning; broadcasting remains the original action's resume path. */
|
|
69
|
+
function createFeeReplacementWorker({transactions,provider,signerForRecord,authorize,minAgeMs=60000,maxAttempts=3,maxFeePerGas,maxPriorityFeePerGas}={}){
|
|
70
|
+
check(Number.isSafeInteger(minAgeMs)&&minAgeMs>0&&Number.isSafeInteger(maxAttempts)&&maxAttempts>=1,'Invalid replacement bounds');
|
|
71
|
+
const cap=BigInt(maxFeePerGas),tipCap=BigInt(maxPriorityFeePerGas);
|
|
72
|
+
return async()=>{const results=[];for(const record of await transactions.list()){
|
|
73
|
+
if((record.replacements?.length||0)>=maxAttempts||!record.created_at||Date.now()-Date.parse(record.replacements?.at(-1)?.created_at||record.created_at)<minAgeMs)continue;
|
|
74
|
+
const attempts=[record,...(record.replacements||[])];let mined=false;for(const a of attempts)if(await provider.getTransactionReceipt(a.hash)){mined=true;break;}if(mined)continue;
|
|
75
|
+
const last=Transaction.from(attempts.at(-1).raw);if(last.type!==2)continue;
|
|
76
|
+
const fee=(last.maxFeePerGas*125n+99n)/100n,tip=(last.maxPriorityFeePerGas*125n+99n)/100n;if(fee>cap||tip>tipCap){results.push({id:record.id,status:'cap_reached'});continue;}
|
|
77
|
+
try{const result=await replacePendingTransaction({id:record.id,transactions,provider,signer:await signerForRecord(record),authorize,maxFeePerGas:fee,maxPriorityFeePerGas:tip});results.push({id:record.id,status:'prepared',hash:result.hash});}catch(error){results.push({id:record.id,status:'error',error:error.message});}
|
|
78
|
+
}return results;};
|
|
79
|
+
}
|
|
80
|
+
module.exports.createFeeReplacementWorker=createFeeReplacementWorker;
|
|
81
|
+
/** Concrete transaction-only delegation policy, evaluated again for every signature. */
|
|
82
|
+
function createDelegationAuthorizer({expiresAt,chainIds,contracts,selectors,maxValue='0',maxFeePerGas,maxGasLimit,now=()=>Date.now()}={}){
|
|
83
|
+
check(Number.isSafeInteger(expiresAt)&&Array.isArray(chainIds)&&Array.isArray(contracts)&&Array.isArray(selectors),'Explicit delegation bounds required');
|
|
84
|
+
const allowed=new Set(contracts.map(a=>getAddress(a))),methods=new Set(selectors);
|
|
85
|
+
const valueCap=BigInt(maxValue),feeCap=BigInt(maxFeePerGas),gasCap=BigInt(maxGasLimit);
|
|
86
|
+
return async context=>{if(context.kind!=='transaction'||Math.floor(now()/1000)>=expiresAt)return false;const t=context.transaction;
|
|
87
|
+
if(!chainIds.map(String).includes(String(t.chainId))||!t.to||!allowed.has(getAddress(t.to))||!methods.has((t.data||'0x').slice(0,10)))return false;
|
|
88
|
+
if(BigInt(t.value||0)>valueCap||BigInt(t.gasLimit||0)>gasCap||BigInt(t.maxFeePerGas||t.gasPrice||0)>feeCap)return false;
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
module.exports.createDelegationAuthorizer=createDelegationAuthorizer;
|
|
93
|
+
/** Incremental confirmed log indexer. Consumers must deduplicate key and handle orphan notifications. */
|
|
94
|
+
function createEscrowIndexer({provider,chainId,contractAddress,confirmations,fromBlock,kv,onEvent,onReorg,batchSize=1000}={}){
|
|
95
|
+
check(Number.isSafeInteger(fromBlock)&&fromBlock>=0&&Number.isSafeInteger(confirmations)&&confirmations>0&&Number.isSafeInteger(batchSize)&&batchSize>0&&batchSize<=10000,'Explicit indexing bounds required');
|
|
96
|
+
check(kv&&typeof onEvent==='function'&&typeof onReorg==='function','Durable cursor and callbacks required');
|
|
97
|
+
const address=getAddress(contractAddress),iface=new Interface(ESCROW_ABI),scope=chainId+':'+address;
|
|
98
|
+
return async()=>{
|
|
99
|
+
check(Number((await provider.getNetwork()).chainId)===chainId,'Indexer RPC chain mismatch');
|
|
100
|
+
let cursor=await kv.get('index-cursors',scope);
|
|
101
|
+
if(cursor){const block=await provider.getBlock(cursor.block);if(!block||block.hash!==cursor.hash){await onReorg({scope,previous:cursor});
|
|
102
|
+
for(const row of await kv.list('index-events:'+scope)){const b=await provider.getBlock(row.value.block_number);if(!b||b.hash!==row.value.block_hash)await kv.put('index-events:'+scope,row.id,{...row.value,orphaned:true});}
|
|
103
|
+
cursor=null;await kv.put('index-cursors',scope,null);
|
|
104
|
+
}}
|
|
105
|
+
const head=await provider.getBlock('latest'),start=cursor?cursor.block+1:fromBlock,end=Math.min(head.number-confirmations+1,start+batchSize-1);if(end<start)return {indexed:0};
|
|
106
|
+
const scanEnd=await provider.getBlock(end);
|
|
107
|
+
const logs=await provider.getLogs({address,fromBlock:start,toBlock:end});let indexed=0;
|
|
108
|
+
for(const log of logs){let parsed;try{parsed=iface.parseLog(log);}catch{continue;}if(!parsed)continue;
|
|
109
|
+
const key=log.blockHash+':'+log.transactionHash+':'+log.index;
|
|
110
|
+
const old=await kv.get('index-events:'+scope,key);if(old?.delivered&&!old.orphaned)continue;
|
|
111
|
+
const event={key,chain_id:String(chainId),contract:address,block_number:log.blockNumber,block_hash:log.blockHash,transaction_hash:log.transactionHash,log_index:log.index,event:parsed.name,args:parsed.fragment.inputs.reduce((obj,input,i)=>{const value=parsed.args[i];obj[input.name]=typeof value==='bigint'?value.toString():value;return obj;},{}),orphaned:false,delivered:false};
|
|
112
|
+
await kv.put('index-events:'+scope,key,event);await onEvent(event);await kv.put('index-events:'+scope,key,{...event,delivered:true});indexed++;
|
|
113
|
+
}
|
|
114
|
+
const endBlock=await provider.getBlock(end);
|
|
115
|
+
if(!endBlock||endBlock.hash!==scanEnd.hash){await onReorg({scope,during_scan:true});for(const row of await kv.list('index-events:'+scope)){const block=await provider.getBlock(row.value.block_number);if(!block||block.hash!==row.value.block_hash)await kv.put('index-events:'+scope,row.id,{...row.value,orphaned:true});}return {indexed,reorg:true};}
|
|
116
|
+
await kv.put('index-cursors',scope,{block:end,hash:endBlock.hash});return {indexed,through:end};
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
module.exports.createEscrowIndexer=createEscrowIndexer;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {createWorkflowHandler}=require('../workflow.cjs');
|
|
3
|
+
function createChatTools({workflow,store,queue,actor}={}){
|
|
4
|
+
const {enqueueRunningJobs}=require('./index.cjs');
|
|
5
|
+
const act=(action)=>async({job_id,input={},idempotency_key})=>workflow.act(job_id,action,input,{actor,idempotencyKey:idempotency_key});
|
|
6
|
+
return {
|
|
7
|
+
send_job_offer:async({id,worker_id,verifier_id,arbitrator_id,terms,metadata={}})=>{
|
|
8
|
+
let job=await workflow.create({id,participants:{requester:actor,worker:worker_id,verifier:verifier_id,arbitrator:arbitrator_id},metadata},actor);
|
|
9
|
+
if(terms)job=await workflow.act(job.id,'quote',{terms},{actor,idempotencyKey:'initial_quote'});return job;
|
|
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'),
|
|
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
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function createApplicationHandler({workflow,store,queue,authenticate,revalidate,consentFor,legacyState=async()=>({}),authorityHandler,basePath='/api/a423'}={}){
|
|
16
|
+
const {createEventStream,freshReceipts,renderReceiptPdf}=require('./index.cjs');
|
|
17
|
+
const protocol=createWorkflowHandler({workflow,authenticate,basePath});const events=createEventStream({store,authenticate});
|
|
18
|
+
return async request=>{
|
|
19
|
+
const path=new URL(request.url).pathname;
|
|
20
|
+
if(path.startsWith('/api/a423-auth/')&&authorityHandler)return authorityHandler(request);
|
|
21
|
+
if(path.startsWith(basePath+'/'))return protocol(request);
|
|
22
|
+
if(request.method!=='GET')return Response.json({error:'not found'},{status:404});
|
|
23
|
+
if(path==='/api/a423-events')return events(request);
|
|
24
|
+
const actor=await authenticate(request);if(!actor)return Response.json({error:'unauthenticated'},{status:401});
|
|
25
|
+
try{
|
|
26
|
+
if(path==='/api/state'){
|
|
27
|
+
const jobs=await Promise.all((await store.list({actor,limit:1000})).map(async job=>({...job,safety:await store.getSafety(job.id)})));const wallet_transactions=jobs.flatMap(j=>j.evidence.filter(e=>e.evidence.transaction_hash).map(e=>({job_id:j.id,action:e.action,...e.evidence})));
|
|
28
|
+
return Response.json({...await legacyState(actor),a423:{jobs,wallet_transactions}},{headers:{'cache-control':'no-store'}});
|
|
29
|
+
}
|
|
30
|
+
const receipt=/^\/api\/a423-receipts\/([A-Za-z0-9_-]{1,128})(\.pdf)?$/.exec(path);
|
|
31
|
+
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'}});}
|
|
32
|
+
const consent=/^\/api\/a423-worker-consent\/([A-Za-z0-9_-]{1,128})$/.exec(path);
|
|
33
|
+
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
|
+
return Response.json({error:'not found'},{status:404});
|
|
35
|
+
}catch(error){return Response.json({error:error.code||'EVIDENCE_OR_CONFIGURATION_UNAVAILABLE'},{status:error.code==='NOT_FOUND'?404:503});}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
module.exports={createChatTools,createApplicationHandler};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {createWorkflow,hashAgreement}=require('../workflow.cjs');
|
|
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');
|
|
6
|
+
const {validateTask,validateEscrowEvidence,createSpendingAuthorizer,createExpiryAdapter,createApplicationHandler,createChatTools}=require('./index.cjs');
|
|
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
|
+
const registry=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),c]));
|
|
9
|
+
const configFor=job=>{const e=job.agreement?.terms.escrow;const c=registry.get(e?.chain_id+':'+e?.contract?.toLowerCase());if(!c)throw Error('Unregistered escrow');return c;};
|
|
10
|
+
const checkPolicy=async context=>{
|
|
11
|
+
if(typeof authority.isActive!=='function'||await authority.isActive(context.actor)!==true)return false;
|
|
12
|
+
if((await storage.jobs.getSafety(context.job.id))?.blocked)return false;
|
|
13
|
+
const e=context.job.agreement.terms.escrow;
|
|
14
|
+
if(requireIndependentArbitrator&&[e.payer,e.worker,e.verifier].map(a=>a.toLowerCase()).includes(e.arbitrator.toLowerCase()))return false;
|
|
15
|
+
return await approveSigning(context)===true;
|
|
16
|
+
};
|
|
17
|
+
const authorizeTransaction=createSpendingAuthorizer({spending:storage.spending,limitsFor,authorize:checkPolicy});
|
|
18
|
+
const adapters=createEscrowRegistry(escrows.map(c=>({...c,signerFor,addressFor,transactions:storage.transactions,authorizeTransaction,validateArtifact})));
|
|
19
|
+
const workflow=createWorkflow({store:storage.jobs,adapters:{...adapters,expire:createExpiryAdapter()},transitions:escrowTransitions,
|
|
20
|
+
authorize:async({actor})=>authority.isActive(actor),
|
|
21
|
+
validateTerms:terms=>{validateTask(terms.task);const job={id:'terms_validation',agreement:{terms,hash:hashAgreement(terms)}};escrowTerms(job,configFor(job));},
|
|
22
|
+
validateEvidence:({evidence})=>{if(evidence.transaction_hash)validateEscrowEvidence(evidence);}
|
|
23
|
+
});
|
|
24
|
+
const validators=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),createEvidenceRevalidator(c)]));
|
|
25
|
+
const revalidate=(job,evidence)=>validators.get(String(configFor(job).chainId)+':'+configFor(job).contractAddress.toLowerCase())(job,evidence);
|
|
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})};
|
|
28
|
+
const workerOptions={authority,actorFor:async job=>job.pending?.actor||job.participants.requester,allowExecute:keeperEnabled,
|
|
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
|
+
};
|
|
31
|
+
return {workflow,applicationOptions,workerOptions,handler:createApplicationHandler(applicationOptions),chatTools:actor=>createChatTools({workflow,store:storage.jobs,queue:storage.queue,actor})};
|
|
32
|
+
}
|
|
33
|
+
module.exports={createEscrowApplication};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {createHash,createHmac,randomUUID}=require('node:crypto');
|
|
3
|
+
const hash=x=>createHash('sha256').update(x).digest('hex');
|
|
4
|
+
function assert(ok,message){if(!ok)throw new TypeError(message);}
|
|
5
|
+
function validateTask(task){
|
|
6
|
+
assert(task&&typeof task==='object'&&!Array.isArray(task),'Task required');
|
|
7
|
+
for(const name of ['id','type','description'])assert(typeof task[name]==='string'&&task[name].trim()&&task[name].length<=16000,'Invalid task '+name);
|
|
8
|
+
assert(Array.isArray(task.deliverables)&&task.deliverables.length>0&&task.deliverables.length<=100,'Deliverables required');
|
|
9
|
+
const ids=new Set();for(const d of task.deliverables){assert(d&&typeof d.id==='string'&&!ids.has(d.id),'Unique deliverable IDs required');ids.add(d.id);assert(typeof d.media_type==='string'&&typeof d.description==='string','Deliverable media_type and description required');}
|
|
10
|
+
assert(task.acceptance&&typeof task.acceptance.validator==='string'&&typeof task.acceptance.version==='string'&&task.acceptance.criteria&&typeof task.acceptance.criteria==='object','Versioned acceptance criteria required');
|
|
11
|
+
JSON.stringify(task);return structuredClone(task);
|
|
12
|
+
}
|
|
13
|
+
function validateEscrowEvidence(e){
|
|
14
|
+
assert(e&&typeof e==='object','Escrow evidence required');
|
|
15
|
+
for(const key of ['chain_id','amount','worker_paid'])assert(typeof e[key]==='string'&&/^(0|[1-9][0-9]*)$/.test(e[key]),'Invalid evidence '+key);
|
|
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
|
+
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
|
+
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');
|
|
21
|
+
return structuredClone(e);
|
|
22
|
+
}
|
|
23
|
+
function createSpendingAuthorizer({spending,limitsFor,authorize}={}){
|
|
24
|
+
assert(spending&&typeof limitsFor==='function'&&typeof authorize==='function','Spending store, limitsFor and authorize required');
|
|
25
|
+
return async context=>{
|
|
26
|
+
if(await authorize(context)!==true)return false;
|
|
27
|
+
if(['fund','approve','approve_zero'].includes(context.step)){
|
|
28
|
+
const e=context.job.agreement.terms.escrow,limits=await limitsFor(context);
|
|
29
|
+
try {await spending.reserve({...limits,id:hash(JSON.stringify([e.chain_id,e.contract.toLowerCase(),context.job.id])),scope:JSON.stringify([e.chain_id,e.payer.toLowerCase(),e.token.toLowerCase()]),amount:e.amount});}catch(error){if(error.code==='SPENDING_LIMIT_EXCEEDED')return false;throw error;}
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Resume is deliberately separate from observe-only reconcile; caller must authorize execution. */
|
|
35
|
+
async function runMaintenanceOnce({workflow,store,actorFor,allowExecute=false,now=Date.now()}={}){
|
|
36
|
+
const results=[];let after='';
|
|
37
|
+
while(true){const jobs=await store.list({after,limit:100});if(!jobs.length)break;after=jobs.at(-1).id;
|
|
38
|
+
for(const job of jobs){try{
|
|
39
|
+
const actor=await actorFor(job);if(!actor)continue;
|
|
40
|
+
let result=job;
|
|
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.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';
|
|
45
|
+
const expiry=job.agreement.terms.expires_at;
|
|
46
|
+
if(['quoted','accepted'].includes(job.state)&&Number.isSafeInteger(expiry)&&seconds>expiry)action='expire';
|
|
47
|
+
if(action&&allowExecute)result=await workflow.act(job.id,action,{}, {actor,idempotencyKey:'keeper_'+hash(job.id+':'+action)});
|
|
48
|
+
}
|
|
49
|
+
results.push({id:job.id,state:result.state,pending:!!result.pending});
|
|
50
|
+
}catch(error){results.push({id:job.id,error:error.message});}}
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
function createExpiryAdapter({now=()=>Date.now()}={}){const run=async({job})=>{
|
|
55
|
+
const expiry=job.agreement?.terms.expires_at;
|
|
56
|
+
if(!Number.isSafeInteger(expiry)||Math.floor(now()/1000)<=expiry)return {status:'rejected',reason:'Agreement has not expired'};
|
|
57
|
+
return {status:'confirmed',evidence:{expires_at:expiry,observed_at:new Date(now()).toISOString()}};
|
|
58
|
+
};return {execute:run,reconcile:run};}
|
|
59
|
+
async function runExecutionOnce({workflow,queue,execute,beforeExecute,leaseMs=30000}={}){
|
|
60
|
+
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)));
|
|
62
|
+
try{
|
|
63
|
+
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');}
|
|
65
|
+
assert(typeof result.result_hash==='string','Execution must return artifact commitment');
|
|
66
|
+
const job=await workflow.act(item.payload.job_id,'submit',result,{actor:item.payload.actor,idempotencyKey:'execution_'+hash(item.id)});
|
|
67
|
+
assert(await queue.complete(item.id,item.lease,{job_id:job.id,state:job.state}),'Execution lease lost');return job;
|
|
68
|
+
}catch(error){await queue.retry(item.id,item.lease,{delayMs:1000});throw error;}finally{clearInterval(heartbeat);}
|
|
69
|
+
}
|
|
70
|
+
async function enqueueRunningJobs({store,queue}){let after='',count=0;while(true){const jobs=await store.list({after,limit:100});if(!jobs.length)break;after=jobs.at(-1).id;for(const j of jobs)if(j.state==='running'&&!j.pending){const event=j.events.filter(e=>e.type==='start.confirmed').at(-1);if(event&&await queue.enqueue({id:'execution_'+hash(j.id+':'+event.sequence),kind:'execution',payload:{job_id:j.id,actor:j.participants.worker}}))count++;}}return count;}
|
|
71
|
+
function createEventStream({store,authenticate,pollMs=1000,maxDurationMs=25000}={}){
|
|
72
|
+
return async request=>{
|
|
73
|
+
const actor=await authenticate(request);if(!actor)return Response.json({error:'unauthenticated'},{status:401});
|
|
74
|
+
let cursor=Number(request.headers.get('last-event-id')||new URL(request.url).searchParams.get('after')||0);if(!Number.isSafeInteger(cursor)||cursor<0)return Response.json({error:'invalid cursor'},{status:400});
|
|
75
|
+
let closed=false,timer;const encoder=new TextEncoder(),began=Date.now();
|
|
76
|
+
const stream=new ReadableStream({start(controller){const tick=async()=>{if(closed)return;try{const events=await store.events({actor,after:cursor,limit:100});for(const event of events){cursor=event.cursor;controller.enqueue(encoder.encode(`id: ${cursor}\nevent: a423\ndata: ${JSON.stringify(event)}\n\n`));}if(request.signal.aborted||Date.now()-began>=maxDurationMs){closed=true;controller.close();return;}controller.enqueue(encoder.encode(': heartbeat\n\n'));timer=setTimeout(tick,pollMs);}catch(e){closed=true;controller.error(e);}};void tick();},cancel(){closed=true;clearTimeout(timer);}});
|
|
77
|
+
return new Response(stream,{headers:{'content-type':'text/event-stream','cache-control':'no-store','x-accel-buffering':'no'}});
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function createWebhookDispatcher({queue,kv,subscriptions,secretFor,fetch:fetchImpl=globalThis.fetch,timeoutMs=10000}={}){
|
|
81
|
+
assert(queue&&kv&&Array.isArray(subscriptions)&&typeof secretFor==='function','Webhook configuration required');
|
|
82
|
+
const targets=new Map();for(const s of subscriptions){const u=new URL(s.url);assert(u.protocol==='https:'&&!u.username&&!u.password&&!u.hash,'Use explicitly configured HTTPS webhook destinations');targets.set(s.id,{...s,url:u.href});}
|
|
83
|
+
return {
|
|
84
|
+
async project(store){for(const s of targets.values()){const events=await store.events({actor:s.actor,after:(await kv.get('webhook-cursors',s.id))?.cursor||s.after||0,limit:1000});for(const event of events){await queue.enqueue({id:'webhook_'+hash(s.id+':'+event.cursor),kind:'webhook',payload:{subscription:s.id,event}});await kv.put('webhook-cursors',s.id,{cursor:event.cursor});}}},
|
|
85
|
+
async deliverOne(){const item=await queue.claim({kind:'webhook',leaseMs:timeoutMs+5000});if(!item)return null;try{
|
|
86
|
+
const target=targets.get(item.payload.subscription);assert(target,'Subscription removed');const body=JSON.stringify(item.payload.event),timestamp=String(Math.floor(Date.now()/1000));const secret=await secretFor(target.id);assert(typeof secret==='string'&&secret.length>=32,'Webhook signing secret must have at least 32 characters');
|
|
87
|
+
const signature=createHmac('sha256',secret).update(timestamp+'.'+body).digest('hex');const response=await fetchImpl(target.url,{method:'POST',body,headers:{'content-type':'application/json','a423-delivery-id':item.id,'a423-timestamp':timestamp,'a423-signature':'v1='+signature},redirect:'error',signal:AbortSignal.timeout(timeoutMs)});
|
|
88
|
+
await response.body?.cancel();if(!response.ok)throw Error('Webhook HTTP '+response.status);await queue.complete(item.id,item.lease,{status:response.status});return {id:item.id,status:'delivered'};
|
|
89
|
+
}catch(error){await queue.retry(item.id,item.lease,{delayMs:Math.min(3600000,1000*2**Math.min(item.attempts,12))});return {id:item.id,status:'retry',error:error.message};}}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
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
|
+
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};}
|
|
95
|
+
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
|
+
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
|
+
module.exports={validateTask,validateEscrowEvidence,createSpendingAuthorizer,runMaintenanceOnce,createExpiryAdapter,runExecutionOnce,enqueueRunningJobs,createEventStream,createWebhookDispatcher,watchEvidenceOnce,freshReceipts,renderReceiptPdf,planLegacyMigration};
|
|
98
|
+
|
|
99
|
+
async function migrateUnfundedLegacy({records,workflow,map,actor}){
|
|
100
|
+
const created=[];for(const source of records){assert(['offered','draft','quoted','accepted'].includes(String(source.state).toLowerCase()),'Funded/terminal legacy records cannot be automatically migrated');const target=await map(structuredClone(source));assert(target.id&&target.id!==source.id,'Use a new explicit job ID');const job=await workflow.create({...target,metadata:{...target.metadata,migration:{source_id:source.id,source_state:source.state,source_hash:hash(JSON.stringify(source)),accepted_terms_carried:false}}},actor);created.push(job);}return created;
|
|
101
|
+
}
|
|
102
|
+
module.exports.migrateUnfundedLegacy=migrateUnfundedLegacy;
|
|
103
|
+
Object.assign(module.exports,require('./application.cjs'));
|
|
104
|
+
|
|
105
|
+
Object.assign(module.exports,require('./factory.cjs'));
|