@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/SETTLEMENT.md ADDED
@@ -0,0 +1,140 @@
1
+ # Negotiated closing settlements — 0.5.0
2
+
3
+ This release adds a closing negotiation for cancellation and partial-work compensation. A party proposes a worker payment, the other can counter or accept, and the contract pays the agreed amount and refunds the remainder atomically. It closes the whole job. **It does not introduce partial releases while continuing work or milestone payments.**
4
+
5
+ ## Separate facts
6
+
7
+ - `job.state` drives allowed workflow operations. Published negotiation uses `settlement_negotiation`.
8
+ - `job.work_status` records not_started/running/submitted/verified/paused/completed/stopped.
9
+ - `job.escrow_ledger` records funded amount, worker paid, payer refunded, locked amount and proposal version, all as raw units except version.
10
+ - `job.negotiation` records origin state, current proposal, complete proposal history, status and closing observation.
11
+
12
+ No money leaves custody when a proposal is made or countered. Reported effort, token consumption and expenses are claims, not automatically payable debts. Both parties decide the closing allocation, or the agreed arbitrator/deadline rules decide it.
13
+
14
+ ## Lifecycle
15
+
16
+ | Situation | Action | Result |
17
+ |---|---|---|
18
+ | Before funding | Existing `cancel` | No token refund needed |
19
+ | Funded/running/submitted/verified | `propose_settlement` | Publish allocation commitment; work pauses and contract enters Negotiating |
20
+ | Open negotiation | `counter_settlement` | Other party publishes next revision; previous proposal/signatures become stale |
21
+ | Open negotiation | `accept_settlement` | Counterparty accepts exact current revision/hash; worker paid, payer refunded, job settled |
22
+ | Open negotiation | `dispute` | Escalate to arbitrator; ordinary mutual acceptance is no longer available |
23
+ | Disputed | `resolve` | Arbitrator splits and closes escrow under original deadline |
24
+ | Unsubmitted job, including negotiation/dispute | `refund` / `timeout` after original work deadline | Full payer refund remains available |
25
+ | Submitted job in negotiation/dispute | `timeout` after original resolution deadline | Original signed timeout allocation applies |
26
+ | Already paid/refunded/split | Any new proposal/payment attempt | Rejected; settled money cannot be recovered by this protocol |
27
+
28
+ Negotiation is reusable across the eligible work stages, but remains state-aware. Work must stop while it is pending. Once arbitration is engaged, parties cannot unilaterally reopen negotiations and obstruct the arbitrator. Proposed payment never exceeds the locked balance. There is one closing settlement, not a remaining-work agreement.
29
+
30
+ ## Example: request 30, agree on 20 out of 100
31
+
32
+ ```ts
33
+ const offered = await client.act(jobId, 'propose_settlement', {
34
+ expected_revision: 0,
35
+ worker_amount: '30',
36
+ expires_at: proposalExpiryUnixSeconds,
37
+ reason: 'Research complete; analysis is unfinished. I propose closing the job.',
38
+ claims: [
39
+ {kind: 'work', description: 'Collected and checked sources', amount: '20', evidence: ['artifact:source-list']},
40
+ {kind: 'expense', description: 'Agreed research cost', amount: '10', evidence: ['receipt:research-cost']},
41
+ ],
42
+ }, {idempotencyKey: 'closing_offer_1'});
43
+ ```
44
+
45
+ The payer uses its own authenticated client:
46
+
47
+ ```ts
48
+ const counter = await payerClient.act(jobId, 'counter_settlement', {
49
+ expected_revision: offered.negotiation!.current.escrow_version,
50
+ worker_amount: '20',
51
+ expires_at: proposalExpiryUnixSeconds,
52
+ reason: 'The source list is useful; I propose 20 for the completed portion.',
53
+ claims: [],
54
+ }, {idempotencyKey: 'closing_counter_1'});
55
+ ```
56
+
57
+ The worker accepts the exact counter:
58
+
59
+ ```ts
60
+ await workerClient.act(jobId, 'accept_settlement', {
61
+ expected_revision: counter.negotiation!.current.escrow_version,
62
+ proposal_hash: counter.negotiation!.current.proposal_hash,
63
+ }, {idempotencyKey: 'closing_accept_1'});
64
+ ```
65
+
66
+ After confirmation: work stopped, workflow settled, funded 100, worker paid 20, payer refunded 80, locked 0. A payment transaction pending confirmation does not establish these final amounts. Raw amount `100` is not necessarily 100 displayed tokens; apply the configured token decimals only for presentation.
67
+
68
+ Use the same action/key to progress a pending transaction; `workflow.reconcile` observes known transactions and `workflow.resume` authorizes continued execution. A stale, expired, unauthorized or failed proposal does not move tokens. Check operation results: a definitively rejected action leaves the last confirmed job state unchanged. Use a new action key only for a deliberate new request after rejection.
69
+
70
+ ## Exact proposal recipe
71
+
72
+ `settlementProposal(job, input)` derives the payer refund and returns a canonical `a423.closing.v1` record/hash. Input fields:
73
+
74
+ - `expected_revision`: current on-chain proposal revision; 0 for the first proposal.
75
+ - `worker_amount`: nonnegative raw-token integer string. `payer_amount` is optional but must equal funded amount minus worker amount.
76
+ - `expires_at`: absolute Unix seconds, no later than original resolution deadline. The contract also requires it to be in the future when published.
77
+ - `reason`: nonempty explanation, up to 4000 characters.
78
+ - `claims`: optional list, up to 64 work/expense/other claims, each with description, optional raw amount and up to 32 evidence references.
79
+
80
+ The canonical commitment includes job ID, original agreement hash, next escrow/proposal version, both allocations, expiry, explanation, claims and `closes_job: true`. The proposal's claim amounts do not automatically sum to the payout; they support the negotiation and can be disputed. Evidence references are not fetched or certified by the helper. Do not place secrets or private customer information in proposal data.
81
+
82
+ Publish/counter goes on-chain with expected revision, worker amount, proposal hash and expiry. Explanations/claims remain in the app's durable record and are bound by the hash. A transaction per published revision is deliberate: it freezes ordinary release and invalidates old revisions on-chain. Informal chat discussion can remain off-chain until a party actually publishes a proposal.
83
+
84
+ Counterproposals must alternate parties. A proposer cannot self-accept through `acceptSettlement`, or unilaterally renew its own current proposal. A published offer is actionable until it expires, is superseded by the counterparty's proposal, enters dispute, or the job is otherwise settled. Choose expiry carefully. Expiration does not resume work or release money automatically; the other party may counter, either party may escalate, or the original fallback remains available.
85
+
86
+ ## Signatures and relayers
87
+
88
+ The counterparty's direct `acceptSettlement(jobId, revision, proposalHash)` transaction is on-chain acceptance of the proposer's published terms. Alternatively, obtain both signatures:
89
+
90
+ ```ts
91
+ import { settlementConsent } from '@allorc/a423-sdk/escrow';
92
+ const typed = settlementConsent(job, escrowConfiguration);
93
+ const payerSignature = await payerSigner.signTypedData(typed.domain, typed.types, typed.value);
94
+ const workerSignature = await workerSigner.signTypedData(typed.domain, typed.types, typed.value);
95
+ ```
96
+
97
+ Then a participant may relay through the adapter:
98
+
99
+ ```ts
100
+ await client.act(jobId, 'accept_settlement', {
101
+ expected_revision: typed.value.escrowVersion,
102
+ proposal_hash: typed.value.proposalHash,
103
+ payer_signature: payerSignature,
104
+ worker_signature: workerSignature,
105
+ }, {idempotencyKey: 'relay_closing_1'});
106
+ ```
107
+
108
+ `settleByConsent` is permissionless on-chain: any relayer can submit both approvals. It verifies the EIP-712 domain (chain and contract), job, agreement hash, exact current proposal revision/hash, both allocations and expiry. EOA and ERC-1271 signatures are supported. Old revisions, altered allocations, wrong chains/contracts, expired signatures and already-settled jobs are rejected. A proposal commitment without these approvals is not a payout authorization.
109
+
110
+ `GET /api/a423-settlement-consent/:id` returns typed data only to the payer/worker. It never generates private keys or signs for them. The reference dashboard can sign the current closing terms with an EIP-1193 EOA wallet and display/share the public signature. For ERC-1271, obtain the account's own signature format through its wallet. A proposer relaying through the SDK must supply both signatures; it cannot self-accept merely by submitting its own transaction. Externally submitted settlement transactions can be adopted with verified `transaction_hash` alongside the expected revision/hash.
111
+
112
+ ## Contract enforcement and deadline behaviour
113
+
114
+ New reference contract state `Negotiating` is enum value 8; previous state values 0..7 remain unchanged. `proposeSettlement` accepts funded/submitted/verified/current-negotiating jobs. It sets Negotiating before acceptance is possible. `submit`, `verify` and ordinary `release` are blocked in that state. New event `SettlementProposed` records revision, proposer, allocation, hash and expiry. Acceptance emits `SettlementAccepted` and `Settled` with reason **4 (mutual closing settlement)**. Transfers use the same exact-token/nonreentrant settlement path.
115
+
116
+ Crucially, proposing does not extend `workDeadline`, `reviewDeadline` or `resolutionDeadline`:
117
+
118
+ - **No committed submission:** after the original work deadline, anyone can still trigger full payer refund—even if a partial-work claim or an unexpired proposal exists. A claim alone does not remove that agreed refund right. Agree or seek arbitration in time; do not imply unpaid effort itself guarantees compensation.
119
+ - **Committed submission:** original refund restrictions remain. After the original resolution deadline, anyone may trigger the original `timeoutWorkerBps` split, not the last unaccepted offer.
120
+ - **Arbitration:** an arbitrator may resolve an eligible dispute before the original resolution deadline, including pre-submission compensation disputes. That competes with any independently eligible original fallback. The first valid settlement mined closes the job.
121
+
122
+ If release was mined before cancellation was published, cancellation fails; there is no guaranteed challenge window before verified release. Likewise, acceptance and an eligible timeout/refund can race: whichever valid transaction executes first settles once, and later attempts fail. A chat message or a pending transaction does not establish an on-chain lock on release.
123
+
124
+ ## Worker and UI integration
125
+
126
+ The SDK marks `work_status: paused` when a proposal is confirmed and blocks queued work through `assertSafe`. Pending proposal/dispute intent also blocks fresh execution immediately. The execution runner supplies `execute({job,idempotencyKey,signal})` and aborts that signal when it observes a pause or lost lease. Your AI/tools must honor cancellation signals to stop ongoing computation. External processes cannot be physically stopped by a Solidity contract. A checkpointed partial artifact is retained, but cannot be automatically submitted while negotiation blocks the job.
127
+
128
+ Chat tools now expose `request_cancellation`, `propose_settlement`, `counter_settlement`, `accept_settlement`; cancellation maps to a closing proposal after funding. Ordinary `cancel_job` remains pre-funding. The application factory wires the new adapters and settlement-consent endpoint. Existing generic workflow users must use the updated `escrowTransitions` and reference adapters deliberately.
129
+
130
+ The dashboard shell shows work status, negotiation/history and escrow ledger. It provides proposal/counter/accept actions, raw allocation inputs, claim data and a wallet signing control. Display the two sides distinctly:
131
+
132
+ > Cancellation / partial-work settlement proposed. Work paused. 100 remains locked. Proposed worker payment 30; payer refund 70. Awaiting the counterparty.
133
+
134
+ Display completed allocations only after matching confirmed `SettlementAccepted`/`Settled` evidence. `freshReceipts` checks claim commitments and revalidates proposal/settlement events. PDFs/JSON include history and the ledger, without claiming expenses were independently proven.
135
+
136
+ ## Upgrade and limits
137
+
138
+ Version **0.5.0 requires a newly deployed reference contract** for this functionality. Existing 0.4 and earlier contracts are immutable and cannot acquire these methods by changing the npm version or RPC URL. Keep their adapter/ABI pinned for existing custody; do not relabel their records or move their tokens without the original release/refund process. Node SDK package files include the new compiled artifact and standard compiler input. No public deployment is performed automatically.
139
+
140
+ The upgrade does not add milestones, payment while continuing work, scope revisions, resubmission after a commitment, a quality/cost oracle, token recovery, admin withdrawals, pause/upgrades or support for broken/fee/rebasing tokens. Verifier/arbitrator trust and reviewed-token assumptions remain. Deployment and funding on a public chain spend real assets; the new contract remains experimental and unaudited.
@@ -6,12 +6,13 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol
6
6
  import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
7
7
  import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
8
8
  import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
9
+ import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";
9
10
 
10
11
  /// @notice Experimental reference escrow for standard, non-rebasing ERC20 tokens.
11
12
  /// No admin withdrawals/upgrades. Arbitration and timeout allocation are agreed before funding.
12
13
  contract A423Escrow is ReentrancyGuard, EIP712 {
13
14
  using SafeERC20 for IERC20;
14
- enum State { None, Funded, Submitted, Verified, Disputed, Paid, Refunded, Split }
15
+ enum State { None, Funded, Submitted, Verified, Disputed, Paid, Refunded, Split, Negotiating }
15
16
  struct Terms {
16
17
  bytes32 jobId;
17
18
  address payer;
@@ -25,10 +26,29 @@ contract A423Escrow is ReentrancyGuard, EIP712 {
25
26
  uint16 timeoutWorkerBps;
26
27
  bytes32 agreementHash;
27
28
  }
28
- struct Job { Terms terms; State state; bytes32 resultHash; uint256 workerPaid; }
29
+ struct Proposal {
30
+ uint64 revision;
31
+ uint64 expiresAt;
32
+ address proposer;
33
+ uint256 workerAmount;
34
+ bytes32 proposalHash;
35
+ }
36
+ struct ClosingSettlement {
37
+ bytes32 jobId;
38
+ bytes32 agreementHash;
39
+ uint64 escrowVersion;
40
+ uint256 workerAmount;
41
+ uint256 payerAmount;
42
+ bytes32 proposalHash;
43
+ uint64 expiresAt;
44
+ }
45
+ struct Job { Terms terms; State state; bytes32 resultHash; uint256 workerPaid; Proposal proposal; }
29
46
  IERC20 public immutable token;
30
47
  mapping(bytes32 => Job) private jobs;
31
48
  bytes32 public constant TERMS_TYPEHASH = keccak256("Terms(bytes32 jobId,address payer,address worker,address verifier,address arbitrator,uint256 amount,uint64 workDeadline,uint64 reviewDeadline,uint64 resolutionDeadline,uint16 timeoutWorkerBps,bytes32 agreementHash)");
49
+ bytes32 public constant SETTLEMENT_TYPEHASH = keccak256("ClosingSettlement(bytes32 jobId,bytes32 agreementHash,uint64 escrowVersion,uint256 workerAmount,uint256 payerAmount,bytes32 proposalHash,uint64 expiresAt)");
50
+ event SettlementProposed(bytes32 indexed jobId, uint64 revision, address indexed proposer, uint256 workerAmount, uint256 payerAmount, bytes32 proposalHash, uint64 expiresAt);
51
+ event SettlementAccepted(bytes32 indexed jobId, uint64 revision, bytes32 proposalHash);
32
52
  event Funded(bytes32 indexed jobId, bytes32 indexed agreementHash, address indexed payer, address worker, uint256 amount);
33
53
  event Submitted(bytes32 indexed jobId, bytes32 resultHash);
34
54
  event Verified(bytes32 indexed jobId, bytes32 resultHash);
@@ -54,8 +74,11 @@ contract A423Escrow is ReentrancyGuard, EIP712 {
54
74
  if (jobs[t.jobId].state != State.None) revert WrongState();
55
75
  if (t.jobId == bytes32(0) || t.agreementHash == bytes32(0) || t.amount == 0 || t.worker == address(0) || t.worker == t.payer || t.verifier == address(0) || t.arbitrator == address(0) || t.arbitrator == t.payer || t.arbitrator == t.worker || t.timeoutWorkerBps > 10000) revert InvalidTerms();
56
76
  if (t.workDeadline <= block.timestamp || t.reviewDeadline <= t.workDeadline || t.resolutionDeadline <= t.reviewDeadline) revert InvalidTerms();
57
- if (ECDSA.recover(termsDigest(t), workerSignature) != t.worker) revert Unauthorized();
58
- jobs[t.jobId] = Job(t, State.Funded, bytes32(0), 0);
77
+ bool valid;
78
+ if (t.worker.code.length == 0) { valid = ECDSA.recover(termsDigest(t), workerSignature) == t.worker; }
79
+ else { try IERC1271(t.worker).isValidSignature(termsDigest(t), workerSignature) returns (bytes4 magic) { valid = magic == IERC1271.isValidSignature.selector; } catch { valid = false; } }
80
+ if (!valid) revert Unauthorized();
81
+ jobs[t.jobId] = Job(t, State.Funded, bytes32(0), 0, Proposal(0, 0, address(0), 0, bytes32(0)));
59
82
  uint256 beforeBalance = token.balanceOf(address(this));
60
83
  token.safeTransferFrom(t.payer, address(this), t.amount);
61
84
  if (token.balanceOf(address(this)) != beforeBalance + t.amount) revert TokenAmountMismatch();
@@ -87,14 +110,14 @@ contract A423Escrow is ReentrancyGuard, EIP712 {
87
110
  }
88
111
  function refundExpired(bytes32 id) external nonReentrant {
89
112
  Job storage j = jobs[id];
90
- if (j.state != State.Funded) revert WrongState();
113
+ if (j.state != State.Funded && !((j.state == State.Negotiating || j.state == State.Disputed) && j.resultHash == bytes32(0))) revert WrongState();
91
114
  if (block.timestamp <= j.terms.workDeadline) revert Deadline();
92
115
  _settle(id, j, 0, 1);
93
116
  }
94
117
  function dispute(bytes32 id, bytes32 reasonHash) external {
95
118
  Job storage j = jobs[id];
96
119
  if (msg.sender != j.terms.payer && msg.sender != j.terms.worker && msg.sender != j.terms.verifier) revert Unauthorized();
97
- if (j.state != State.Submitted && j.state != State.Verified) revert WrongState();
120
+ if (j.state != State.Submitted && j.state != State.Verified && j.state != State.Negotiating && j.state != State.Funded) revert WrongState();
98
121
  if (block.timestamp > j.terms.resolutionDeadline) revert Deadline();
99
122
  j.state = State.Disputed; emit Disputed(id, reasonHash);
100
123
  }
@@ -108,12 +131,55 @@ contract A423Escrow is ReentrancyGuard, EIP712 {
108
131
  /// Permissionless liveness fallback; requires an actual transaction after the deadline.
109
132
  function timeout(bytes32 id) external nonReentrant {
110
133
  Job storage j = jobs[id];
111
- if (j.state != State.Submitted && j.state != State.Verified && j.state != State.Disputed) revert WrongState();
134
+ if ((j.state != State.Submitted && j.state != State.Verified && j.state != State.Disputed && j.state != State.Negotiating) || j.resultHash == bytes32(0)) revert WrongState();
112
135
  if (block.timestamp <= j.terms.resolutionDeadline) revert Deadline();
113
136
  // Quotient/remainder avoids overflowing amount * bps.
114
137
  uint256 amount = (j.terms.amount / 10000) * j.terms.timeoutWorkerBps + ((j.terms.amount % 10000) * j.terms.timeoutWorkerBps) / 10000;
115
138
  _settle(id, j, amount, 3);
116
139
  }
140
+ /// A published proposal freezes submit/verify/release, but never extends a deadline.
141
+ /// Off-chain claim/evidence bytes are committed through proposalHash.
142
+ function proposeSettlement(bytes32 id, uint64 expectedRevision, uint256 workerAmount, bytes32 proposalHash, uint64 expiresAt) external {
143
+ Job storage j = jobs[id];
144
+ if (msg.sender != j.terms.payer && msg.sender != j.terms.worker) revert Unauthorized();
145
+ if (j.state != State.Funded && j.state != State.Submitted && j.state != State.Verified && j.state != State.Negotiating) revert WrongState();
146
+ if (j.proposal.revision != expectedRevision || workerAmount > j.terms.amount || proposalHash == bytes32(0)) revert InvalidTerms();
147
+ if (j.state == State.Negotiating && j.proposal.proposer == msg.sender) revert Unauthorized();
148
+ if (expiresAt <= block.timestamp || expiresAt > j.terms.resolutionDeadline || block.timestamp > j.terms.resolutionDeadline) revert Deadline();
149
+ j.proposal = Proposal(expectedRevision + 1, expiresAt, msg.sender, workerAmount, proposalHash);
150
+ j.state = State.Negotiating;
151
+ emit SettlementProposed(id, j.proposal.revision, msg.sender, workerAmount, j.terms.amount - workerAmount, proposalHash, expiresAt);
152
+ }
153
+ /// The counterparty's transaction accepts the exact currently published closing allocation.
154
+ function acceptSettlement(bytes32 id, uint64 revision, bytes32 proposalHash) external nonReentrant {
155
+ Job storage j = jobs[id];
156
+ if ((msg.sender != j.terms.payer && msg.sender != j.terms.worker) || msg.sender == j.proposal.proposer) revert Unauthorized();
157
+ _checkProposal(j, revision, proposalHash);
158
+ emit SettlementAccepted(id, revision, proposalHash);
159
+ _settle(id, j, j.proposal.workerAmount, 4);
160
+ }
161
+ function settlementDigest(ClosingSettlement calldata s) public view returns (bytes32) {
162
+ return _hashTypedDataV4(keccak256(abi.encode(SETTLEMENT_TYPEHASH, s)));
163
+ }
164
+ /// Anyone can relay both parties' exact, current, chain/domain-bound signatures.
165
+ function settleByConsent(ClosingSettlement calldata s, bytes calldata payerSignature, bytes calldata workerSignature) external nonReentrant {
166
+ Job storage j = jobs[s.jobId];
167
+ _checkProposal(j, s.escrowVersion, s.proposalHash);
168
+ if (s.agreementHash != j.terms.agreementHash || s.workerAmount != j.proposal.workerAmount || s.payerAmount != j.terms.amount - s.workerAmount || s.expiresAt != j.proposal.expiresAt) revert InvalidTerms();
169
+ bytes32 digest = settlementDigest(s);
170
+ if (!_validSignature(j.terms.payer, digest, payerSignature) || !_validSignature(j.terms.worker, digest, workerSignature)) revert Unauthorized();
171
+ emit SettlementAccepted(s.jobId, s.escrowVersion, s.proposalHash);
172
+ _settle(s.jobId, j, s.workerAmount, 4);
173
+ }
174
+ function _validSignature(address account, bytes32 digest, bytes calldata signature) private view returns (bool) {
175
+ if (account.code.length == 0) return ECDSA.recover(digest, signature) == account;
176
+ try IERC1271(account).isValidSignature(digest, signature) returns (bytes4 magic) { return magic == IERC1271.isValidSignature.selector; } catch { return false; }
177
+ }
178
+ function _checkProposal(Job storage j, uint64 revision, bytes32 proposalHash) private view {
179
+ if (j.state != State.Negotiating) revert WrongState();
180
+ if (revision != j.proposal.revision || proposalHash != j.proposal.proposalHash) revert InvalidTerms();
181
+ if (block.timestamp > j.proposal.expiresAt || block.timestamp > j.terms.resolutionDeadline) revert Deadline();
182
+ }
117
183
  function _settle(bytes32 id, Job storage j, uint256 workerAmount, uint8 reason) private {
118
184
  uint256 payerAmount = j.terms.amount - workerAmount;
119
185
  j.state = workerAmount == 0 ? State.Refunded : payerAmount == 0 ? State.Paid : State.Split;