@allorc/a423-sdk 0.3.0 → 0.5.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 +19 -0
- package/ESCROW.md +9 -6
- package/INTEGRATION.md +12 -2
- package/LEGACY.md +251 -0
- package/OPERATIONS.md +184 -0
- package/README.md +66 -316
- package/SECURITY.md +10 -7
- package/SETTLEMENT.md +140 -0
- package/contracts/A423Escrow.sol +73 -7
- package/contracts/standard-input.json +80 -0
- package/examples/operations-dashboard.html +16 -0
- package/examples/operations-server.mjs +19 -0
- package/package.json +37 -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/closing.cjs +17 -0
- package/src/escrow/artifact.json +292 -1
- package/src/escrow/index.cjs +58 -12
- package/src/escrow/index.d.mts +17 -2
- package/src/escrow/index.d.ts +17 -2
- package/src/escrow/index.mjs +1 -1
- package/src/escrow/negotiation.cjs +13 -0
- package/src/escrow/services.cjs +122 -0
- package/src/operations/application.cjs +40 -0
- package/src/operations/factory.cjs +33 -0
- package/src/operations/index.cjs +106 -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 +41 -5
- package/src/workflow.d.ts +9 -1
package/src/escrow/index.cjs
CHANGED
|
@@ -6,9 +6,13 @@ 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
|
-
|
|
9
|
+
propose_settlement: {from:['funded','running','submitted','verified'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
|
|
10
|
+
counter_settlement: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settlement_negotiation',effect:true},
|
|
11
|
+
accept_settlement: {from:['settlement_negotiation'],roles:['requester','worker'],to:'settled',effect:true},
|
|
12
|
+
refund: {from:['funded','running','settlement_negotiation','disputed'],roles:['requester'],to:'refunded',effect:true},
|
|
13
|
+
dispute: {from:['funded','running','submitted','verified','settlement_negotiation'],roles:['requester','worker','verifier'],to:'disputed',effect:true},
|
|
10
14
|
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}
|
|
15
|
+
timeout: {from:['funded','running','submitted','verified','disputed','settlement_negotiation'],roles:['requester','worker','verifier','arbitrator'],to:'settled',effect:true}
|
|
12
16
|
};
|
|
13
17
|
function check(condition,message) { if(!condition) throw new Error('Escrow: '+message); }
|
|
14
18
|
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; }
|
|
@@ -31,10 +35,13 @@ function createMemoryTransactionStore() {
|
|
|
31
35
|
return {
|
|
32
36
|
async get(id){return records.has(id)?structuredClone(records.get(id)):null;},
|
|
33
37
|
async create(id,record){if(records.has(id))return false;records.set(id,structuredClone(record));return true;},
|
|
38
|
+
async list(){return [...records].map(([id,record])=>({id,...structuredClone(record)}));},
|
|
39
|
+
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)];},
|
|
34
40
|
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;}
|
|
35
41
|
};
|
|
36
42
|
}
|
|
37
|
-
function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,signerFor,addressFor,transactions,confirmations,validateArtifact}={}) {
|
|
43
|
+
function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,signerFor,addressFor,transactions,confirmations,validateArtifact,authorizeTransaction}={}) {
|
|
44
|
+
check(typeof authorizeTransaction==='function','authorizeTransaction is required before any signing or broadcast');
|
|
38
45
|
check(provider && typeof signerFor==='function' && typeof addressFor==='function','provider, signerFor and addressFor required');
|
|
39
46
|
check(Number.isSafeInteger(chainId)&&chainId>0 && Number.isSafeInteger(confirmations)&&confirmations>0,'explicit chain ID and positive confirmations required');
|
|
40
47
|
check(transactions && ['get','create','reserveNonce'].every(k=>typeof transactions[k]==='function'),'durable transaction journal required');
|
|
@@ -63,6 +70,7 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
|
|
|
63
70
|
let record=await transactions.get(id);
|
|
64
71
|
if(!record && reconcile)return {status:'pending',reference:id};
|
|
65
72
|
if(!record) {
|
|
73
|
+
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'};
|
|
66
74
|
const signer=await signerFor(ctx.operation.actor);
|
|
67
75
|
const address=getAddress(await signer.getAddress());
|
|
68
76
|
check(address===getAddress(await addressFor(ctx.operation.actor)),'signer ownership mismatch');
|
|
@@ -75,17 +83,20 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
|
|
|
75
83
|
const raw=await signer.signTransaction(request);
|
|
76
84
|
const tx=Transaction.from(raw);
|
|
77
85
|
check(tx.from===address && Number(tx.chainId)===chainId && tx.to===getAddress(to) && tx.data===data && tx.nonce===nonce && tx.value===0n,'signed transaction mismatch');
|
|
78
|
-
await transactions.create(id,{hash:tx.hash,raw,to:getAddress(to),data,from:address});
|
|
86
|
+
await transactions.create(id,{hash:tx.hash,raw,to:getAddress(to),data,from:address,created_at:new Date().toISOString()});
|
|
79
87
|
record=await transactions.get(id);
|
|
80
88
|
}
|
|
81
89
|
check(record.to===getAddress(to) && record.data===data && record.from===getAddress(await addressFor(ctx.operation.actor)),'journal request mismatch');
|
|
82
|
-
|
|
90
|
+
const attempts=[record,...(record.replacements||[])];
|
|
91
|
+
let active=attempts.at(-1),r=null;
|
|
92
|
+
for(const attempt of attempts){const observed=await receipt(attempt.hash);if(observed){r=observed;active=attempt;break;}}
|
|
83
93
|
if(!r && !reconcile) {
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
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');
|
|
95
|
+
try {await provider.broadcastTransaction(active.raw);} catch(err) {if(!await provider.getTransaction(active.hash))throw err;}
|
|
96
|
+
r=await receipt(active.hash);
|
|
86
97
|
}
|
|
87
|
-
if(!r)return {status:'pending',reference:
|
|
88
|
-
if(r.status!==1)return {status:'rejected',reason:'Transaction reverted',reference:
|
|
98
|
+
if(!r)return {status:'pending',reference:active.hash};
|
|
99
|
+
if(r.status!==1)return {status:'rejected',reason:'Transaction reverted',reference:active.hash};
|
|
89
100
|
return {receipt:r};
|
|
90
101
|
}
|
|
91
102
|
async function observe(ctx,t,suffix,method,args,eventName,validate,reconcile) {
|
|
@@ -101,19 +112,52 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
|
|
|
101
112
|
const event=logs.find(log=>log?.name===eventName && log.args.jobId===t.jobId);
|
|
102
113
|
check(event,'matching '+eventName+' event missing');
|
|
103
114
|
const snapshot=await contract.getJob(t.jobId,{blockTag:r.blockNumber});match(snapshot,t);validate(snapshot,event);
|
|
104
|
-
return {status:'confirmed',evidence:{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,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()}};
|
|
115
|
+
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()})};
|
|
105
116
|
}
|
|
106
117
|
function adapter(run){return {execute:ctx=>run(ctx,false),reconcile:ctx=>run(ctx,true)};}
|
|
107
118
|
const state=(s,n)=>check(Number(s.state)===n,'unexpected escrow state');
|
|
108
119
|
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');};
|
|
120
|
+
async function propose(ctx,reconcile){
|
|
121
|
+
const {settlementProposal}=require('./negotiation.cjs');
|
|
122
|
+
const t=await context(ctx.job);let proposal;
|
|
123
|
+
try {proposal=settlementProposal(ctx.job,ctx.input);}catch(error){return {status:'rejected',reason:error.message};}
|
|
124
|
+
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)=>{
|
|
125
|
+
check(Number(s.state)===8,'negotiation is not active');
|
|
126
|
+
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');
|
|
127
|
+
},reconcile);
|
|
128
|
+
}
|
|
129
|
+
const proposeAdapter=adapter(async(ctx,r)=>{ctx.settlementActorAddress=getAddress(await addressFor(ctx.operation.actor));return propose(ctx,r);});
|
|
109
130
|
return {
|
|
131
|
+
propose_settlement:proposeAdapter,
|
|
132
|
+
counter_settlement:proposeAdapter,
|
|
133
|
+
accept_settlement:adapter(async(ctx,r)=>{
|
|
134
|
+
const {settlementConsent}=require('./negotiation.cjs');const t=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:'Accept the exact current proposal hash and revision'};
|
|
136
|
+
let consent;try{consent=settlementConsent(ctx.job,config);}catch(error){return {status:'rejected',reason:error.message};}
|
|
137
|
+
let method='acceptSettlement',args=[t.jobId,p.escrow_version,p.proposal_hash];
|
|
138
|
+
if(ctx.input.payer_signature!==undefined || ctx.input.worker_signature!==undefined){
|
|
139
|
+
if(typeof ctx.input.payer_signature!=='string'||typeof ctx.input.worker_signature!=='string')return {status:'rejected',reason:'Both party signatures required for relayed settlement'};
|
|
140
|
+
method='settleByConsent';args=[consent.value,ctx.input.payer_signature,ctx.input.worker_signature];
|
|
141
|
+
}else if(ctx.job.negotiation.current.proposer_id===ctx.operation.actor)return {status:'rejected',reason:'Counterparty must accept; proposer cannot self-accept'};
|
|
142
|
+
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);
|
|
143
|
+
if(result.status==='confirmed'){
|
|
144
|
+
const receipt=await provider.getTransactionReceipt(result.evidence.transaction_hash);
|
|
145
|
+
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);
|
|
146
|
+
check(accepted&&Number(accepted.args.revision)===p.escrow_version&&accepted.args.proposalHash===p.proposal_hash,'matching settlement acceptance missing');
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}),
|
|
110
150
|
fund:adapter(async(ctx,reconcile)=>{
|
|
111
151
|
const t=await context(ctx.job);
|
|
112
152
|
check([ctx.job.participants.requester,ctx.job.participants.worker].every(p=>ctx.job.agreement.accepted_by.includes(p)),'both acceptances required');
|
|
113
153
|
if(typeof ctx.input.worker_signature!=='string')return {status:'rejected',reason:'worker_signature required'};
|
|
114
154
|
// Consent is checked locally as well as by the contract before moving tokens.
|
|
115
155
|
const {verifyTypedData}=require('ethers');const c=workerConsent(ctx.job,config);
|
|
116
|
-
|
|
156
|
+
const digest=TypedDataEncoder.hash(c.domain,c.types,c.value);
|
|
157
|
+
if(await provider.getCode(t.worker)!=='0x'){
|
|
158
|
+
const smart=new Contract(t.worker,['function isValidSignature(bytes32,bytes) view returns(bytes4)'],provider);
|
|
159
|
+
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;}
|
|
160
|
+
}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'};}}
|
|
117
161
|
// Never send further approvals if the funding transaction already exists in the journal.
|
|
118
162
|
if(!ctx.input.transaction_hash && !await transactions.get(ctx.operation.id+':fund')) {
|
|
119
163
|
let allowance=await token.allowance(t.payer,config.contractAddress);
|
|
@@ -131,7 +175,9 @@ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,sig
|
|
|
131
175
|
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);}),
|
|
132
176
|
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);}),
|
|
133
177
|
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);}),
|
|
134
|
-
timeout:adapter(async(ctx,r)=>{const t=await context(ctx.job);const
|
|
178
|
+
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);})
|
|
135
179
|
};
|
|
136
180
|
}
|
|
137
181
|
module.exports={createEscrowAdapters,createMemoryTransactionStore,workerConsent,escrowTerms,escrowTransitions:transitions,ESCROW_ABI:ABI,ESCROW_BYTECODE:artifact.bytecode};
|
|
182
|
+
|
|
183
|
+
Object.assign(module.exports,require('./services.cjs'),require('./negotiation.cjs'));
|
package/src/escrow/index.d.mts
CHANGED
|
@@ -6,9 +6,11 @@ export interface ContractTerms {
|
|
|
6
6
|
amount: bigint; workDeadline: number; reviewDeadline: number; resolutionDeadline: number;
|
|
7
7
|
timeoutWorkerBps: number; agreementHash: string;
|
|
8
8
|
}
|
|
9
|
-
export interface TransactionRecord { hash: string; raw: string; to: string; data: string; from: string }
|
|
9
|
+
export interface TransactionRecord { created_at?: string; replacements?: TransactionRecord[]; hash: string; raw: string; to: string; data: string; from: string }
|
|
10
10
|
export interface TransactionStore {
|
|
11
11
|
get(id: string): Promise<TransactionRecord | null>;
|
|
12
|
+
list(): Promise<Array<TransactionRecord & {id: string}>>;
|
|
13
|
+
appendReplacement(id: string, expectedHash: string, replacement: TransactionRecord): Promise<void>;
|
|
12
14
|
/** Atomic insert if absent; caller reads winner before broadcasting. */
|
|
13
15
|
create(id: string, record: TransactionRecord): Promise<boolean>;
|
|
14
16
|
/** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
|
|
@@ -21,10 +23,11 @@ export interface EscrowAdapterOptions extends EscrowConfiguration {
|
|
|
21
23
|
signerFor(actor: string): Promise<Signer>;
|
|
22
24
|
addressFor(actor: string): Promise<string>;
|
|
23
25
|
transactions: TransactionStore;
|
|
26
|
+
authorizeTransaction(context: { job: WorkflowJob; operation: AdapterContext['operation']; actor: string; step: string; to: string; data: string; phase: 'sign' | 'broadcast' }): Promise<boolean>;
|
|
24
27
|
confirmations: number;
|
|
25
28
|
validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
|
|
26
29
|
}
|
|
27
|
-
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', EscrowAdapter>;
|
|
28
31
|
export function createMemoryTransactionStore(): TransactionStore;
|
|
29
32
|
export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
|
|
30
33
|
export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
|
|
@@ -34,3 +37,15 @@ export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
|
|
|
34
37
|
export const escrowTransitions: Record<string, Transition>;
|
|
35
38
|
export const ESCROW_ABI: InterfaceAbi;
|
|
36
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}>;
|
|
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}};
|
package/src/escrow/index.d.ts
CHANGED
|
@@ -6,9 +6,11 @@ export interface ContractTerms {
|
|
|
6
6
|
amount: bigint; workDeadline: number; reviewDeadline: number; resolutionDeadline: number;
|
|
7
7
|
timeoutWorkerBps: number; agreementHash: string;
|
|
8
8
|
}
|
|
9
|
-
export interface TransactionRecord { hash: string; raw: string; to: string; data: string; from: string }
|
|
9
|
+
export interface TransactionRecord { created_at?: string; replacements?: TransactionRecord[]; hash: string; raw: string; to: string; data: string; from: string }
|
|
10
10
|
export interface TransactionStore {
|
|
11
11
|
get(id: string): Promise<TransactionRecord | null>;
|
|
12
|
+
list(): Promise<Array<TransactionRecord & {id: string}>>;
|
|
13
|
+
appendReplacement(id: string, expectedHash: string, replacement: TransactionRecord): Promise<void>;
|
|
12
14
|
/** Atomic insert if absent; caller reads winner before broadcasting. */
|
|
13
15
|
create(id: string, record: TransactionRecord): Promise<boolean>;
|
|
14
16
|
/** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
|
|
@@ -21,10 +23,11 @@ export interface EscrowAdapterOptions extends EscrowConfiguration {
|
|
|
21
23
|
signerFor(actor: string): Promise<Signer>;
|
|
22
24
|
addressFor(actor: string): Promise<string>;
|
|
23
25
|
transactions: TransactionStore;
|
|
26
|
+
authorizeTransaction(context: { job: WorkflowJob; operation: AdapterContext['operation']; actor: string; step: string; to: string; data: string; phase: 'sign' | 'broadcast' }): Promise<boolean>;
|
|
24
27
|
confirmations: number;
|
|
25
28
|
validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
|
|
26
29
|
}
|
|
27
|
-
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', EscrowAdapter>;
|
|
28
31
|
export function createMemoryTransactionStore(): TransactionStore;
|
|
29
32
|
export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
|
|
30
33
|
export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
|
|
@@ -34,3 +37,15 @@ export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
|
|
|
34
37
|
export const escrowTransitions: Record<string, Transition>;
|
|
35
38
|
export const ESCROW_ABI: InterfaceAbi;
|
|
36
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}>;
|
|
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}};
|
package/src/escrow/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import escrow from './index.cjs';
|
|
2
|
-
export const { createEscrowAdapters, createMemoryTransactionStore, workerConsent, escrowTerms, escrowTransitions, ESCROW_ABI, ESCROW_BYTECODE } = escrow;
|
|
2
|
+
export const { createEscrowAdapters, createMemoryTransactionStore, workerConsent, escrowTerms, escrowTransitions, ESCROW_ABI, ESCROW_BYTECODE, createEscrowRegistry, createEvidenceRevalidator, createManagedSigner, createDelegationAuthorizer, replacePendingTransaction, createFeeReplacementWorker, createEscrowIndexer, settlementProposal, settlementConsent } = 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&&job.negotiation.status==='open','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};
|
|
@@ -0,0 +1,122 @@
|
|
|
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','propose_settlement','counter_settlement','accept_settlement'];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==='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');}
|
|
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');
|
|
38
|
+
return {status:'valid',transaction_hash:receipt.hash,block_hash:block.hash,confirmations:head.number-block.number+1};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Noncustodial integration surface: the injected digest signer may call a KMS/HSM or remote signer. */
|
|
42
|
+
function createManagedSigner({address,provider,signDigest,authorize}={}){
|
|
43
|
+
address=getAddress(address);check(typeof signDigest==='function'&&typeof authorize==='function','Digest signer and authorization required');
|
|
44
|
+
class ManagedSigner extends AbstractSigner {
|
|
45
|
+
async getAddress(){return address;}
|
|
46
|
+
connect(next){return createManagedSigner({address,provider:next,signDigest,authorize});}
|
|
47
|
+
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;}
|
|
48
|
+
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;}
|
|
49
|
+
async signMessage(message){return (await this.signature(hashMessage(message),{kind:'message',message})).serialized;}
|
|
50
|
+
async signTypedData(domain,types,value){return (await this.signature(TypedDataEncoder.hash(domain,types,value),{kind:'typed_data',domain,types,value})).serialized;}
|
|
51
|
+
}
|
|
52
|
+
return new ManagedSigner(provider);
|
|
53
|
+
}
|
|
54
|
+
/** Explicit bounded same-nonce replacement; never change recipient/calldata/value. Journal preserves lineage. */
|
|
55
|
+
async function replacePendingTransaction({id,transactions,provider,signer,authorize,maxFeePerGas,maxPriorityFeePerGas}={}){
|
|
56
|
+
const record=await transactions.get(id);check(record,'Unknown transaction');
|
|
57
|
+
for(const attempt of [record,...(record.replacements||[])])check(!await provider.getTransactionReceipt(attempt.hash),'An attempt is already mined; reconcile instead');
|
|
58
|
+
const last=record.replacements?.at(-1)||record;const old=Transaction.from(last.raw);
|
|
59
|
+
check(old.type===2,'Only EIP-1559 replacements supported');
|
|
60
|
+
const fee=BigInt(maxFeePerGas),tip=BigInt(maxPriorityFeePerGas);
|
|
61
|
+
check(fee>=(old.maxFeePerGas*125n+99n)/100n&&tip>=(old.maxPriorityFeePerGas*125n+99n)/100n&&tip<=fee,'Replacement must raise both fees at least 25%');
|
|
62
|
+
check(await authorize({record,fee,tip})===true,'Replacement not authorized');
|
|
63
|
+
check(getAddress(await signer.getAddress())===old.from,'Wrong replacement signer');
|
|
64
|
+
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};
|
|
65
|
+
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');
|
|
66
|
+
check(typeof transactions.appendReplacement==='function','Durable replacement journal required');
|
|
67
|
+
await transactions.appendReplacement(id,last.hash,{hash:tx.hash,raw,to:record.to,data:record.data,from:record.from,created_at:new Date().toISOString()});
|
|
68
|
+
return {hash:tx.hash,raw}; // persisted first; adapter resume broadcasts all known attempts safely.
|
|
69
|
+
}
|
|
70
|
+
module.exports={createEscrowRegistry,createEvidenceRevalidator,createManagedSigner,replacePendingTransaction};
|
|
71
|
+
/** Opt-in bounded fee replacement planning; broadcasting remains the original action's resume path. */
|
|
72
|
+
function createFeeReplacementWorker({transactions,provider,signerForRecord,authorize,minAgeMs=60000,maxAttempts=3,maxFeePerGas,maxPriorityFeePerGas}={}){
|
|
73
|
+
check(Number.isSafeInteger(minAgeMs)&&minAgeMs>0&&Number.isSafeInteger(maxAttempts)&&maxAttempts>=1,'Invalid replacement bounds');
|
|
74
|
+
const cap=BigInt(maxFeePerGas),tipCap=BigInt(maxPriorityFeePerGas);
|
|
75
|
+
return async()=>{const results=[];for(const record of await transactions.list()){
|
|
76
|
+
if((record.replacements?.length||0)>=maxAttempts||!record.created_at||Date.now()-Date.parse(record.replacements?.at(-1)?.created_at||record.created_at)<minAgeMs)continue;
|
|
77
|
+
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;
|
|
78
|
+
const last=Transaction.from(attempts.at(-1).raw);if(last.type!==2)continue;
|
|
79
|
+
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;}
|
|
80
|
+
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});}
|
|
81
|
+
}return results;};
|
|
82
|
+
}
|
|
83
|
+
module.exports.createFeeReplacementWorker=createFeeReplacementWorker;
|
|
84
|
+
/** Concrete transaction-only delegation policy, evaluated again for every signature. */
|
|
85
|
+
function createDelegationAuthorizer({expiresAt,chainIds,contracts,selectors,maxValue='0',maxFeePerGas,maxGasLimit,now=()=>Date.now()}={}){
|
|
86
|
+
check(Number.isSafeInteger(expiresAt)&&Array.isArray(chainIds)&&Array.isArray(contracts)&&Array.isArray(selectors),'Explicit delegation bounds required');
|
|
87
|
+
const allowed=new Set(contracts.map(a=>getAddress(a))),methods=new Set(selectors);
|
|
88
|
+
const valueCap=BigInt(maxValue),feeCap=BigInt(maxFeePerGas),gasCap=BigInt(maxGasLimit);
|
|
89
|
+
return async context=>{if(context.kind!=='transaction'||Math.floor(now()/1000)>=expiresAt)return false;const t=context.transaction;
|
|
90
|
+
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;
|
|
91
|
+
if(BigInt(t.value||0)>valueCap||BigInt(t.gasLimit||0)>gasCap||BigInt(t.maxFeePerGas||t.gasPrice||0)>feeCap)return false;
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
module.exports.createDelegationAuthorizer=createDelegationAuthorizer;
|
|
96
|
+
/** Incremental confirmed log indexer. Consumers must deduplicate key and handle orphan notifications. */
|
|
97
|
+
function createEscrowIndexer({provider,chainId,contractAddress,confirmations,fromBlock,kv,onEvent,onReorg,batchSize=1000}={}){
|
|
98
|
+
check(Number.isSafeInteger(fromBlock)&&fromBlock>=0&&Number.isSafeInteger(confirmations)&&confirmations>0&&Number.isSafeInteger(batchSize)&&batchSize>0&&batchSize<=10000,'Explicit indexing bounds required');
|
|
99
|
+
check(kv&&typeof onEvent==='function'&&typeof onReorg==='function','Durable cursor and callbacks required');
|
|
100
|
+
const address=getAddress(contractAddress),iface=new Interface(ESCROW_ABI),scope=chainId+':'+address;
|
|
101
|
+
return async()=>{
|
|
102
|
+
check(Number((await provider.getNetwork()).chainId)===chainId,'Indexer RPC chain mismatch');
|
|
103
|
+
let cursor=await kv.get('index-cursors',scope);
|
|
104
|
+
if(cursor){const block=await provider.getBlock(cursor.block);if(!block||block.hash!==cursor.hash){await onReorg({scope,previous:cursor});
|
|
105
|
+
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});}
|
|
106
|
+
cursor=null;await kv.put('index-cursors',scope,null);
|
|
107
|
+
}}
|
|
108
|
+
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};
|
|
109
|
+
const scanEnd=await provider.getBlock(end);
|
|
110
|
+
const logs=await provider.getLogs({address,fromBlock:start,toBlock:end});let indexed=0;
|
|
111
|
+
for(const log of logs){let parsed;try{parsed=iface.parseLog(log);}catch{continue;}if(!parsed)continue;
|
|
112
|
+
const key=log.blockHash+':'+log.transactionHash+':'+log.index;
|
|
113
|
+
const old=await kv.get('index-events:'+scope,key);if(old?.delivered&&!old.orphaned)continue;
|
|
114
|
+
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};
|
|
115
|
+
await kv.put('index-events:'+scope,key,event);await onEvent(event);await kv.put('index-events:'+scope,key,{...event,delivered:true});indexed++;
|
|
116
|
+
}
|
|
117
|
+
const endBlock=await provider.getBlock(end);
|
|
118
|
+
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};}
|
|
119
|
+
await kv.put('index-cursors',scope,{block:end,hash:endBlock.hash});return {indexed,through:end};
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
module.exports.createEscrowIndexer=createEscrowIndexer;
|
|
@@ -0,0 +1,40 @@
|
|
|
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'),propose_settlement:act('propose_settlement'),counter_settlement:act('counter_settlement'),accept_settlement:act('accept_settlement'),request_cancellation:act('propose_settlement'),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,settlementConsentFor,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 closing=/^\/api\/a423-settlement-consent\/([A-Za-z0-9_-]{1,128})$/.exec(path);
|
|
33
|
+
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'}});}
|
|
34
|
+
const consent=/^\/api\/a423-worker-consent\/([A-Za-z0-9_-]{1,128})$/.exec(path);
|
|
35
|
+
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'}});}
|
|
36
|
+
return Response.json({error:'not found'},{status:404});
|
|
37
|
+
}catch(error){return Response.json({error:error.code||'EVIDENCE_OR_CONFIGURATION_UNAVAILABLE'},{status:error.code==='NOT_FOUND'?404:503});}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
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,settlementConsent,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)),settlementConsentFor:async job=>settlementConsent(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,106 @@
|
|
|
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','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
|
+
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)||(['settlement_negotiation','disputed'].includes(job.state)&&!job.submission))&&seconds>e.work_deadline)action='timeout';
|
|
44
|
+
if(e&&['submitted','verified','disputed','settlement_negotiation'].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 controller=new AbortController();
|
|
62
|
+
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))));
|
|
63
|
+
try{
|
|
64
|
+
let result=item.result;
|
|
65
|
+
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');}
|
|
66
|
+
assert(typeof result.result_hash==='string','Execution must return artifact commitment');
|
|
67
|
+
const job=await workflow.act(item.payload.job_id,'submit',result,{actor:item.payload.actor,idempotencyKey:'execution_'+hash(item.id)});
|
|
68
|
+
assert(await queue.complete(item.id,item.lease,{job_id:job.id,state:job.state}),'Execution lease lost');return job;
|
|
69
|
+
}catch(error){await queue.retry(item.id,item.lease,{delayMs:1000});throw error;}finally{clearInterval(heartbeat);}
|
|
70
|
+
}
|
|
71
|
+
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;}
|
|
72
|
+
function createEventStream({store,authenticate,pollMs=1000,maxDurationMs=25000}={}){
|
|
73
|
+
return async request=>{
|
|
74
|
+
const actor=await authenticate(request);if(!actor)return Response.json({error:'unauthenticated'},{status:401});
|
|
75
|
+
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});
|
|
76
|
+
let closed=false,timer;const encoder=new TextEncoder(),began=Date.now();
|
|
77
|
+
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);}});
|
|
78
|
+
return new Response(stream,{headers:{'content-type':'text/event-stream','cache-control':'no-store','x-accel-buffering':'no'}});
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function createWebhookDispatcher({queue,kv,subscriptions,secretFor,fetch:fetchImpl=globalThis.fetch,timeoutMs=10000}={}){
|
|
82
|
+
assert(queue&&kv&&Array.isArray(subscriptions)&&typeof secretFor==='function','Webhook configuration required');
|
|
83
|
+
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});}
|
|
84
|
+
return {
|
|
85
|
+
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});}}},
|
|
86
|
+
async deliverOne(){const item=await queue.claim({kind:'webhook',leaseMs:timeoutMs+5000});if(!item)return null;try{
|
|
87
|
+
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');
|
|
88
|
+
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)});
|
|
89
|
+
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'};
|
|
90
|
+
}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};}}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
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'});}}
|
|
94
|
+
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;}
|
|
95
|
+
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};}
|
|
96
|
+
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();}
|
|
97
|
+
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'}));}
|
|
98
|
+
module.exports={validateTask,validateEscrowEvidence,createSpendingAuthorizer,runMaintenanceOnce,createExpiryAdapter,runExecutionOnce,enqueueRunningJobs,createEventStream,createWebhookDispatcher,watchEvidenceOnce,freshReceipts,renderReceiptPdf,planLegacyMigration};
|
|
99
|
+
|
|
100
|
+
async function migrateUnfundedLegacy({records,workflow,map,actor}){
|
|
101
|
+
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;
|
|
102
|
+
}
|
|
103
|
+
module.exports.migrateUnfundedLegacy=migrateUnfundedLegacy;
|
|
104
|
+
Object.assign(module.exports,require('./application.cjs'));
|
|
105
|
+
|
|
106
|
+
Object.assign(module.exports,require('./factory.cjs'));
|