Settle one verified forge bounty from the treasury

ae9fad08d4aa · Devin AI · · parent 3ad2bc0cd754

Settle one verified forge bounty from the treasury

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • added docs/bounty-settlement.md
  • added lib/openagents/settlement.ex
  • added lib/openagents/settlement/adjustment.ex
  • added lib/openagents/settlement/bounty_spec.ex
  • added lib/openagents/settlement/claim.ex
  • added lib/openagents/settlement/payment_gateway.ex
  • added lib/openagents/settlement/payment_gateway/unconfigured.ex
  • added lib/openagents/settlement/payment_intent.ex
  • added lib/openagents/settlement/payment_receipt.ex
  • added lib/openagents/settlement/treasury_policy.ex
  • added lib/openagents/settlement/verification.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823060000_create_bounty_settlement.exs
  • added test/openagents/settlement_test.exs

Diff

15 files changed, +2580 -1

INVARIANTS.md modified +39

@@ -834,6 +834,44 @@ constraints on `reputation_attestations`, `OpenAgentsWeb.ReputationController`,

834 834
`test/openagents/reputation_test.exs`, and
835 835
`test/openagents_web/controllers/reputation_controller_test.exs`.
836 836
837
### SETTLEMENT-001 — A bounty pays once, against fingerprinted evidence
838
839
Status: Current
840
841
Bounty settlement is a separate authority from attribution accounting. A payment
842
leaves the treasury only when an operator-admitted treasury policy bounds the
843
amount, the daily budget, the attempt count, and the admitted self-custodial
844
destination kinds; the priced specification carries a named buyer, a sats
845
amount, acceptance criteria, a verification policy, an expiry, and a fingerprint
846
over all of them; the claim pins that fingerprint and the claimant's own
847
destination; a verification under the specification's own verifier policy digest
848
accepts the exact commit the claim delivered; and the settlement request carries
849
an approval reference and an idempotency key.
850
851
A repriced specification, a moved fingerprint, a rejected verifier, a commit
852
without its own verification, a missing approval, an expired claim, a dispute,
853
an exhausted budget, or an exhausted attempt bound each stop the payment. One
854
idempotency key names one payment intent, one intent per claim can reach `paid`,
855
one receipt exists per intent, and a payment hash is unique, so a duplicate
856
request, a retry, and a lost acknowledgement all resolve to the first receipt
857
instead of a second payment. Expiry, dispute, and refund are append-only
858
adjustments that never rewrite a receipt.
859
860
The treasury never holds the claimant's keys and never provisions a wallet for
861
them: the domain hands an authorized request to the configured gateway and
862
records the returned evidence, and an unconfigured gateway fails closed. Public
863
projections publish only the amount, the status, reference kinds, and the
864
evidence the repository's disclosure level admits, never a destination, a
865
claimant or buyer reference, an operator identity, an approval reference, or a
866
gateway reference. The claimant can export the full receipt, including their own
867
destination, without a hosted wallet.
868
869
Evidence: `OpenAgents.Settlement`, `OpenAgents.Settlement.PaymentGateway`, its
870
seven append-only schemas with their uniqueness and partial-uniqueness
871
constraints, and the pricing, claim, verification, duplicate, stale-commit,
872
approval, budget, retry, reconciliation, expiry, dispute, refund, privacy, and
873
receipt-export cases in `test/openagents/settlement_test.exs`.
874
837 875
### MODULE-001 — Every invocation pins one immutable admitted module
838 876
839 877
Status: Current

@@ -2114,6 +2152,7 @@ contract; the invariant prose above defines the assertion, not the filename.

2114 2152
| COLLECTIVE-003 | `test/openagents/collective_publication_test.exs` |
2115 2153
| COMPENSATION-001 | `test/openagents/compensation_test.exs` |
2116 2154
| REPUTATION-001 | `test/openagents/reputation_test.exs`, `test/openagents_web/controllers/reputation_controller_test.exs` |
2155
| SETTLEMENT-001 | `test/openagents/settlement_test.exs` |
2117 2156
| MODULE-001 | `test/openagents/modules/registry_test.exs`, `test/openagents/tool_step_persistence_test.exs` |
2118 2157
| MODULE-002 | `test/openagents/modules/discovery_test.exs`, `test/openagents/modules/lifecycle_test.exs` |
2119 2158
| MODULE-003 | `test/openagents/modules/router_test.exs`, `test/openagents/turn_tool_loop_test.exs` |
docs/bounty-settlement.md added +87

@@ -0,0 +1,87 @@

1
# Bounty settlement
2
3
`OpenAgents.Settlement` prices a forge issue in sats, admits one claim, grades
4
the delivery at an exact commit, and pays the claimant from the treasury against
5
an inspectable receipt chain. The contract is `SETTLEMENT-001` in
6
[`INVARIANTS.md`](../INVARIANTS.md), proven by
7
[`test/openagents/settlement_test.exs`](../test/openagents/settlement_test.exs).
8
9
The settlement authority is separate from attribution accounting. Compensation
10
accounting still holds `payout_authority: false` (`COMPENSATION-001`); nothing in
11
this loop grants it a payment operation.
12
13
## The loop
14
15
1. **Admit the treasury policy.** `admit_treasury_policy/2` records the maximum
16
   payment, the daily budget, the attempt bound, the admitted self-custodial
17
   destination kinds, and the refund, expiry, retry, and dispute behavior, with
18
   the operator identity, the authentication method, and the approval reference
19
   behind it. The policy digest covers its exact rules.
20
2. **Price the issue.** `price_bounty/3` records the named buyer, the sats
21
   amount, the acceptance criteria, the verification policy, the destination
22
   kind, and the expiry, then fingerprints all of them together with the policy
23
   digest and the revision. Repricing appends a revision with a new fingerprint.
24
3. **Claim it.** `claim_bounty/2` pins the fingerprint and the claimant's own
25
   destination, and stores the destination digest next to it. One claim holds a
26
   specification at a time; an expired or rejected claim releases it.
27
4. **Verify the delivery.** `verify_claim/2` records a qualification receipt for
28
   one claim at one commit, under the specification's own verifier policy digest
29
   and the claim's own work job. An accepted verification moves the claim to
30
   `verified`; a rejected one moves it to `rejected`.
31
5. **Settle.** `settle/2` needs the exact commit, an approval reference, and an
32
   idempotency key. It refuses a superseded specification, a moved fingerprint, a
33
   commit without its own verification, an expired claim, a dispute, an amount
34
   above the treasury authority, an exhausted daily budget, and an exhausted
35
   attempt bound. It then dispatches one payment through the configured gateway
36
   and records the exact receipt.
37
6. **Export the evidence.** `export_payment_receipt/2` gives the claimant the
38
   amount, the fee, the payment hash, the preimage digest, the commit, the
39
   fingerprint, and their own destination. `public_projection/1` publishes only
40
   what the repository's disclosure level admits.
41
42
## Custody
43
44
The domain holds no keys, no node, and no wallet. `PaymentGateway` is the whole
45
boundary: it takes an authorized, idempotency-keyed request and answers `{:ok,
46
settled}`, `{:pending, reason_code}`, or `{:error, reason_code}`. Configure the
47
production gateway with `:settlement_payment_gateway`. Without that
48
configuration, `PaymentGateway.Unconfigured` refuses every payment, so an
49
unprepared environment fails closed instead of appearing to pay.
50
51
The claimant is paid at a destination they control, so nobody needs a hosted
52
OpenAgents wallet to collect a bounty.
53
54
Forum tipping keeps its own narrower boundary,
55
`OpenAgents.Forum.Tips.PaymentService`, because a tip is a one-shot transfer
56
between two people. A treasury payment also has to survive a lost
57
acknowledgement, so the settlement gateway additionally answers `lookup/1` for
58
the terminal state of an idempotency key.
59
60
## What stops a second payment
61
62
One idempotency key names one payment intent for the life of the settlement:
63
64
- A duplicate request returns the first receipt and never calls the gateway
65
  again.
66
- A failed attempt retries under the same key until the policy attempt bound.
67
- A lost acknowledgement stays `pending`; `reconcile/1` asks the gateway for the
68
  key's terminal state and turns a settled key into its receipt.
69
- A partial unique index allows one `paid` intent per claim, one receipt per
70
  intent, and one row per payment hash.
71
- Reusing a key for a different claim fails with `:idempotency_key_conflict`.
72
73
## Expiry, dispute, and refund
74
75
`expire_claim/3`, `open_dispute/3`, and `refund/3` append an adjustment carrying
76
the operator identity, the authentication method, the approval reference, and a
77
reason code. An expiry releases the specification for another claimant. A dispute
78
freezes settlement. A refund applies only to a paid claim and never rewrites the
79
payment receipt.
80
81
## What stays private
82
83
Public projections carry the amount, the status, reference kinds, the acceptance
84
criteria count, and — at the ledger level — the fingerprint, the commit, the
85
payment hash, and the receipt digest. They never carry a destination, a claimant
86
or buyer reference, a work job reference, an operator identity, an approval
87
reference, a gateway reference, or a preimage digest.
lib/openagents/settlement.ex added +1142

@@ -0,0 +1,1142 @@

1
defmodule OpenAgents.Settlement do
2
  @moduledoc """
3
  Bounty settlement: price a forge issue, claim it, verify the delivery, and pay
4
  the claimant from the treasury against an inspectable receipt chain.
5
6
  The domain owns authority and evidence, never custody. Every payment needs an
7
  operator-admitted treasury policy, an approval reference, a specification
8
  fingerprint that has not moved, an accepted verification at the exact commit,
9
  and an idempotency key. The configured payment gateway
10
  (`OpenAgents.Settlement.PaymentGateway`) performs the transfer to a
11
  self-custodial destination and returns exact evidence.
12
13
  A specification change, a failed verifier, a stale commit, a missing
14
  approval, an exhausted budget, an expired claim, a dispute, or a duplicate
15
  request each stop a payment. A lost acknowledgement reconciles through the
16
  same idempotency key, so it never becomes a second payment.
17
  """
18
19
  import Ecto.Query
20
21
  alias OpenAgents.Forge.Visibility
22
  alias OpenAgents.Issues.Issue
23
  alias OpenAgents.Provenance.Canonical
24
  alias OpenAgents.Repo
25
26
  alias OpenAgents.Settlement.{
27
    Adjustment,
28
    BountySpec,
29
    Claim,
30
    PaymentGateway,
31
    PaymentIntent,
32
    PaymentReceipt,
33
    TreasuryPolicy,
34
    Verification
35
  }
36
37
  @policy_id "openagents.settlement.bounty.v1"
38
  @policy_version 1
39
40
  @default_rules %{
41
    "unit" => "sat",
42
    "custody" => "claimant_self_custodial_destination",
43
    "destination_kinds" => ["bolt12_offer"],
44
    "max_payment_sats" => 10_000,
45
    "daily_budget_sats" => 50_000,
46
    "max_attempts" => 3,
47
    "requires_accepted_verification" => true,
48
    "requires_approval_receipt" => true,
49
    "expiry" => "an expired claim releases the specification and never pays",
50
    "failed_payment" => "the same idempotency key retries until max_attempts",
51
    "lost_acknowledgement" => "reconcile the idempotency key, never pay twice",
52
    "dispute" => "a dispute freezes settlement until an operator resolves it",
53
    "refund" => "a refund is an append-only adjustment, never a receipt rewrite"
54
  }
55
56
  @doc "The settlement policy identifier."
57
  def policy_id, do: @policy_id
58
59
  @doc "The default treasury rules an operator can narrow before admission."
60
  def default_rules, do: @default_rules
61
62
  @doc """
63
  Admits the treasury policy that bounds every later payment.
64
65
  `operator` carries `actor_id`, `auth_method`, and `approval_receipt_ref`.
66
  """
67
  @spec admit_treasury_policy(map(), map()) :: {:ok, TreasuryPolicy.t()} | {:error, term()}
68
  def admit_treasury_policy(operator, rules \\ %{}) do
69
    rules = Map.merge(@default_rules, rules)
70
71
    with :ok <- validate_operator(operator),
72
         :ok <- validate_rules(rules) do
73
      digest =
74
        Canonical.digest!(%{
75
          "policy_id" => @policy_id,
76
          "version" => @policy_version,
77
          "rules" => rules
78
        })
79
80
      %TreasuryPolicy{}
81
      |> TreasuryPolicy.changeset(%{
82
        policy_id: @policy_id,
83
        version: @policy_version,
84
        policy_digest: digest,
85
        rules: rules,
86
        actor_id: operator.actor_id,
87
        auth_method: operator.auth_method,
88
        approval_receipt_ref: operator.approval_receipt_ref
89
      })
90
      |> Repo.insert()
91
    end
92
  end
93
94
  @doc "The admitted treasury policy, or `{:error, :treasury_policy_missing}`."
95
  @spec treasury_policy() :: {:ok, TreasuryPolicy.t()} | {:error, :treasury_policy_missing}
96
  def treasury_policy do
97
    case Repo.get_by(TreasuryPolicy, policy_id: @policy_id, version: @policy_version) do
98
      %TreasuryPolicy{} = policy -> {:ok, policy}
99
      nil -> {:error, :treasury_policy_missing}
100
    end
101
  end
102
103
  @doc """
104
  Prices one issue and fingerprints the specification.
105
106
  Repricing the same issue appends a revision with a new fingerprint. Claims
107
  pinned to the previous fingerprint can no longer be paid.
108
  """
109
  @spec price_bounty(Issue.t(), map(), map()) :: {:ok, BountySpec.t()} | {:error, term()}
110
  def price_bounty(%Issue{} = issue, attributes, operator) do
111
    with :ok <- validate_operator(operator),
112
         {:ok, policy} <- treasury_policy(),
113
         {:ok, priced} <- validate_price(policy, attributes) do
114
      revision = next_revision(issue)
115
116
      fingerprint =
117
        Canonical.digest!(%{
118
          "policy_digest" => policy.policy_digest,
119
          "issue_id" => issue.id,
120
          "revision" => revision,
121
          "buyer_ref" => priced.buyer_ref,
122
          "amount_sats" => priced.amount_sats,
123
          "acceptance_criteria" => priced.acceptance_criteria,
124
          "verification_policy" => priced.verification_policy,
125
          "destination_kind" => priced.destination_kind,
126
          "expires_at" => DateTime.to_iso8601(priced.expires_at)
127
        })
128
129
      %BountySpec{}
130
      |> BountySpec.changeset(%{
131
        treasury_policy_id: policy.id,
132
        issue_id: issue.id,
133
        revision: revision,
134
        buyer_ref: priced.buyer_ref,
135
        amount_sats: priced.amount_sats,
136
        acceptance_criteria: priced.acceptance_criteria,
137
        verification_policy: priced.verification_policy,
138
        destination_kind: priced.destination_kind,
139
        expires_at: priced.expires_at,
140
        spec_fingerprint: fingerprint,
141
        actor_id: operator.actor_id,
142
        auth_method: operator.auth_method,
143
        approval_receipt_ref: operator.approval_receipt_ref
144
      })
145
      |> Repo.insert()
146
    end
147
  end
148
149
  @doc "The current priced specification for an issue, or nil."
150
  @spec current_spec(Issue.t()) :: BountySpec.t() | nil
151
  def current_spec(%Issue{} = issue) do
152
    BountySpec
153
    |> where(issue_id: ^issue.id)
154
    |> order_by(desc: :revision)
155
    |> limit(1)
156
    |> Repo.one()
157
  end
158
159
  @doc """
160
  Claims the current specification and pins its fingerprint.
161
162
  `claimant` carries `claimant_ref`, `work_job_ref`, `destination_kind`, and
163
  `destination`. The destination is the claimant's own; the settlement never
164
  creates or holds a wallet for them.
165
  """
166
  @spec claim_bounty(BountySpec.t(), map()) :: {:ok, Claim.t()} | {:error, term()}
167
  def claim_bounty(%BountySpec{} = spec, claimant) do
168
    with {:ok, policy} <- treasury_policy(),
169
         :ok <- require_current_spec(spec),
170
         :ok <- require_unexpired_spec(spec),
171
         {:ok, destination} <- validate_destination(policy, spec, claimant),
172
         {:ok, refs} <- validate_claimant(claimant) do
173
      digest =
174
        Canonical.digest!(%{
175
          "spec_fingerprint" => spec.spec_fingerprint,
176
          "claimant_ref" => refs.claimant_ref,
177
          "work_job_ref" => refs.work_job_ref,
178
          "destination_digest" => destination.digest
179
        })
180
181
      %Claim{}
182
      |> Claim.changeset(%{
183
        bounty_spec_id: spec.id,
184
        spec_fingerprint: spec.spec_fingerprint,
185
        claimant_ref: refs.claimant_ref,
186
        work_job_ref: refs.work_job_ref,
187
        destination_kind: destination.kind,
188
        destination: destination.value,
189
        destination_digest: destination.digest,
190
        state: "claimed",
191
        claim_digest: digest,
192
        expires_at: spec.expires_at
193
      })
194
      |> Repo.insert()
195
    end
196
  end
197
198
  @doc """
199
  Records the qualification receipt for one claim at one exact commit.
200
201
  An accepted verification moves the claim to `verified`; a rejected one moves
202
  it to `rejected` and releases the specification for another claimant.
203
  """
204
  @spec verify_claim(Claim.t(), map()) :: {:ok, Verification.t()} | {:error, term()}
205
  def verify_claim(%Claim{} = claim, attributes) do
206
    with {:ok, claim} <- reload_claim(claim),
207
         {:ok, spec} <- claim_spec(claim),
208
         :ok <- require_settleable_claim(claim),
209
         :ok <- require_pinned_fingerprint(claim, spec),
210
         :ok <- require_unexpired_claim(claim),
211
         {:ok, decision} <- validate_verification(claim, spec, attributes) do
212
      Repo.transaction(fn ->
213
        verification =
214
          %Verification{}
215
          |> Verification.changeset(%{
216
            claim_id: claim.id,
217
            spec_fingerprint: spec.spec_fingerprint,
218
            commit_sha: decision.commit_sha,
219
            work_job_ref: claim.work_job_ref,
220
            verifier_ref: decision.verifier_ref,
221
            verifier_policy_digest: decision.verifier_policy_digest,
222
            evidence_digest: decision.evidence_digest,
223
            outcome: decision.outcome,
224
            reason_code: decision.reason_code,
225
            auth_method: decision.auth_method,
226
            decision_receipt_ref: decision.decision_receipt_ref
227
          })
228
          |> Repo.insert()
229
          |> or_rollback()
230
231
        claim_state = if decision.outcome == "accepted", do: "verified", else: "rejected"
232
233
        claim
234
        |> Claim.state_changeset(claim_state)
235
        |> Repo.update()
236
        |> or_rollback()
237
238
        verification
239
      end)
240
      |> transaction_result()
241
    end
242
  end
243
244
  @doc """
245
  Settles a verified claim from the treasury, once.
246
247
  `request` carries `commit_sha`, `idempotency_key`, `actor_id`, `auth_method`,
248
  and `approval_receipt_ref`. A duplicate request with the same key returns the
249
  original receipt without paying again.
250
  """
251
  @spec settle(Claim.t(), map()) ::
252
          {:ok, %{claim: Claim.t(), intent: PaymentIntent.t(), receipt: PaymentReceipt.t()}}
253
          | {:pending, PaymentIntent.t()}
254
          | {:error, term()}
255
  def settle(%Claim{} = claim, request) do
256
    with {:ok, operator} <- validate_settlement_request(request),
257
         {:ok, claim} <- reload_claim(claim),
258
         {:ok, prior} <- prior_intent(claim, operator.idempotency_key) do
259
      case prior do
260
        %PaymentIntent{state: "paid"} = intent -> settled_intent(intent)
261
        _open -> authorize(claim, operator)
262
      end
263
    end
264
  end
265
266
  defp prior_intent(%Claim{} = claim, idempotency_key) do
267
    case Repo.get_by(PaymentIntent, idempotency_key: idempotency_key) do
268
      nil -> {:ok, nil}
269
      %PaymentIntent{claim_id: claim_id} = intent when claim_id == claim.id -> {:ok, intent}
270
      %PaymentIntent{} -> {:error, :idempotency_key_conflict}
271
    end
272
  end
273
274
  defp settled_intent(%PaymentIntent{} = intent) do
275
    case receipt_for(intent) do
276
      %PaymentReceipt{} = receipt -> settled_result(intent, receipt)
277
      nil -> reconcile_open(intent)
278
    end
279
  end
280
281
  defp authorize(%Claim{} = claim, operator) do
282
    with {:ok, spec} <- claim_spec(claim),
283
         {:ok, policy} <- treasury_policy(),
284
         :ok <- require_settleable_claim(claim),
285
         :ok <- require_pinned_fingerprint(claim, spec),
286
         :ok <- require_current_spec(spec),
287
         :ok <- require_unexpired_claim(claim),
288
         {:ok, verification} <- accepted_verification(claim, operator.commit_sha, spec),
289
         :ok <- require_payment_authority(policy, spec),
290
         {:ok, intent} <- payment_intent(claim, spec, verification, operator) do
291
      dispatch(claim, spec, policy, intent)
292
    end
293
  end
294
295
  @doc """
296
  Reconciles one dispatched idempotency key against the gateway.
297
298
  A settled key that never acknowledged becomes its receipt here. A key the
299
  gateway does not know stays unpaid.
300
  """
301
  @spec reconcile(String.t()) ::
302
          {:ok, %{claim: Claim.t(), intent: PaymentIntent.t(), receipt: PaymentReceipt.t()}}
303
          | {:pending, PaymentIntent.t()}
304
          | {:error, term()}
305
  def reconcile(idempotency_key) when is_binary(idempotency_key) do
306
    case Repo.get_by(PaymentIntent, idempotency_key: idempotency_key) do
307
      nil -> {:error, :payment_intent_missing}
308
      %PaymentIntent{} = intent -> settled_intent(intent)
309
    end
310
  end
311
312
  @doc "Expires a live claim and releases the specification."
313
  @spec expire_claim(Claim.t(), map(), String.t()) :: {:ok, Adjustment.t()} | {:error, term()}
314
  def expire_claim(%Claim{} = claim, operator, reason_code),
315
    do: adjust(claim, operator, "expiry", reason_code, "expired")
316
317
  @doc "Freezes settlement for a claim under dispute."
318
  @spec open_dispute(Claim.t(), map(), String.t()) :: {:ok, Adjustment.t()} | {:error, term()}
319
  def open_dispute(%Claim{} = claim, operator, reason_code),
320
    do: adjust(claim, operator, "dispute", reason_code, "disputed")
321
322
  @doc "Records a refund for a paid claim without rewriting its receipt."
323
  @spec refund(Claim.t(), map(), String.t()) :: {:ok, Adjustment.t()} | {:error, term()}
324
  def refund(%Claim{} = claim, operator, reason_code) do
325
    with {:ok, claim} <- reload_claim(claim),
326
         :ok <- require_paid_claim(claim) do
327
      adjust(claim, operator, "refund", reason_code, "refunded")
328
    end
329
  end
330
331
  @doc """
332
  The public projection for an issue's settlement, bounded by the repository's
333
  disclosure level (TRANSPARENCY-001).
334
335
  Returns nil when the repository publishes nothing, when the level is below
336
  `:l1`, or when the issue has no priced specification.
337
  """
338
  @spec public_projection(Issue.t()) :: map() | nil
339
  def public_projection(%Issue{} = issue) do
340
    issue = Repo.preload(issue, :repository)
341
    level = Visibility.level(issue.repository && issue.repository.name)
342
343
    case {level, current_spec(issue)} do
344
      {:l0, _spec} -> nil
345
      {_level, nil} -> nil
346
      {level, spec} -> projection(issue, spec, level)
347
    end
348
  end
349
350
  @doc """
351
  The claimant's exportable payment receipt.
352
353
  The export carries the claimant's own destination and the evidence chain that
354
  proves the payment. It never carries operator identity, approval references,
355
  or gateway credentials, and it does not depend on a hosted wallet.
356
  """
357
  @spec export_payment_receipt(Claim.t(), String.t()) :: {:ok, map()} | {:error, term()}
358
  def export_payment_receipt(%Claim{} = claim, claimant_ref) when is_binary(claimant_ref) do
359
    with {:ok, claim} <- reload_claim(claim),
360
         :ok <- require_claimant(claim, claimant_ref),
361
         {:ok, spec} <- claim_spec(claim),
362
         {:ok, receipt} <- paid_receipt(claim),
363
         {:ok, verification} <- receipt_verification(receipt) do
364
      issue = Repo.preload(Repo.get!(Issue, spec.issue_id), :repository)
365
366
      {:ok,
367
       %{
368
         "contract" => "openagents.settlement.payment-receipt.v1",
369
         "issue" => issue_reference(issue),
370
         "buyer_kind" => reference_kind(spec.buyer_ref),
371
         "amount_sats" => receipt.amount_sats,
372
         "fee_sats" => receipt.fee_sats,
373
         "unit" => "sat",
374
         "state" => claim.state,
375
         "spec_fingerprint" => spec.spec_fingerprint,
376
         "acceptance_criteria" => spec.acceptance_criteria,
377
         "commit_sha" => verification.commit_sha,
378
         "work_job_ref" => claim.work_job_ref,
379
         "verifier_kind" => reference_kind(verification.verifier_ref),
380
         "verification_evidence_digest" => verification.evidence_digest,
381
         "destination_kind" => claim.destination_kind,
382
         "destination" => claim.destination,
383
         "payment_hash" => receipt.payment_hash,
384
         "preimage_digest" => receipt.preimage_digest,
385
         "paid_at" => DateTime.to_iso8601(receipt.paid_at),
386
         "receipt_digest" => receipt.receipt_digest
387
       }}
388
    end
389
  end
390
391
  @doc "The claim of a specification, live or terminal, or nil."
392
  @spec claim_for(BountySpec.t()) :: Claim.t() | nil
393
  def claim_for(%BountySpec{} = spec) do
394
    Claim
395
    |> where(bounty_spec_id: ^spec.id)
396
    |> order_by(desc: :inserted_at)
397
    |> limit(1)
398
    |> Repo.one()
399
  end
400
401
  defp dispatch(_claim, _spec, _policy, %PaymentIntent{state: "paid"} = intent),
402
    do: settled_intent(intent)
403
404
  defp dispatch(_claim, _spec, _policy, %PaymentIntent{state: "refunded"}),
405
    do: {:error, :payment_refunded}
406
407
  defp dispatch(claim, spec, policy, %PaymentIntent{attempts: 0} = intent) do
408
    with :ok <- require_attempts_remaining(policy, intent),
409
         :ok <- require_daily_budget(policy, intent) do
410
      pay(claim, spec, intent)
411
    end
412
  end
413
414
  defp dispatch(claim, spec, policy, %PaymentIntent{} = intent) do
415
    with :ok <- require_attempts_remaining(policy, intent),
416
         :ok <- require_daily_budget(policy, intent) do
417
      case PaymentGateway.lookup(intent.idempotency_key) do
418
        {:ok, settled} -> record_settlement(intent, settled)
419
        {:pending, reason_code} -> hold(intent, reason_code)
420
        _unsettled -> pay(claim, spec, intent)
421
      end
422
    end
423
  end
424
425
  defp pay(claim, spec, intent) do
426
    request = %{
427
      idempotency_key: intent.idempotency_key,
428
      amount_sats: intent.amount_sats,
429
      destination_kind: claim.destination_kind,
430
      destination: claim.destination,
431
      memo: "bounty #{spec.spec_fingerprint} commit #{intent.commit_sha}"
432
    }
433
434
    case PaymentGateway.pay(request) do
435
      {:ok, settled} -> record_settlement(intent, settled)
436
      {:pending, reason_code} -> hold(intent, reason_code)
437
      {:error, reason_code} -> record_failure(intent, reason_code)
438
      {:unknown, _nothing} -> record_failure(intent, "payment_gateway_unknown")
439
    end
440
  end
441
442
  defp reconcile_open(%PaymentIntent{} = intent) do
443
    case PaymentGateway.lookup(intent.idempotency_key) do
444
      {:ok, settled} -> record_settlement(intent, settled)
445
      {:pending, reason_code} -> hold(intent, reason_code)
446
      _unsettled -> {:error, :payment_unsettled}
447
    end
448
  end
449
450
  defp record_settlement(%PaymentIntent{} = intent, settled) do
451
    with {:ok, evidence} <- validate_settled(intent, settled) do
452
      Repo.transaction(fn ->
453
        digest =
454
          Canonical.digest!(%{
455
            "intent_digest" => intent.intent_digest,
456
            "amount_sats" => intent.amount_sats,
457
            "fee_sats" => evidence.fee_sats,
458
            "payment_hash" => evidence.payment_hash,
459
            "preimage_digest" => evidence.preimage_digest,
460
            "paid_at" => DateTime.to_iso8601(evidence.paid_at)
461
          })
462
463
        receipt =
464
          %PaymentReceipt{}
465
          |> PaymentReceipt.changeset(%{
466
            payment_intent_id: intent.id,
467
            claim_id: intent.claim_id,
468
            amount_sats: intent.amount_sats,
469
            fee_sats: evidence.fee_sats,
470
            payment_hash: evidence.payment_hash,
471
            preimage_digest: evidence.preimage_digest,
472
            gateway_ref: evidence.gateway_ref,
473
            paid_at: evidence.paid_at,
474
            receipt_digest: digest
475
          })
476
          |> Repo.insert()
477
          |> or_rollback()
478
479
        paid_intent =
480
          intent
481
          |> PaymentIntent.result_changeset(%{
482
            state: "paid",
483
            attempts: intent.attempts + 1,
484
            failure_reason_code: nil
485
          })
486
          |> Repo.update()
487
          |> or_rollback()
488
489
        claim =
490
          Claim
491
          |> Repo.get!(intent.claim_id)
492
          |> Claim.state_changeset("paid")
493
          |> Repo.update()
494
          |> or_rollback()
495
496
        %{claim: claim, intent: paid_intent, receipt: receipt}
497
      end)
498
      |> transaction_result()
499
    end
500
  end
501
502
  defp record_failure(%PaymentIntent{} = intent, reason_code) do
503
    result =
504
      intent
505
      |> PaymentIntent.result_changeset(%{
506
        state: "failed",
507
        attempts: intent.attempts + 1,
508
        failure_reason_code: reason_code
509
      })
510
      |> Repo.update()
511
512
    case result do
513
      {:ok, _failed} -> {:error, {:payment_failed, reason_code}}
514
      {:error, changeset} -> {:error, changeset}
515
    end
516
  end
517
518
  defp hold(%PaymentIntent{} = intent, reason_code) do
519
    result =
520
      intent
521
      |> PaymentIntent.result_changeset(%{
522
        state: "pending",
523
        attempts: intent.attempts + 1,
524
        failure_reason_code: reason_code
525
      })
526
      |> Repo.update()
527
528
    case result do
529
      {:ok, pending} -> {:pending, pending}
530
      {:error, changeset} -> {:error, changeset}
531
    end
532
  end
533
534
  defp settled_result(%PaymentIntent{} = intent, %PaymentReceipt{} = receipt) do
535
    {:ok, %{claim: Repo.get!(Claim, intent.claim_id), intent: intent, receipt: receipt}}
536
  end
537
538
  defp payment_intent(claim, spec, verification, operator) do
539
    case Repo.get_by(PaymentIntent, idempotency_key: operator.idempotency_key) do
540
      %PaymentIntent{} = intent ->
541
        require_matching_intent(intent, claim, spec, verification, operator)
542
543
      nil ->
544
        insert_intent(claim, spec, verification, operator)
545
    end
546
  end
547
548
  defp insert_intent(claim, spec, verification, operator) do
549
    digest =
550
      Canonical.digest!(%{
551
        "claim_digest" => claim.claim_digest,
552
        "spec_fingerprint" => spec.spec_fingerprint,
553
        "commit_sha" => verification.commit_sha,
554
        "amount_sats" => spec.amount_sats,
555
        "idempotency_key" => operator.idempotency_key
556
      })
557
558
    result =
559
      %PaymentIntent{}
560
      |> PaymentIntent.changeset(%{
561
        claim_id: claim.id,
562
        verification_id: verification.id,
563
        idempotency_key: operator.idempotency_key,
564
        amount_sats: spec.amount_sats,
565
        commit_sha: verification.commit_sha,
566
        destination_digest: claim.destination_digest,
567
        spec_fingerprint: spec.spec_fingerprint,
568
        state: "pending",
569
        attempts: 0,
570
        intent_digest: digest,
571
        actor_id: operator.actor_id,
572
        auth_method: operator.auth_method,
573
        approval_receipt_ref: operator.approval_receipt_ref
574
      })
575
      |> Repo.insert()
576
577
    case result do
578
      {:ok, intent} ->
579
        {:ok, intent}
580
581
      {:error, changeset} ->
582
        case Repo.get_by(PaymentIntent, idempotency_key: operator.idempotency_key) do
583
          %PaymentIntent{} = intent ->
584
            require_matching_intent(intent, claim, spec, verification, operator)
585
586
          nil ->
587
            {:error, changeset}
588
        end
589
    end
590
  end
591
592
  defp require_matching_intent(intent, claim, spec, verification, operator) do
593
    matches? =
594
      intent.claim_id == claim.id and intent.spec_fingerprint == spec.spec_fingerprint and
595
        intent.commit_sha == verification.commit_sha and intent.amount_sats == spec.amount_sats and
596
        intent.approval_receipt_ref == operator.approval_receipt_ref
597
598
    if matches?, do: {:ok, intent}, else: {:error, :idempotency_key_conflict}
599
  end
600
601
  defp require_attempts_remaining(policy, intent) do
602
    max_attempts = Map.get(policy.rules, "max_attempts", 1)
603
604
    if intent.attempts < max_attempts,
605
      do: :ok,
606
      else: {:error, :payment_attempts_exhausted}
607
  end
608
609
  defp require_daily_budget(policy, intent) do
610
    budget = Map.get(policy.rules, "daily_budget_sats", 0)
611
    window = DateTime.add(DateTime.utc_now(), -86_400, :second)
612
613
    paid =
614
      PaymentIntent
615
      |> where([intent], intent.state == "paid" and intent.updated_at >= ^window)
616
      |> select([intent], sum(intent.amount_sats))
617
      |> Repo.one()
618
      |> Kernel.||(0)
619
620
    if paid + intent.amount_sats <= budget,
621
      do: :ok,
622
      else: {:error, :daily_budget_exhausted}
623
  end
624
625
  defp require_payment_authority(policy, spec) do
626
    max_payment = Map.get(policy.rules, "max_payment_sats", 0)
627
628
    if spec.amount_sats <= max_payment,
629
      do: :ok,
630
      else: {:error, :amount_exceeds_treasury_authority}
631
  end
632
633
  defp accepted_verification(claim, commit_sha, spec) do
634
    verification = Repo.get_by(Verification, claim_id: claim.id, commit_sha: commit_sha)
635
636
    cond do
637
      is_nil(verification) and Repo.exists?(where(Verification, claim_id: ^claim.id)) ->
638
        {:error, :stale_commit}
639
640
      is_nil(verification) ->
641
        {:error, :verification_missing}
642
643
      verification.outcome != "accepted" ->
644
        {:error, :verification_rejected}
645
646
      verification.spec_fingerprint != spec.spec_fingerprint ->
647
        {:error, :spec_fingerprint_mismatch}
648
649
      true ->
650
        {:ok, verification}
651
    end
652
  end
653
654
  defp adjust(claim, operator, kind, reason_code, claim_state) do
655
    with {:ok, claim} <- reload_claim(claim),
656
         :ok <- validate_operator(operator),
657
         :ok <- validate_reason_code(reason_code) do
658
      digest =
659
        Canonical.digest!(%{
660
          "claim_digest" => claim.claim_digest,
661
          "kind" => kind,
662
          "reason_code" => reason_code,
663
          "approval_receipt_ref" => operator.approval_receipt_ref
664
        })
665
666
      Repo.transaction(fn ->
667
        adjustment =
668
          %Adjustment{}
669
          |> Adjustment.changeset(%{
670
            claim_id: claim.id,
671
            kind: kind,
672
            reason_code: reason_code,
673
            actor_id: operator.actor_id,
674
            auth_method: operator.auth_method,
675
            approval_receipt_ref: operator.approval_receipt_ref,
676
            adjustment_digest: digest
677
          })
678
          |> Repo.insert()
679
          |> or_rollback()
680
681
        claim
682
        |> Claim.state_changeset(claim_state)
683
        |> Repo.update()
684
        |> or_rollback()
685
686
        adjustment
687
      end)
688
      |> transaction_result()
689
    end
690
  end
691
692
  defp projection(issue, spec, level) do
693
    claim = claim_for(spec)
694
    receipt = claim && paid_receipt_or_nil(claim)
695
696
    pulse = %{
697
      "contract" => "openagents.settlement.public.v1",
698
      "issue_number" => issue.number,
699
      "unit" => "sat",
700
      "amount_sats" => spec.amount_sats,
701
      "state" => public_state(claim),
702
      "buyer_kind" => reference_kind(spec.buyer_ref),
703
      "claimant_kind" => claim && reference_kind(claim.claimant_ref),
704
      "acceptance_criteria_count" => length(spec.acceptance_criteria),
705
      "paid" => not is_nil(receipt)
706
    }
707
708
    if level == :l1, do: pulse, else: Map.merge(pulse, ledger(spec, claim, receipt))
709
  end
710
711
  defp ledger(spec, claim, receipt) do
712
    verification = claim && accepted_verification_or_nil(claim)
713
714
    %{
715
      "spec_fingerprint" => spec.spec_fingerprint,
716
      "acceptance_criteria" => spec.acceptance_criteria,
717
      "expires_at" => DateTime.to_iso8601(spec.expires_at),
718
      "commit_sha" => verification && verification.commit_sha,
719
      "verifier_kind" => verification && reference_kind(verification.verifier_ref),
720
      "verification_evidence_digest" => verification && verification.evidence_digest,
721
      "payment_hash" => receipt && receipt.payment_hash,
722
      "fee_sats" => receipt && receipt.fee_sats,
723
      "paid_at" => receipt && DateTime.to_iso8601(receipt.paid_at),
724
      "receipt_digest" => receipt && receipt.receipt_digest
725
    }
726
  end
727
728
  defp public_state(nil), do: "priced"
729
  defp public_state(%Claim{state: state}), do: state
730
731
  defp accepted_verification_or_nil(claim) do
732
    Verification
733
    |> where([verification], verification.claim_id == ^claim.id)
734
    |> where([verification], verification.outcome == "accepted")
735
    |> order_by(desc: :inserted_at)
736
    |> limit(1)
737
    |> Repo.one()
738
  end
739
740
  defp paid_receipt_or_nil(claim) do
741
    PaymentReceipt
742
    |> where(claim_id: ^claim.id)
743
    |> order_by(desc: :inserted_at)
744
    |> limit(1)
745
    |> Repo.one()
746
  end
747
748
  defp paid_receipt(claim) do
749
    case paid_receipt_or_nil(claim) do
750
      %PaymentReceipt{} = receipt -> {:ok, receipt}
751
      nil -> {:error, :payment_receipt_missing}
752
    end
753
  end
754
755
  defp receipt_verification(%PaymentReceipt{} = receipt) do
756
    intent = Repo.get!(PaymentIntent, receipt.payment_intent_id)
757
758
    case Repo.get(Verification, intent.verification_id) do
759
      %Verification{} = verification -> {:ok, verification}
760
      nil -> {:error, :verification_missing}
761
    end
762
  end
763
764
  defp receipt_for(%PaymentIntent{} = intent),
765
    do: Repo.get_by(PaymentReceipt, payment_intent_id: intent.id)
766
767
  defp issue_reference(%Issue{} = issue) do
768
    %{
769
      "owner" => issue.repository && issue.repository.owner,
770
      "repository" => issue.repository && issue.repository.name,
771
      "number" => issue.number
772
    }
773
  end
774
775
  defp reference_kind(reference) when is_binary(reference) do
776
    case String.split(reference, ":", parts: 2) do
777
      [kind, _identifier] -> kind
778
      [_identifier] -> "unattributed"
779
    end
780
  end
781
782
  defp reference_kind(_reference), do: "unattributed"
783
784
  defp next_revision(issue) do
785
    revision =
786
      BountySpec
787
      |> where(issue_id: ^issue.id)
788
      |> select([spec], max(spec.revision))
789
      |> Repo.one()
790
791
    (revision || 0) + 1
792
  end
793
794
  defp claim_spec(%Claim{} = claim) do
795
    case Repo.get(BountySpec, claim.bounty_spec_id) do
796
      %BountySpec{} = spec -> {:ok, spec}
797
      nil -> {:error, :bounty_spec_missing}
798
    end
799
  end
800
801
  defp reload_claim(%Claim{id: id}) do
802
    case Repo.get(Claim, id) do
803
      %Claim{} = claim -> {:ok, claim}
804
      nil -> {:error, :claim_missing}
805
    end
806
  end
807
808
  defp require_current_spec(%BountySpec{} = spec) do
809
    latest =
810
      BountySpec
811
      |> where(issue_id: ^spec.issue_id)
812
      |> select([candidate], max(candidate.revision))
813
      |> Repo.one()
814
815
    if latest == spec.revision, do: :ok, else: {:error, :spec_superseded}
816
  end
817
818
  defp require_pinned_fingerprint(%Claim{} = claim, %BountySpec{} = spec) do
819
    if claim.spec_fingerprint == spec.spec_fingerprint,
820
      do: :ok,
821
      else: {:error, :spec_fingerprint_mismatch}
822
  end
823
824
  defp require_unexpired_spec(%BountySpec{} = spec) do
825
    if DateTime.compare(spec.expires_at, DateTime.utc_now()) == :gt,
826
      do: :ok,
827
      else: {:error, :bounty_expired}
828
  end
829
830
  defp require_unexpired_claim(%Claim{} = claim) do
831
    if DateTime.compare(claim.expires_at, DateTime.utc_now()) == :gt,
832
      do: :ok,
833
      else: {:error, :claim_expired}
834
  end
835
836
  defp require_settleable_claim(%Claim{state: state}) when state in ~w(claimed verified),
837
    do: :ok
838
839
  defp require_settleable_claim(%Claim{state: state}),
840
    do: {:error, {:claim_not_settleable, state}}
841
842
  defp require_paid_claim(%Claim{state: "paid"}), do: :ok
843
  defp require_paid_claim(%Claim{state: state}), do: {:error, {:claim_not_paid, state}}
844
845
  defp require_claimant(%Claim{claimant_ref: claimant_ref}, claimant_ref), do: :ok
846
  defp require_claimant(_claim, _claimant_ref), do: {:error, :not_the_claimant}
847
848
  defp validate_operator(operator) when is_map(operator) do
849
    with :ok <- require_text(operator, :actor_id, 256),
850
         :ok <- require_text(operator, :auth_method, 128),
851
         :ok <- require_text(operator, :approval_receipt_ref, 256) do
852
      :ok
853
    end
854
  end
855
856
  defp validate_operator(_operator), do: {:error, :operator_invalid}
857
858
  defp validate_settlement_request(request) when is_map(request) do
859
    with :ok <- validate_approval(request),
860
         :ok <- validate_operator(request),
861
         :ok <- require_text(request, :idempotency_key, 256),
862
         {:ok, commit_sha} <- validate_commit_sha(request) do
863
      {:ok,
864
       %{
865
         actor_id: request.actor_id,
866
         auth_method: request.auth_method,
867
         approval_receipt_ref: request.approval_receipt_ref,
868
         idempotency_key: request.idempotency_key,
869
         commit_sha: commit_sha
870
       }}
871
    end
872
  end
873
874
  defp validate_settlement_request(_request), do: {:error, :settlement_request_invalid}
875
876
  defp validate_approval(request) do
877
    case Map.get(request, :approval_receipt_ref) do
878
      value when is_binary(value) and value != "" -> :ok
879
      _missing -> {:error, :approval_missing}
880
    end
881
  end
882
883
  defp validate_commit_sha(request) do
884
    case Map.get(request, :commit_sha) do
885
      value when is_binary(value) ->
886
        if Regex.match?(~r/\A[0-9a-f]{40}\z/, value),
887
          do: {:ok, value},
888
          else: {:error, :commit_sha_invalid}
889
890
      _missing ->
891
        {:error, :commit_sha_invalid}
892
    end
893
  end
894
895
  defp validate_rules(rules) do
896
    max_payment = Map.get(rules, "max_payment_sats")
897
    daily_budget = Map.get(rules, "daily_budget_sats")
898
    max_attempts = Map.get(rules, "max_attempts")
899
    destinations = Map.get(rules, "destination_kinds")
900
901
    cond do
902
      not (is_integer(max_payment) and max_payment > 0) -> {:error, :max_payment_invalid}
903
      not (is_integer(daily_budget) and daily_budget >= max_payment) -> {:error, :budget_invalid}
904
      not (is_integer(max_attempts) and max_attempts > 0) -> {:error, :max_attempts_invalid}
905
      not (is_list(destinations) and destinations != []) -> {:error, :destination_kinds_invalid}
906
      Map.get(rules, "requires_accepted_verification") != true -> {:error, :verification_optional}
907
      Map.get(rules, "requires_approval_receipt") != true -> {:error, :approval_optional}
908
      true -> :ok
909
    end
910
  end
911
912
  defp validate_price(policy, attributes) when is_map(attributes) do
913
    with {:ok, amount_sats} <- validate_amount(policy, attributes),
914
         {:ok, criteria} <- validate_criteria(attributes),
915
         {:ok, verification_policy} <- validate_verification_policy(attributes),
916
         {:ok, destination_kind} <- validate_destination_kind(policy, attributes),
917
         {:ok, expires_at} <- validate_expiry(attributes),
918
         :ok <- require_text(attributes, :buyer_ref, 256) do
919
      {:ok,
920
       %{
921
         buyer_ref: attributes.buyer_ref,
922
         amount_sats: amount_sats,
923
         acceptance_criteria: criteria,
924
         verification_policy: verification_policy,
925
         destination_kind: destination_kind,
926
         expires_at: expires_at
927
       }}
928
    end
929
  end
930
931
  defp validate_price(_policy, _attributes), do: {:error, :bounty_price_invalid}
932
933
  defp validate_amount(policy, attributes) do
934
    amount = Map.get(attributes, :amount_sats)
935
    max_payment = Map.get(policy.rules, "max_payment_sats", 0)
936
937
    cond do
938
      not is_integer(amount) or amount <= 0 -> {:error, :amount_invalid}
939
      amount > max_payment -> {:error, :amount_exceeds_treasury_authority}
940
      true -> {:ok, amount}
941
    end
942
  end
943
944
  defp validate_criteria(attributes) do
945
    case Map.get(attributes, :acceptance_criteria) do
946
      [_first | _rest] = criteria ->
947
        if Enum.all?(criteria, &(is_binary(&1) and &1 != "")),
948
          do: {:ok, criteria},
949
          else: {:error, :acceptance_criteria_invalid}
950
951
      _missing ->
952
        {:error, :acceptance_criteria_invalid}
953
    end
954
  end
955
956
  defp validate_verification_policy(attributes) do
957
    case Map.get(attributes, :verification_policy) do
958
      policy when is_map(policy) and map_size(policy) > 0 -> {:ok, policy}
959
      _missing -> {:error, :verification_policy_invalid}
960
    end
961
  end
962
963
  defp validate_destination_kind(policy, attributes) do
964
    kinds = Map.get(policy.rules, "destination_kinds", [])
965
    kind = Map.get(attributes, :destination_kind)
966
967
    if is_binary(kind) and kind in kinds,
968
      do: {:ok, kind},
969
      else: {:error, :destination_kind_not_admitted}
970
  end
971
972
  defp validate_expiry(attributes) do
973
    case Map.get(attributes, :expires_at) do
974
      %DateTime{} = expires_at ->
975
        if DateTime.compare(expires_at, DateTime.utc_now()) == :gt,
976
          do: {:ok, expires_at},
977
          else: {:error, :expiry_in_the_past}
978
979
      _missing ->
980
        {:error, :expiry_invalid}
981
    end
982
  end
983
984
  defp validate_destination(policy, spec, claimant) when is_map(claimant) do
985
    kinds = Map.get(policy.rules, "destination_kinds", [])
986
    kind = Map.get(claimant, :destination_kind)
987
    value = Map.get(claimant, :destination)
988
989
    cond do
990
      not (is_binary(kind) and kind in kinds) ->
991
        {:error, :destination_kind_not_admitted}
992
993
      kind != spec.destination_kind ->
994
        {:error, :destination_kind_not_admitted}
995
996
      not (is_binary(value) and byte_size(value) >= 16) ->
997
        {:error, :destination_invalid}
998
999
      true ->
1000
        {:ok, %{kind: kind, value: value, digest: Canonical.sha256(value)}}
1001
    end
1002
  end
1003
1004
  defp validate_destination(_policy, _spec, _claimant), do: {:error, :destination_invalid}
1005
1006
  defp validate_claimant(claimant) do
1007
    with :ok <- require_text(claimant, :claimant_ref, 256),
1008
         :ok <- require_text(claimant, :work_job_ref, 256) do
1009
      {:ok, %{claimant_ref: claimant.claimant_ref, work_job_ref: claimant.work_job_ref}}
1010
    end
1011
  end
1012
1013
  defp validate_verification(claim, spec, attributes) when is_map(attributes) do
1014
    expected_policy_digest = Canonical.digest!(spec.verification_policy)
1015
1016
    with {:ok, commit_sha} <- validate_commit_sha(attributes),
1017
         :ok <- require_text(attributes, :verifier_ref, 256),
1018
         :ok <- require_text(attributes, :auth_method, 128),
1019
         :ok <- require_text(attributes, :decision_receipt_ref, 256),
1020
         :ok <- validate_reason_code(Map.get(attributes, :reason_code)),
1021
         {:ok, outcome} <- validate_outcome(attributes),
1022
         {:ok, evidence_digest} <- validate_evidence_digest(attributes),
1023
         :ok <- require_verifier_policy(attributes, expected_policy_digest),
1024
         :ok <- require_claim_work_job(claim, attributes) do
1025
      {:ok,
1026
       %{
1027
         commit_sha: commit_sha,
1028
         verifier_ref: attributes.verifier_ref,
1029
         verifier_policy_digest: expected_policy_digest,
1030
         evidence_digest: evidence_digest,
1031
         outcome: outcome,
1032
         reason_code: attributes.reason_code,
1033
         auth_method: attributes.auth_method,
1034
         decision_receipt_ref: attributes.decision_receipt_ref
1035
       }}
1036
    end
1037
  end
1038
1039
  defp validate_verification(_claim, _spec, _attributes), do: {:error, :verification_invalid}
1040
1041
  defp validate_outcome(attributes) do
1042
    case Map.get(attributes, :outcome) do
1043
      outcome when outcome in ~w(accepted rejected) -> {:ok, outcome}
1044
      _invalid -> {:error, :verification_outcome_invalid}
1045
    end
1046
  end
1047
1048
  defp validate_evidence_digest(attributes) do
1049
    case Map.get(attributes, :evidence_digest) do
1050
      digest when is_binary(digest) ->
1051
        if Regex.match?(~r/\A[0-9a-f]{64}\z/, digest),
1052
          do: {:ok, digest},
1053
          else: {:error, :evidence_digest_invalid}
1054
1055
      _missing ->
1056
        {:error, :evidence_digest_invalid}
1057
    end
1058
  end
1059
1060
  defp require_verifier_policy(attributes, expected) do
1061
    case Map.get(attributes, :verifier_policy_digest) do
1062
      nil -> :ok
1063
      ^expected -> :ok
1064
      _other -> {:error, :verifier_policy_mismatch}
1065
    end
1066
  end
1067
1068
  defp require_claim_work_job(claim, attributes) do
1069
    case Map.get(attributes, :work_job_ref) do
1070
      nil -> :ok
1071
      work_job_ref when work_job_ref == claim.work_job_ref -> :ok
1072
      _other -> {:error, :work_job_mismatch}
1073
    end
1074
  end
1075
1076
  defp validate_settled(_intent, settled) when is_map(settled) do
1077
    with {:ok, payment_hash} <- digest_field(settled, :payment_hash, :payment_hash_invalid),
1078
         {:ok, preimage_digest} <- digest_field(settled, :preimage_digest, :preimage_invalid),
1079
         {:ok, paid_at} <- paid_at(settled),
1080
         {:ok, fee_sats} <- fee_sats(settled),
1081
         :ok <- require_text(settled, :gateway_ref, 256) do
1082
      {:ok,
1083
       %{
1084
         payment_hash: payment_hash,
1085
         preimage_digest: preimage_digest,
1086
         paid_at: paid_at,
1087
         fee_sats: fee_sats,
1088
         gateway_ref: settled.gateway_ref
1089
       }}
1090
    end
1091
  end
1092
1093
  defp validate_settled(_intent, _settled), do: {:error, :payment_evidence_invalid}
1094
1095
  defp digest_field(settled, key, error) do
1096
    case Map.get(settled, key) do
1097
      value when is_binary(value) ->
1098
        if Regex.match?(~r/\A[0-9a-f]{64}\z/, value), do: {:ok, value}, else: {:error, error}
1099
1100
      _missing ->
1101
        {:error, error}
1102
    end
1103
  end
1104
1105
  defp paid_at(settled) do
1106
    case Map.get(settled, :paid_at) do
1107
      %DateTime{} = paid_at -> {:ok, paid_at}
1108
      _missing -> {:error, :paid_at_invalid}
1109
    end
1110
  end
1111
1112
  defp fee_sats(settled) do
1113
    case Map.get(settled, :fee_sats) do
1114
      fee when is_integer(fee) and fee >= 0 -> {:ok, fee}
1115
      _invalid -> {:error, :fee_sats_invalid}
1116
    end
1117
  end
1118
1119
  defp validate_reason_code(reason_code) do
1120
    if is_binary(reason_code) and reason_code != "" and byte_size(reason_code) <= 128,
1121
      do: :ok,
1122
      else: {:error, :reason_code_invalid}
1123
  end
1124
1125
  defp require_text(source, key, max_bytes) do
1126
    case Map.get(source, key) do
1127
      value when is_binary(value) ->
1128
        if value != "" and byte_size(value) <= max_bytes,
1129
          do: :ok,
1130
          else: {:error, {:invalid_field, key}}
1131
1132
      _missing ->
1133
        {:error, {:invalid_field, key}}
1134
    end
1135
  end
1136
1137
  defp or_rollback({:ok, record}), do: record
1138
  defp or_rollback({:error, reason}), do: Repo.rollback(reason)
1139
1140
  defp transaction_result({:ok, value}), do: {:ok, value}
1141
  defp transaction_result({:error, reason}), do: {:error, reason}
1142
end
lib/openagents/settlement/adjustment.ex added +49

@@ -0,0 +1,49 @@

1
defmodule OpenAgents.Settlement.Adjustment do
2
  @moduledoc """
3
  An append-only expiry, dispute, or refund record for one claim.
4
5
  An adjustment never rewrites a payment receipt. It records the decision that
6
  changed what the claim may still do, with the operator authority behind it.
7
  """
8
9
  use Ecto.Schema
10
  import Ecto.Changeset
11
12
  alias OpenAgents.Settlement.Claim
13
14
  @primary_key {:id, :binary_id, autogenerate: true}
15
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
16
17
  @kinds ~w(expiry dispute refund)
18
19
  @fields ~w(claim_id kind reason_code actor_id auth_method approval_receipt_ref
20
             adjustment_digest)a
21
22
  schema "settlement_adjustments" do
23
    field :kind, :string
24
    field :reason_code, :string
25
    field :actor_id, :string
26
    field :auth_method, :string
27
    field :approval_receipt_ref, :string
28
    field :adjustment_digest, :string
29
    belongs_to :claim, Claim, type: :binary_id
30
    timestamps()
31
  end
32
33
  @type t :: %__MODULE__{}
34
35
  @doc "Every adjustment kind."
36
  def kinds, do: @kinds
37
38
  def changeset(record, attributes) do
39
    record
40
    |> cast(attributes, @fields)
41
    |> validate_required(@fields)
42
    |> validate_inclusion(:kind, @kinds)
43
    |> validate_format(:adjustment_digest, ~r/\A[0-9a-f]{64}\z/)
44
    |> validate_length(:reason_code, min: 1, max: 128)
45
    |> validate_length(:approval_receipt_ref, min: 1, max: 256)
46
    |> unique_constraint([:claim_id, :kind, :approval_receipt_ref])
47
    |> foreign_key_constraint(:claim_id)
48
  end
49
end
lib/openagents/settlement/bounty_spec.ex added +59

@@ -0,0 +1,59 @@

1
defmodule OpenAgents.Settlement.BountySpec do
2
  @moduledoc """
3
  One priced specification for one forge issue, fingerprinted before a claim.
4
5
  Revisions are append-only. Repricing an issue writes a new revision with a
6
  new fingerprint, which strands every claim pinned to the previous
7
  fingerprint instead of paying against a specification nobody agreed to.
8
  """
9
10
  use Ecto.Schema
11
  import Ecto.Changeset
12
13
  alias OpenAgents.Issues.Issue
14
  alias OpenAgents.Settlement.TreasuryPolicy
15
16
  @primary_key {:id, :binary_id, autogenerate: true}
17
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
18
19
  @fields ~w(treasury_policy_id issue_id revision buyer_ref amount_sats acceptance_criteria
20
             verification_policy destination_kind expires_at spec_fingerprint actor_id
21
             auth_method approval_receipt_ref)a
22
23
  schema "settlement_bounty_specs" do
24
    field :revision, :integer
25
    field :buyer_ref, :string
26
    field :amount_sats, :integer
27
    field :acceptance_criteria, {:array, :string}
28
    field :verification_policy, :map
29
    field :destination_kind, :string
30
    field :expires_at, :utc_datetime_usec
31
    field :spec_fingerprint, :string
32
    field :actor_id, :string
33
    field :auth_method, :string
34
    field :approval_receipt_ref, :string
35
    belongs_to :treasury_policy, TreasuryPolicy, type: :binary_id
36
    belongs_to :issue, Issue
37
    timestamps()
38
  end
39
40
  @type t :: %__MODULE__{}
41
42
  def changeset(record, attributes) do
43
    record
44
    |> cast(attributes, @fields)
45
    |> validate_required(@fields)
46
    |> validate_number(:revision, greater_than: 0)
47
    |> validate_number(:amount_sats, greater_than: 0)
48
    |> validate_format(:spec_fingerprint, ~r/\A[0-9a-f]{64}\z/)
49
    |> validate_length(:buyer_ref, min: 1, max: 256)
50
    |> validate_length(:destination_kind, min: 1, max: 64)
51
    |> validate_length(:acceptance_criteria, min: 1)
52
    |> validate_length(:approval_receipt_ref, min: 1, max: 256)
53
    |> unique_constraint([:issue_id, :revision])
54
    |> unique_constraint(:spec_fingerprint)
55
    |> unique_constraint(:approval_receipt_ref)
56
    |> foreign_key_constraint(:issue_id)
57
    |> foreign_key_constraint(:treasury_policy_id)
58
  end
59
end
lib/openagents/settlement/claim.ex added +64

@@ -0,0 +1,64 @@

1
defmodule OpenAgents.Settlement.Claim do
2
  @moduledoc """
3
  One claimant's pinned commitment to a priced specification.
4
5
  The claim carries the fingerprint it agreed to and the self-custodial
6
  destination that a payment may reach. Only one claim per specification is
7
  live at a time; expired and rejected claims release the specification.
8
  """
9
10
  use Ecto.Schema
11
  import Ecto.Changeset
12
13
  alias OpenAgents.Settlement.BountySpec
14
15
  @primary_key {:id, :binary_id, autogenerate: true}
16
  @timestamps_opts [type: :utc_datetime_usec]
17
18
  @states ~w(claimed verified rejected paid expired disputed refunded)
19
20
  @fields ~w(bounty_spec_id spec_fingerprint claimant_ref work_job_ref destination_kind
21
             destination destination_digest state claim_digest expires_at)a
22
23
  schema "settlement_claims" do
24
    field :spec_fingerprint, :string
25
    field :claimant_ref, :string
26
    field :work_job_ref, :string
27
    field :destination_kind, :string
28
    field :destination, :string
29
    field :destination_digest, :string
30
    field :state, :string
31
    field :claim_digest, :string
32
    field :expires_at, :utc_datetime_usec
33
    belongs_to :bounty_spec, BountySpec, type: :binary_id
34
    timestamps()
35
  end
36
37
  @type t :: %__MODULE__{}
38
39
  @doc "Every claim state."
40
  def states, do: @states
41
42
  @doc "The states that hold a specification against a new claim."
43
  def live_states, do: @states -- ~w(expired rejected)
44
45
  def changeset(record, attributes) do
46
    record
47
    |> cast(attributes, @fields)
48
    |> validate_required(@fields)
49
    |> validate_inclusion(:state, @states)
50
    |> validate_format(:spec_fingerprint, ~r/\A[0-9a-f]{64}\z/)
51
    |> validate_format(:destination_digest, ~r/\A[0-9a-f]{64}\z/)
52
    |> validate_length(:claimant_ref, min: 1, max: 256)
53
    |> validate_length(:work_job_ref, min: 1, max: 256)
54
    |> validate_length(:destination, min: 1, max: 2048)
55
    |> unique_constraint(:bounty_spec_id, name: :settlement_claim_single_live_claim)
56
    |> foreign_key_constraint(:bounty_spec_id)
57
  end
58
59
  def state_changeset(record, state) when state in @states do
60
    record
61
    |> change(state: state)
62
    |> unique_constraint(:bounty_spec_id, name: :settlement_claim_single_live_claim)
63
  end
64
end
lib/openagents/settlement/payment_gateway.ex added +59

@@ -0,0 +1,59 @@

1
defmodule OpenAgents.Settlement.PaymentGateway do
2
  @moduledoc """
3
  The treasury payment boundary for bounty settlement.
4
5
  The settlement domain never holds custody, keys, or a node connection. It
6
  hands an authorized, idempotency-keyed request to the configured gateway —
7
  the self-custodial MoneyDevKit treasury path in production — and records
8
  whatever exact evidence comes back.
9
10
  A gateway reports one of three answers:
11
12
    * `{:ok, settled}` — the payment reached the destination, with
13
      `payment_hash`, `preimage_digest`, `fee_sats`, `paid_at`, and
14
      `gateway_ref`.
15
    * `{:pending, reason_code}` — the gateway accepted the request but has no
16
      terminal answer yet. The intent stays payable under the same key.
17
    * `{:error, reason_code}` — the attempt failed. A retry uses the same key.
18
19
  `lookup/1` answers the same shapes for a key that was already dispatched,
20
  which is how a lost acknowledgement reconciles without a second payment.
21
  """
22
23
  @type request :: %{
24
          idempotency_key: String.t(),
25
          amount_sats: pos_integer(),
26
          destination_kind: String.t(),
27
          destination: String.t(),
28
          memo: String.t()
29
        }
30
31
  @type settled :: %{
32
          payment_hash: String.t(),
33
          preimage_digest: String.t(),
34
          fee_sats: non_neg_integer(),
35
          paid_at: DateTime.t(),
36
          gateway_ref: String.t()
37
        }
38
39
  @type answer ::
40
          {:ok, settled()} | {:pending, String.t()} | {:error, String.t()} | {:unknown, nil}
41
42
  @callback pay(request()) :: answer()
43
  @callback lookup(String.t()) :: answer()
44
45
  @doc "The configured gateway module, fail-closed when unset."
46
  def gateway do
47
    Application.get_env(
48
      :openagents,
49
      :settlement_payment_gateway,
50
      OpenAgents.Settlement.PaymentGateway.Unconfigured
51
    )
52
  end
53
54
  @doc "Dispatches one authorized payment request."
55
  def pay(request), do: gateway().pay(request)
56
57
  @doc "Reads the terminal state of a previously dispatched key."
58
  def lookup(idempotency_key), do: gateway().lookup(idempotency_key)
59
end
lib/openagents/settlement/payment_gateway/unconfigured.ex added +18

@@ -0,0 +1,18 @@

1
defmodule OpenAgents.Settlement.PaymentGateway.Unconfigured do
2
  @moduledoc """
3
  The default gateway: it refuses every payment.
4
5
  An environment without an admitted treasury gateway must fail closed rather
6
  than appear to pay. The refusal is a failed attempt on an existing intent, so
7
  configuring the real gateway and retrying the same idempotency key settles
8
  the original authorization instead of creating a second one.
9
  """
10
11
  @behaviour OpenAgents.Settlement.PaymentGateway
12
13
  @impl true
14
  def pay(_request), do: {:error, "payment_gateway_unconfigured"}
15
16
  @impl true
17
  def lookup(_idempotency_key), do: {:unknown, nil}
18
end
lib/openagents/settlement/payment_intent.ex added +73

@@ -0,0 +1,73 @@

1
defmodule OpenAgents.Settlement.PaymentIntent do
2
  @moduledoc """
3
  The idempotent record of an authorized payment attempt.
4
5
  One idempotency key names one intent for the lifetime of the settlement. A
6
  retry, a duplicate request, and a lost acknowledgement all resolve to the
7
  same intent, and only one intent per claim can reach `paid`.
8
  """
9
10
  use Ecto.Schema
11
  import Ecto.Changeset
12
13
  alias OpenAgents.Settlement.{Claim, Verification}
14
15
  @primary_key {:id, :binary_id, autogenerate: true}
16
  @timestamps_opts [type: :utc_datetime_usec]
17
18
  @states ~w(pending paid failed refunded)
19
20
  @fields ~w(claim_id verification_id idempotency_key amount_sats commit_sha destination_digest
21
             spec_fingerprint state attempts failure_reason_code intent_digest actor_id
22
             auth_method approval_receipt_ref)a
23
24
  @required @fields -- ~w(failure_reason_code)a
25
26
  schema "settlement_payment_intents" do
27
    field :idempotency_key, :string
28
    field :amount_sats, :integer
29
    field :commit_sha, :string
30
    field :destination_digest, :string
31
    field :spec_fingerprint, :string
32
    field :state, :string
33
    field :attempts, :integer, default: 0
34
    field :failure_reason_code, :string
35
    field :intent_digest, :string
36
    field :actor_id, :string
37
    field :auth_method, :string
38
    field :approval_receipt_ref, :string
39
    belongs_to :claim, Claim, type: :binary_id
40
    belongs_to :verification, Verification, type: :binary_id
41
    timestamps()
42
  end
43
44
  @type t :: %__MODULE__{}
45
46
  @doc "Every payment intent state."
47
  def states, do: @states
48
49
  def changeset(record, attributes) do
50
    record
51
    |> cast(attributes, @fields)
52
    |> validate_required(@required)
53
    |> validate_inclusion(:state, @states)
54
    |> validate_number(:amount_sats, greater_than: 0)
55
    |> validate_number(:attempts, greater_than_or_equal_to: 0)
56
    |> validate_format(:commit_sha, ~r/\A[0-9a-f]{40}\z/)
57
    |> validate_format(:intent_digest, ~r/\A[0-9a-f]{64}\z/)
58
    |> validate_length(:idempotency_key, min: 8, max: 256)
59
    |> validate_length(:approval_receipt_ref, min: 1, max: 256)
60
    |> unique_constraint(:idempotency_key)
61
    |> unique_constraint(:claim_id, name: :settlement_payment_intent_single_paid)
62
    |> foreign_key_constraint(:claim_id)
63
    |> foreign_key_constraint(:verification_id)
64
  end
65
66
  def result_changeset(record, attributes) do
67
    record
68
    |> cast(attributes, ~w(state attempts failure_reason_code)a)
69
    |> validate_inclusion(:state, @states)
70
    |> validate_number(:attempts, greater_than_or_equal_to: 0)
71
    |> unique_constraint(:claim_id, name: :settlement_payment_intent_single_paid)
72
  end
73
end
lib/openagents/settlement/payment_receipt.ex added +50

@@ -0,0 +1,50 @@

1
defmodule OpenAgents.Settlement.PaymentReceipt do
2
  @moduledoc """
3
  The exact evidence of one settled payment.
4
5
  One receipt exists per payment intent, and the payment hash is unique across
6
  the ledger, so a replayed acknowledgement cannot become a second payment.
7
  """
8
9
  use Ecto.Schema
10
  import Ecto.Changeset
11
12
  alias OpenAgents.Settlement.{Claim, PaymentIntent}
13
14
  @primary_key {:id, :binary_id, autogenerate: true}
15
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
16
17
  @fields ~w(payment_intent_id claim_id amount_sats fee_sats payment_hash preimage_digest
18
             gateway_ref paid_at receipt_digest)a
19
20
  schema "settlement_payment_receipts" do
21
    field :amount_sats, :integer
22
    field :fee_sats, :integer
23
    field :payment_hash, :string
24
    field :preimage_digest, :string
25
    field :gateway_ref, :string
26
    field :paid_at, :utc_datetime_usec
27
    field :receipt_digest, :string
28
    belongs_to :payment_intent, PaymentIntent, type: :binary_id
29
    belongs_to :claim, Claim, type: :binary_id
30
    timestamps()
31
  end
32
33
  @type t :: %__MODULE__{}
34
35
  def changeset(record, attributes) do
36
    record
37
    |> cast(attributes, @fields)
38
    |> validate_required(@fields)
39
    |> validate_number(:amount_sats, greater_than: 0)
40
    |> validate_number(:fee_sats, greater_than_or_equal_to: 0)
41
    |> validate_format(:payment_hash, ~r/\A[0-9a-f]{64}\z/)
42
    |> validate_format(:preimage_digest, ~r/\A[0-9a-f]{64}\z/)
43
    |> validate_format(:receipt_digest, ~r/\A[0-9a-f]{64}\z/)
44
    |> validate_length(:gateway_ref, min: 1, max: 256)
45
    |> unique_constraint(:payment_intent_id)
46
    |> unique_constraint(:payment_hash)
47
    |> foreign_key_constraint(:claim_id)
48
    |> foreign_key_constraint(:payment_intent_id)
49
  end
50
end
lib/openagents/settlement/treasury_policy.ex added +44

@@ -0,0 +1,44 @@

1
defmodule OpenAgents.Settlement.TreasuryPolicy do
2
  @moduledoc """
3
  The operator-admitted treasury authority that bounds bounty settlement.
4
5
  The policy is append-only. It carries the payment bounds, the admitted
6
  self-custodial destination kinds, and the refund, expiry, retry, and dispute
7
  behavior that must exist before a payment is dispatched.
8
  """
9
10
  use Ecto.Schema
11
  import Ecto.Changeset
12
13
  @primary_key {:id, :binary_id, autogenerate: true}
14
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
15
16
  @fields ~w(policy_id version policy_digest rules actor_id auth_method approval_receipt_ref)a
17
18
  schema "settlement_treasury_policies" do
19
    field :policy_id, :string
20
    field :version, :integer
21
    field :policy_digest, :string
22
    field :rules, :map
23
    field :actor_id, :string
24
    field :auth_method, :string
25
    field :approval_receipt_ref, :string
26
    timestamps()
27
  end
28
29
  @type t :: %__MODULE__{}
30
31
  def changeset(record, attributes) do
32
    record
33
    |> cast(attributes, @fields)
34
    |> validate_required(@fields)
35
    |> validate_number(:version, greater_than: 0)
36
    |> validate_format(:policy_digest, ~r/\A[0-9a-f]{64}\z/)
37
    |> validate_length(:policy_id, min: 1, max: 128)
38
    |> validate_length(:actor_id, min: 1, max: 256)
39
    |> validate_length(:auth_method, min: 1, max: 128)
40
    |> validate_length(:approval_receipt_ref, min: 1, max: 256)
41
    |> unique_constraint([:policy_id, :version])
42
    |> unique_constraint(:approval_receipt_ref)
43
  end
44
end
lib/openagents/settlement/verification.ex added +60

@@ -0,0 +1,60 @@

1
defmodule OpenAgents.Settlement.Verification do
2
  @moduledoc """
3
  The qualification receipt for one claim at one exact commit.
4
5
  A verification is append-only and pins the specification fingerprint and the
6
  commit it graded, so a later commit cannot inherit an earlier acceptance.
7
  """
8
9
  use Ecto.Schema
10
  import Ecto.Changeset
11
12
  alias OpenAgents.Settlement.Claim
13
14
  @primary_key {:id, :binary_id, autogenerate: true}
15
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
16
17
  @outcomes ~w(accepted rejected)
18
19
  @fields ~w(claim_id spec_fingerprint commit_sha work_job_ref verifier_ref
20
             verifier_policy_digest evidence_digest outcome reason_code auth_method
21
             decision_receipt_ref)a
22
23
  schema "settlement_verifications" do
24
    field :spec_fingerprint, :string
25
    field :commit_sha, :string
26
    field :work_job_ref, :string
27
    field :verifier_ref, :string
28
    field :verifier_policy_digest, :string
29
    field :evidence_digest, :string
30
    field :outcome, :string
31
    field :reason_code, :string
32
    field :auth_method, :string
33
    field :decision_receipt_ref, :string
34
    belongs_to :claim, Claim, type: :binary_id
35
    timestamps()
36
  end
37
38
  @type t :: %__MODULE__{}
39
40
  @doc "Every verification outcome."
41
  def outcomes, do: @outcomes
42
43
  def changeset(record, attributes) do
44
    record
45
    |> cast(attributes, @fields)
46
    |> validate_required(@fields)
47
    |> validate_inclusion(:outcome, @outcomes)
48
    |> validate_format(:commit_sha, ~r/\A[0-9a-f]{40}\z/)
49
    |> validate_format(:spec_fingerprint, ~r/\A[0-9a-f]{64}\z/)
50
    |> validate_format(:verifier_policy_digest, ~r/\A[0-9a-f]{64}\z/)
51
    |> validate_format(:evidence_digest, ~r/\A[0-9a-f]{64}\z/)
52
    |> validate_length(:verifier_ref, min: 1, max: 256)
53
    |> validate_length(:work_job_ref, min: 1, max: 256)
54
    |> validate_length(:reason_code, min: 1, max: 128)
55
    |> validate_length(:decision_receipt_ref, min: 1, max: 256)
56
    |> unique_constraint([:claim_id, :commit_sha])
57
    |> unique_constraint(:decision_receipt_ref)
58
    |> foreign_key_constraint(:claim_id)
59
  end
60
end
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -243,7 +243,8 @@

243 243
    20260823051500,
244 244
    20260823052000,
245 245
    20260823053000,
246
    20260823054500
246
    20260823054500,
247
    20260823060000
247 248
  ],
248 249
  "required_tables": [
249 250
    "users",
priv/repo/migrations/20260823060000_create_bounty_settlement.exs added +166

@@ -0,0 +1,166 @@

1
defmodule OpenAgents.Repo.Migrations.CreateBountySettlement do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:settlement_treasury_policies, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
      add :policy_id, :string, null: false
8
      add :version, :integer, null: false
9
      add :policy_digest, :string, null: false
10
      add :rules, :map, null: false
11
      add :actor_id, :string, null: false
12
      add :auth_method, :string, null: false
13
      add :approval_receipt_ref, :string, null: false
14
      timestamps(type: :utc_datetime_usec, updated_at: false)
15
    end
16
17
    create unique_index(:settlement_treasury_policies, [:policy_id, :version])
18
    create unique_index(:settlement_treasury_policies, [:approval_receipt_ref])
19
20
    create table(:settlement_bounty_specs, primary_key: false) do
21
      add :id, :binary_id, primary_key: true
22
23
      add :treasury_policy_id,
24
          references(:settlement_treasury_policies, type: :binary_id, on_delete: :restrict),
25
          null: false
26
27
      add :issue_id, references(:issues, on_delete: :restrict), null: false
28
      add :revision, :integer, null: false
29
      add :buyer_ref, :string, null: false
30
      add :amount_sats, :integer, null: false
31
      add :acceptance_criteria, {:array, :string}, null: false
32
      add :verification_policy, :map, null: false
33
      add :destination_kind, :string, null: false
34
      add :expires_at, :utc_datetime_usec, null: false
35
      add :spec_fingerprint, :string, null: false
36
      add :actor_id, :string, null: false
37
      add :auth_method, :string, null: false
38
      add :approval_receipt_ref, :string, null: false
39
      timestamps(type: :utc_datetime_usec, updated_at: false)
40
    end
41
42
    create unique_index(:settlement_bounty_specs, [:issue_id, :revision])
43
    create unique_index(:settlement_bounty_specs, [:spec_fingerprint])
44
    create unique_index(:settlement_bounty_specs, [:approval_receipt_ref])
45
46
    create table(:settlement_claims, primary_key: false) do
47
      add :id, :binary_id, primary_key: true
48
49
      add :bounty_spec_id,
50
          references(:settlement_bounty_specs, type: :binary_id, on_delete: :restrict),
51
          null: false
52
53
      add :spec_fingerprint, :string, null: false
54
      add :claimant_ref, :string, null: false
55
      add :work_job_ref, :string, null: false
56
      add :destination_kind, :string, null: false
57
      add :destination, :string, null: false
58
      add :destination_digest, :string, null: false
59
      add :state, :string, null: false
60
      add :claim_digest, :string, null: false
61
      add :expires_at, :utc_datetime_usec, null: false
62
      timestamps(type: :utc_datetime_usec)
63
    end
64
65
    create unique_index(:settlement_claims, [:bounty_spec_id],
66
             where: "state NOT IN ('expired', 'rejected')",
67
             name: :settlement_claim_single_live_claim
68
           )
69
70
    create index(:settlement_claims, [:claimant_ref])
71
72
    create table(:settlement_verifications, primary_key: false) do
73
      add :id, :binary_id, primary_key: true
74
75
      add :claim_id, references(:settlement_claims, type: :binary_id, on_delete: :restrict),
76
        null: false
77
78
      add :spec_fingerprint, :string, null: false
79
      add :commit_sha, :string, null: false
80
      add :work_job_ref, :string, null: false
81
      add :verifier_ref, :string, null: false
82
      add :verifier_policy_digest, :string, null: false
83
      add :evidence_digest, :string, null: false
84
      add :outcome, :string, null: false
85
      add :reason_code, :string, null: false
86
      add :auth_method, :string, null: false
87
      add :decision_receipt_ref, :string, null: false
88
      timestamps(type: :utc_datetime_usec, updated_at: false)
89
    end
90
91
    create unique_index(:settlement_verifications, [:claim_id, :commit_sha])
92
    create unique_index(:settlement_verifications, [:decision_receipt_ref])
93
94
    create table(:settlement_payment_intents, primary_key: false) do
95
      add :id, :binary_id, primary_key: true
96
97
      add :claim_id, references(:settlement_claims, type: :binary_id, on_delete: :restrict),
98
        null: false
99
100
      add :verification_id,
101
          references(:settlement_verifications, type: :binary_id, on_delete: :restrict),
102
          null: false
103
104
      add :idempotency_key, :string, null: false
105
      add :amount_sats, :integer, null: false
106
      add :commit_sha, :string, null: false
107
      add :destination_digest, :string, null: false
108
      add :spec_fingerprint, :string, null: false
109
      add :state, :string, null: false
110
      add :attempts, :integer, null: false, default: 0
111
      add :failure_reason_code, :string
112
      add :intent_digest, :string, null: false
113
      add :actor_id, :string, null: false
114
      add :auth_method, :string, null: false
115
      add :approval_receipt_ref, :string, null: false
116
      timestamps(type: :utc_datetime_usec)
117
    end
118
119
    create unique_index(:settlement_payment_intents, [:idempotency_key])
120
121
    create unique_index(:settlement_payment_intents, [:claim_id],
122
             where: "state = 'paid'",
123
             name: :settlement_payment_intent_single_paid
124
           )
125
126
    create table(:settlement_payment_receipts, primary_key: false) do
127
      add :id, :binary_id, primary_key: true
128
129
      add :payment_intent_id,
130
          references(:settlement_payment_intents, type: :binary_id, on_delete: :restrict),
131
          null: false
132
133
      add :claim_id, references(:settlement_claims, type: :binary_id, on_delete: :restrict),
134
        null: false
135
136
      add :amount_sats, :integer, null: false
137
      add :fee_sats, :integer, null: false
138
      add :payment_hash, :string, null: false
139
      add :preimage_digest, :string, null: false
140
      add :gateway_ref, :string, null: false
141
      add :paid_at, :utc_datetime_usec, null: false
142
      add :receipt_digest, :string, null: false
143
      timestamps(type: :utc_datetime_usec, updated_at: false)
144
    end
145
146
    create unique_index(:settlement_payment_receipts, [:payment_intent_id])
147
    create unique_index(:settlement_payment_receipts, [:payment_hash])
148
149
    create table(:settlement_adjustments, primary_key: false) do
150
      add :id, :binary_id, primary_key: true
151
152
      add :claim_id, references(:settlement_claims, type: :binary_id, on_delete: :restrict),
153
        null: false
154
155
      add :kind, :string, null: false
156
      add :reason_code, :string, null: false
157
      add :actor_id, :string, null: false
158
      add :auth_method, :string, null: false
159
      add :approval_receipt_ref, :string, null: false
160
      add :adjustment_digest, :string, null: false
161
      timestamps(type: :utc_datetime_usec, updated_at: false)
162
    end
163
164
    create unique_index(:settlement_adjustments, [:claim_id, :kind, :approval_receipt_ref])
165
  end
166
end
test/openagents/settlement_test.exs added +668

@@ -0,0 +1,668 @@

1
defmodule OpenAgents.SettlementTest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgents.IssuesFixtures
5
6
  alias OpenAgents.Settlement
7
8
  alias OpenAgents.Settlement.{Adjustment, Claim, PaymentIntent, PaymentReceipt}
9
10
  defmodule Gateway do
11
    @moduledoc false
12
    @behaviour OpenAgents.Settlement.PaymentGateway
13
14
    @impl true
15
    def pay(request) do
16
      record(:pay)
17
      answer(:pay, request)
18
    end
19
20
    @impl true
21
    def lookup(idempotency_key) do
22
      record(:lookup)
23
      answer(:lookup, idempotency_key)
24
    end
25
26
    def calls(action) do
27
      Application.get_env(:openagents, :settlement_test_calls)
28
      |> Agent.get(&Map.get(&1, action, 0))
29
    end
30
31
    defp record(action) do
32
      Application.get_env(:openagents, :settlement_test_calls)
33
      |> Agent.update(&Map.update(&1, action, 1, fn count -> count + 1 end))
34
    end
35
36
    defp answer(action, argument) do
37
      answers = Application.fetch_env!(:openagents, :settlement_test_answers)
38
      answers.(action, argument)
39
    end
40
  end
41
42
  @preimage_digest String.duplicate("ab", 32)
43
44
  setup do
45
    counter = start_supervised!({Agent, fn -> %{} end})
46
    Application.put_env(:openagents, :settlement_payment_gateway, Gateway)
47
    Application.put_env(:openagents, :settlement_test_calls, counter)
48
    answer_with(&settles/2)
49
50
    repository = repository_fixture()
51
    issue = issue_fixture(repository, %{title: "Bounded bounty"})
52
53
    on_exit(fn ->
54
      Application.delete_env(:openagents, :settlement_payment_gateway)
55
      Application.delete_env(:openagents, :settlement_test_calls)
56
      Application.delete_env(:openagents, :settlement_test_answers)
57
      Application.delete_env(:openagents, :forge_public_visibility)
58
    end)
59
60
    %{repository: repository, issue: issue}
61
  end
62
63
  describe "treasury policy" do
64
    test "pricing needs an admitted treasury policy", %{issue: issue} do
65
      assert {:error, :treasury_policy_missing} =
66
               Settlement.price_bounty(issue, price_attributes(), operator())
67
    end
68
69
    test "an admitted policy carries a digest over its exact rules" do
70
      assert {:ok, policy} = Settlement.admit_treasury_policy(operator())
71
      assert policy.policy_id == Settlement.policy_id()
72
      assert String.match?(policy.policy_digest, ~r/\A[0-9a-f]{64}\z/)
73
      assert policy.rules["custody"] == "claimant_self_custodial_destination"
74
    end
75
76
    test "a policy that makes verification optional is refused" do
77
      assert {:error, :verification_optional} =
78
               Settlement.admit_treasury_policy(operator(), %{
79
                 "requires_accepted_verification" => false
80
               })
81
    end
82
83
    test "a price above the treasury authority is refused", %{issue: issue} do
84
      {:ok, _policy} =
85
        Settlement.admit_treasury_policy(operator(), %{"max_payment_sats" => 5_000})
86
87
      assert {:error, :amount_exceeds_treasury_authority} =
88
               Settlement.price_bounty(issue, price_attributes(%{amount_sats: 5_001}), operator())
89
    end
90
91
    test "a destination kind outside the policy is refused", %{issue: issue} do
92
      {:ok, _policy} = Settlement.admit_treasury_policy(operator())
93
94
      assert {:error, :destination_kind_not_admitted} =
95
               Settlement.price_bounty(
96
                 issue,
97
                 price_attributes(%{destination_kind: "hosted_wallet"}),
98
                 operator()
99
               )
100
    end
101
  end
102
103
  describe "settlement" do
104
    test "a verified claim is paid once and keeps its exact receipt", %{issue: issue} do
105
      %{claim: claim, spec: spec} = verified_claim(issue)
106
107
      assert {:ok, settled} = Settlement.settle(claim, settlement_request())
108
      assert settled.claim.state == "paid"
109
      assert settled.intent.state == "paid"
110
      assert settled.intent.attempts == 1
111
      assert settled.receipt.amount_sats == spec.amount_sats
112
      assert settled.receipt.preimage_digest == @preimage_digest
113
      assert String.match?(settled.receipt.receipt_digest, ~r/\A[0-9a-f]{64}\z/)
114
      assert Gateway.calls(:pay) == 1
115
      assert Repo.aggregate(PaymentReceipt, :count) == 1
116
    end
117
118
    test "a duplicate request returns the first receipt without paying again", %{issue: issue} do
119
      %{claim: claim} = verified_claim(issue)
120
      request = settlement_request()
121
122
      assert {:ok, first} = Settlement.settle(claim, request)
123
      assert {:ok, second} = Settlement.settle(claim, request)
124
125
      assert second.receipt.id == first.receipt.id
126
      assert Gateway.calls(:pay) == 1
127
      assert Repo.aggregate(PaymentReceipt, :count) == 1
128
    end
129
130
    test "a second idempotency key for a paid claim cannot pay again", %{issue: issue} do
131
      %{claim: claim} = verified_claim(issue)
132
      assert {:ok, _settled} = Settlement.settle(claim, settlement_request())
133
134
      assert {:error, {:claim_not_settleable, "paid"}} =
135
               Settlement.settle(claim, settlement_request())
136
137
      assert Repo.aggregate(PaymentReceipt, :count) == 1
138
    end
139
140
    test "an idempotency key cannot be reused for another claim", %{issue: issue} do
141
      %{claim: first_claim} = verified_claim(issue)
142
      request = settlement_request()
143
      assert {:ok, _settled} = Settlement.settle(first_claim, request)
144
145
      other_issue = issue_fixture(Repo.preload(issue, :repository).repository, %{title: "Second"})
146
      %{claim: second_claim} = verified_claim(other_issue)
147
148
      assert {:error, :idempotency_key_conflict} =
149
               Settlement.settle(second_claim, %{request | commit_sha: commit_sha()})
150
    end
151
152
    test "a settlement without an approval reference is refused", %{issue: issue} do
153
      %{claim: claim} = verified_claim(issue)
154
      request = Map.delete(settlement_request(), :approval_receipt_ref)
155
156
      assert {:error, :approval_missing} = Settlement.settle(claim, request)
157
      assert Gateway.calls(:pay) == 0
158
    end
159
160
    test "a repriced specification stops payment on the old claim", %{issue: issue} do
161
      %{claim: claim} = verified_claim(issue)
162
163
      {:ok, _repriced} =
164
        Settlement.price_bounty(issue, price_attributes(%{amount_sats: 4_000}), operator())
165
166
      assert {:error, :spec_superseded} = Settlement.settle(claim, settlement_request())
167
      assert Gateway.calls(:pay) == 0
168
    end
169
170
    test "a claim pinned to a stale fingerprint stops payment", %{issue: issue} do
171
      %{claim: claim} = verified_claim(issue)
172
173
      {1, nil} =
174
        Repo.update_all(
175
          from(record in Claim, where: record.id == ^claim.id),
176
          set: [spec_fingerprint: String.duplicate("cd", 32)]
177
        )
178
179
      assert {:error, :spec_fingerprint_mismatch} =
180
               Settlement.settle(claim, settlement_request())
181
    end
182
183
    test "a rejected verifier stops payment", %{issue: issue} do
184
      %{claim: claim} = claimed_bounty(issue)
185
186
      assert {:ok, verification} =
187
               Settlement.verify_claim(
188
                 claim,
189
                 verification_attributes(%{outcome: "rejected", reason_code: "criteria_unmet"})
190
               )
191
192
      assert verification.outcome == "rejected"
193
      assert Repo.get!(Claim, claim.id).state == "rejected"
194
195
      assert {:error, {:claim_not_settleable, "rejected"}} =
196
               Settlement.settle(
197
                 claim,
198
                 settlement_request(%{commit_sha: verification.commit_sha})
199
               )
200
201
      assert Gateway.calls(:pay) == 0
202
    end
203
204
    test "a commit without its own verification stops payment", %{issue: issue} do
205
      %{claim: claim} = verified_claim(issue)
206
207
      assert {:error, :stale_commit} =
208
               Settlement.settle(claim, settlement_request(%{commit_sha: commit_sha()}))
209
210
      assert Gateway.calls(:pay) == 0
211
    end
212
213
    test "a claim without any verification stops payment", %{issue: issue} do
214
      %{claim: claim} = claimed_bounty(issue)
215
216
      assert {:error, :verification_missing} = Settlement.settle(claim, settlement_request())
217
    end
218
219
    test "a verification against a different work job is refused", %{issue: issue} do
220
      %{claim: claim} = claimed_bounty(issue)
221
222
      assert {:error, :work_job_mismatch} =
223
               Settlement.verify_claim(
224
                 claim,
225
                 verification_attributes(%{work_job_ref: "work-job:other"})
226
               )
227
    end
228
229
    test "a verification under a different policy digest is refused", %{issue: issue} do
230
      %{claim: claim} = claimed_bounty(issue)
231
232
      assert {:error, :verifier_policy_mismatch} =
233
               Settlement.verify_claim(
234
                 claim,
235
                 verification_attributes(%{verifier_policy_digest: String.duplicate("ef", 32)})
236
               )
237
    end
238
239
    test "the treasury daily budget bounds settlement", %{issue: issue} do
240
      %{claim: claim} =
241
        verified_claim(issue, %{
242
          rules: %{"max_payment_sats" => 3_000, "daily_budget_sats" => 3_000},
243
          price: %{amount_sats: 3_000}
244
        })
245
246
      assert {:ok, _settled} = Settlement.settle(claim, settlement_request())
247
248
      second_issue = issue_fixture(Repo.preload(issue, :repository).repository, %{title: "Next"})
249
250
      {:ok, spec} =
251
        Settlement.price_bounty(second_issue, price_attributes(%{amount_sats: 3_000}), operator())
252
253
      {:ok, second_claim} = Settlement.claim_bounty(spec, claimant())
254
255
      {:ok, verification} =
256
        Settlement.verify_claim(second_claim, verification_attributes(%{work_job_ref: nil}))
257
258
      assert {:error, :daily_budget_exhausted} =
259
               Settlement.settle(
260
                 second_claim,
261
                 settlement_request(%{commit_sha: verification.commit_sha})
262
               )
263
    end
264
  end
265
266
  describe "payment failure and reconciliation" do
267
    test "a failed payment retries under the same key and pays once", %{issue: issue} do
268
      %{claim: claim} = verified_claim(issue)
269
      request = settlement_request()
270
      answer_with(fn :pay, _request -> {:error, "route_not_found"} end)
271
272
      assert {:error, {:payment_failed, "route_not_found"}} = Settlement.settle(claim, request)
273
274
      assert Repo.get_by!(PaymentIntent, idempotency_key: request.idempotency_key).state ==
275
               "failed"
276
277
      answer_with(&settles/2)
278
279
      assert {:ok, settled} = Settlement.settle(claim, request)
280
      assert settled.intent.attempts == 2
281
      assert Repo.aggregate(PaymentReceipt, :count) == 1
282
    end
283
284
    test "retries stop at the policy attempt bound", %{issue: issue} do
285
      %{claim: claim} = verified_claim(issue, %{rules: %{"max_attempts" => 1}})
286
      request = settlement_request()
287
      answer_with(fn :pay, _request -> {:error, "route_not_found"} end)
288
289
      assert {:error, {:payment_failed, "route_not_found"}} = Settlement.settle(claim, request)
290
291
      assert {:error, :payment_attempts_exhausted} = Settlement.settle(claim, request)
292
      assert Gateway.calls(:pay) == 1
293
    end
294
295
    test "a lost acknowledgement reconciles into one payment", %{issue: issue} do
296
      %{claim: claim} = verified_claim(issue)
297
      request = settlement_request()
298
299
      answer_with(fn
300
        :pay, _request -> {:pending, "acknowledgement_missing"}
301
        :lookup, _key -> settled_evidence()
302
      end)
303
304
      assert {:pending, intent} = Settlement.settle(claim, request)
305
      assert intent.state == "pending"
306
      assert Repo.aggregate(PaymentReceipt, :count) == 0
307
308
      assert {:ok, settled} = Settlement.reconcile(request.idempotency_key)
309
      assert settled.claim.state == "paid"
310
      assert Gateway.calls(:pay) == 1
311
      assert Repo.aggregate(PaymentReceipt, :count) == 1
312
313
      assert {:ok, again} = Settlement.reconcile(request.idempotency_key)
314
      assert again.receipt.id == settled.receipt.id
315
      assert Gateway.calls(:pay) == 1
316
    end
317
318
    test "an unknown key stays unpaid", %{issue: issue} do
319
      %{claim: claim} = verified_claim(issue)
320
      request = settlement_request()
321
322
      answer_with(fn
323
        :pay, _request -> {:pending, "acknowledgement_missing"}
324
        :lookup, _key -> {:unknown, nil}
325
      end)
326
327
      assert {:pending, _intent} = Settlement.settle(claim, request)
328
      assert {:error, :payment_unsettled} = Settlement.reconcile(request.idempotency_key)
329
      assert Repo.aggregate(PaymentReceipt, :count) == 0
330
    end
331
332
    test "reconciling an unknown intent reports the missing intent" do
333
      assert {:error, :payment_intent_missing} = Settlement.reconcile("no-such-key-000000")
334
    end
335
336
    test "an unconfigured gateway fails closed", %{issue: issue} do
337
      %{claim: claim} = verified_claim(issue)
338
      Application.delete_env(:openagents, :settlement_payment_gateway)
339
340
      assert {:error, {:payment_failed, "payment_gateway_unconfigured"}} =
341
               Settlement.settle(claim, settlement_request())
342
343
      assert Repo.aggregate(PaymentReceipt, :count) == 0
344
    end
345
  end
346
347
  describe "expiry, dispute, and refund" do
348
    test "an expired claim releases the specification and never pays", %{issue: issue} do
349
      %{claim: claim, spec: spec} = verified_claim(issue)
350
351
      assert {:ok, adjustment} = Settlement.expire_claim(claim, operator(), "claim_window_passed")
352
      assert adjustment.kind == "expiry"
353
      assert Repo.get!(Claim, claim.id).state == "expired"
354
355
      assert {:error, {:claim_not_settleable, "expired"}} =
356
               Settlement.settle(claim, settlement_request())
357
358
      assert {:ok, replacement} = Settlement.claim_bounty(spec, claimant())
359
      assert replacement.id != claim.id
360
    end
361
362
    test "a claim past its expiry cannot be verified or paid", %{issue: issue} do
363
      %{claim: claim} = verified_claim(issue)
364
      past = DateTime.add(DateTime.utc_now(), -60, :second)
365
366
      {1, nil} =
367
        Repo.update_all(from(record in Claim, where: record.id == ^claim.id),
368
          set: [expires_at: past]
369
        )
370
371
      assert {:error, :claim_expired} = Settlement.settle(claim, settlement_request())
372
373
      assert {:error, :claim_expired} =
374
               Settlement.verify_claim(claim, verification_attributes(%{work_job_ref: nil}))
375
    end
376
377
    test "a dispute freezes settlement", %{issue: issue} do
378
      %{claim: claim} = verified_claim(issue)
379
380
      assert {:ok, adjustment} = Settlement.open_dispute(claim, operator(), "buyer_contested")
381
      assert adjustment.kind == "dispute"
382
383
      assert {:error, {:claim_not_settleable, "disputed"}} =
384
               Settlement.settle(claim, settlement_request())
385
386
      assert Gateway.calls(:pay) == 0
387
    end
388
389
    test "a refund is appended without rewriting the payment receipt", %{issue: issue} do
390
      %{claim: claim} = verified_claim(issue)
391
      {:ok, settled} = Settlement.settle(claim, settlement_request())
392
393
      assert {:ok, adjustment} = Settlement.refund(claim, operator(), "buyer_withdrew")
394
      assert adjustment.kind == "refund"
395
      assert Repo.get!(Claim, claim.id).state == "refunded"
396
397
      receipt = Repo.get!(PaymentReceipt, settled.receipt.id)
398
      assert receipt.receipt_digest == settled.receipt.receipt_digest
399
      assert receipt.payment_hash == settled.receipt.payment_hash
400
      assert Repo.aggregate(Adjustment, :count) == 1
401
    end
402
403
    test "an unpaid claim cannot be refunded", %{issue: issue} do
404
      %{claim: claim} = verified_claim(issue)
405
406
      assert {:error, {:claim_not_paid, "verified"}} =
407
               Settlement.refund(claim, operator(), "buyer_withdrew")
408
    end
409
  end
410
411
  describe "public projection" do
412
    test "a dark repository publishes nothing", %{issue: issue} do
413
      %{claim: claim} = verified_claim(issue)
414
      {:ok, _settled} = Settlement.settle(claim, settlement_request())
415
416
      assert Settlement.public_projection(issue) == nil
417
    end
418
419
    test "the pulse level publishes bounded amount and status only", %{
420
      issue: issue,
421
      repository: repository
422
    } do
423
      %{claim: claim} = verified_claim(issue)
424
      {:ok, _settled} = Settlement.settle(claim, settlement_request())
425
      publish(repository, :l1)
426
427
      projection = Settlement.public_projection(issue)
428
429
      assert projection["amount_sats"] == 2_500
430
      assert projection["state"] == "paid"
431
      assert projection["paid"] == true
432
      assert projection["buyer_kind"] == "buyer"
433
      assert projection["claimant_kind"] == "agent"
434
      refute Map.has_key?(projection, "spec_fingerprint")
435
      refute Map.has_key?(projection, "commit_sha")
436
      refute_private(projection)
437
    end
438
439
    test "the ledger level publishes the evidence chain without private facts", %{
440
      issue: issue,
441
      repository: repository
442
    } do
443
      %{claim: claim, verification: verification} = verified_claim(issue)
444
      {:ok, settled} = Settlement.settle(claim, settlement_request())
445
      publish(repository, :l2)
446
447
      projection = Settlement.public_projection(issue)
448
449
      assert projection["commit_sha"] == verification.commit_sha
450
      assert projection["payment_hash"] == settled.receipt.payment_hash
451
      assert projection["receipt_digest"] == settled.receipt.receipt_digest
452
      assert projection["verifier_kind"] == "verifier"
453
      refute_private(projection)
454
    end
455
456
    test "an unpriced issue publishes nothing", %{issue: issue, repository: repository} do
457
      publish(repository, :l2)
458
      assert Settlement.public_projection(issue) == nil
459
    end
460
  end
461
462
  describe "receipt export" do
463
    test "the claimant exports a receipt that needs no hosted wallet", %{issue: issue} do
464
      %{claim: claim, verification: verification} = verified_claim(issue)
465
      {:ok, settled} = Settlement.settle(claim, settlement_request())
466
467
      assert {:ok, export} = Settlement.export_payment_receipt(claim, claim.claimant_ref)
468
469
      assert export["amount_sats"] == 2_500
470
      assert export["destination"] == claim.destination
471
      assert export["destination_kind"] == "bolt12_offer"
472
      assert export["commit_sha"] == verification.commit_sha
473
      assert export["payment_hash"] == settled.receipt.payment_hash
474
      assert export["receipt_digest"] == settled.receipt.receipt_digest
475
      refute Map.has_key?(export, "actor_id")
476
      refute Map.has_key?(export, "approval_receipt_ref")
477
      refute Map.has_key?(export, "gateway_ref")
478
    end
479
480
    test "another claimant cannot export the receipt", %{issue: issue} do
481
      %{claim: claim} = verified_claim(issue)
482
      {:ok, _settled} = Settlement.settle(claim, settlement_request())
483
484
      assert {:error, :not_the_claimant} =
485
               Settlement.export_payment_receipt(claim, "agent:someone-else")
486
    end
487
488
    test "an unpaid claim has no receipt to export", %{issue: issue} do
489
      %{claim: claim} = verified_claim(issue)
490
491
      assert {:error, :payment_receipt_missing} =
492
               Settlement.export_payment_receipt(claim, claim.claimant_ref)
493
    end
494
  end
495
496
  describe "claim admission" do
497
    test "only one live claim holds a specification", %{issue: issue} do
498
      %{spec: spec} = claimed_bounty(issue)
499
500
      assert {:error, changeset} = Settlement.claim_bounty(spec, claimant())
501
      assert "has already been taken" in errors_on(changeset).bounty_spec_id
502
    end
503
504
    test "a destination outside the policy is refused", %{issue: issue} do
505
      {:ok, _policy} = Settlement.admit_treasury_policy(operator())
506
      {:ok, spec} = Settlement.price_bounty(issue, price_attributes(), operator())
507
508
      assert {:error, :destination_kind_not_admitted} =
509
               Settlement.claim_bounty(spec, claimant(%{destination_kind: "hosted_wallet"}))
510
    end
511
512
    test "a superseded specification cannot be claimed", %{issue: issue} do
513
      {:ok, _policy} = Settlement.admit_treasury_policy(operator())
514
      {:ok, spec} = Settlement.price_bounty(issue, price_attributes(), operator())
515
516
      {:ok, repriced} =
517
        Settlement.price_bounty(issue, price_attributes(%{amount_sats: 1_500}), operator())
518
519
      assert {:error, :spec_superseded} = Settlement.claim_bounty(spec, claimant())
520
      assert {:ok, _claim} = Settlement.claim_bounty(repriced, claimant())
521
    end
522
523
    test "a claim records the destination digest, never a treasury wallet", %{issue: issue} do
524
      %{claim: claim} = claimed_bounty(issue)
525
526
      assert String.match?(claim.destination_digest, ~r/\A[0-9a-f]{64}\z/)
527
      assert claim.destination_kind == "bolt12_offer"
528
      assert claim.state == "claimed"
529
    end
530
  end
531
532
  defp verified_claim(issue, options \\ %{}) do
533
    %{claim: claim, spec: spec} = claimed_bounty(issue, options)
534
535
    {:ok, verification} =
536
      Settlement.verify_claim(claim, verification_attributes(%{commit_sha: default_commit_sha()}))
537
538
    %{claim: Repo.get!(Claim, claim.id), spec: spec, verification: verification}
539
  end
540
541
  defp claimed_bounty(issue, options \\ %{}) do
542
    {:ok, _policy} =
543
      case Settlement.treasury_policy() do
544
        {:ok, policy} -> {:ok, policy}
545
        {:error, :treasury_policy_missing} -> admit(Map.get(options, :rules, %{}))
546
      end
547
548
    {:ok, spec} =
549
      Settlement.price_bounty(
550
        issue,
551
        price_attributes(Map.get(options, :price, %{})),
552
        operator()
553
      )
554
555
    {:ok, claim} = Settlement.claim_bounty(spec, claimant())
556
    %{claim: claim, spec: spec}
557
  end
558
559
  defp admit(rules), do: Settlement.admit_treasury_policy(operator(), rules)
560
561
  defp operator do
562
    %{
563
      actor_id: "user:treasury-operator",
564
      auth_method: "session",
565
      approval_receipt_ref: "approval:#{unique()}"
566
    }
567
  end
568
569
  defp claimant(overrides \\ %{}) do
570
    Map.merge(
571
      %{
572
        claimant_ref: "agent:claimant-#{unique()}",
573
        work_job_ref: "work-job:#{unique()}",
574
        destination_kind: "bolt12_offer",
575
        destination: "lno1#{String.duplicate("q", 40)}"
576
      },
577
      overrides
578
    )
579
  end
580
581
  defp price_attributes(overrides \\ %{}) do
582
    Map.merge(
583
      %{
584
        buyer_ref: "buyer:openagents-treasury",
585
        amount_sats: 2_500,
586
        acceptance_criteria: ["The test suite passes.", "The receipt chain is exportable."],
587
        verification_policy: %{
588
          "name" => "forge.precommit.v1",
589
          "requires" => ["mix precommit", "reviewer decision"]
590
        },
591
        destination_kind: "bolt12_offer",
592
        expires_at: DateTime.add(DateTime.utc_now(), 7 * 86_400, :second)
593
      },
594
      overrides
595
    )
596
  end
597
598
  defp verification_attributes(overrides) do
599
    %{
600
      commit_sha: default_commit_sha(),
601
      verifier_ref: "verifier:forge-precommit",
602
      evidence_digest: String.duplicate("1a", 32),
603
      outcome: "accepted",
604
      reason_code: "criteria_met",
605
      auth_method: "session",
606
      decision_receipt_ref: "decision:#{unique()}"
607
    }
608
    |> Map.merge(overrides)
609
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
610
    |> Map.new()
611
  end
612
613
  defp settlement_request(overrides \\ %{}) do
614
    Map.merge(
615
      %{
616
        commit_sha: default_commit_sha(),
617
        idempotency_key: "settlement-#{unique()}",
618
        actor_id: "user:treasury-operator",
619
        auth_method: "session",
620
        approval_receipt_ref: "approval:#{unique()}"
621
      },
622
      overrides
623
    )
624
  end
625
626
  defp settles(:pay, _request), do: settled_evidence()
627
  defp settles(:lookup, _key), do: {:unknown, nil}
628
629
  defp settled_evidence do
630
    {:ok,
631
     %{
632
       payment_hash: String.duplicate("0", 63) <> "1",
633
       preimage_digest: @preimage_digest,
634
       fee_sats: 3,
635
       paid_at: DateTime.utc_now(),
636
       gateway_ref: "treasury-node:payment-1"
637
     }}
638
  end
639
640
  defp answer_with(answers),
641
    do: Application.put_env(:openagents, :settlement_test_answers, answers)
642
643
  defp publish(repository, level),
644
    do: Application.put_env(:openagents, :forge_public_visibility, %{repository.name => level})
645
646
  defp refute_private(projection) do
647
    refute Map.has_key?(projection, "destination")
648
    refute Map.has_key?(projection, "destination_digest")
649
    refute Map.has_key?(projection, "claimant_ref")
650
    refute Map.has_key?(projection, "buyer_ref")
651
    refute Map.has_key?(projection, "work_job_ref")
652
    refute Map.has_key?(projection, "actor_id")
653
    refute Map.has_key?(projection, "approval_receipt_ref")
654
    refute Map.has_key?(projection, "gateway_ref")
655
    refute Map.has_key?(projection, "preimage_digest")
656
  end
657
658
  defp default_commit_sha, do: String.duplicate("a", 40)
659
660
  defp commit_sha do
661
    unique()
662
    |> Integer.to_string(16)
663
    |> String.downcase()
664
    |> String.pad_leading(40, "b")
665
  end
666
667
  defp unique, do: System.unique_integer([:positive, :monotonic])
668
end

This page updates live while a promote is in flight · changelog