Drive one sats-priced bounty from pricing to receipt

8cd9cad5c0b3 · AtlantisPleb · · parent 1666117ff708

Drive one sats-priced bounty from pricing to receipt

SETTLEMENT-001 proves every clause of bounty settlement in isolation, but
against invented material: a commit sha of forty `a` characters, a work-job
reference naming no work job, an evidence digest over nothing, and a gateway
private to the test file. The clauses hold; the loop had never been driven.

Ship a simulated rail and drive it once on real material. The commit is a
git object the forge resolves, the issue is labelled `bounty` and scoped to
the accepted-outcome contract, and the work is an accepted OUTCOME-001
completion claim closed at that revision. Settlement then prices it in whole
sats, pins the fingerprint, verifies that exact commit against a digest taken
over the completion claim, and pays once.

The rail moves nothing and cannot be mistaken for one that does: it refuses
when `runtime_environment` is `:production`, stamps `simulated:` on every
receipt it mints, derives its payment hash from the idempotency key so a
replay collides with the unique constraint instead of paying twice, and
refuses a sats amount that is not a positive integer rather than rounding one
into a receipt. Outbound payout stays on the MoneyDevKit bridge.

The proof also names two things it shows rather than enforces: settlement
checks a commit sha's shape and never asks the forge whether that commit
exists, and nothing requires an evidence digest to be taken over an accepted
completion claim. The test does both by hand and asserts the stale commit is
refused, so the green could have been red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.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.

pushed
by user · WAL seq 377 · 2026-08-25T14:28:28.601442Z

Changed files

  • modified INVARIANTS.md
  • added lib/openagents/settlement/payment_gateway/simulated.ex
  • added test/openagents/settlement/simulated_bounty_proof_test.exs

Diff

3 files changed, +665 -2

INVARIANTS.md modified +31 -2

@@ -1588,11 +1588,40 @@ claimant or buyer reference, an operator identity, an approval reference, or a

1588 1588
gateway reference. The claimant can export the full receipt, including their own
1589 1589
destination, without a hosted wallet.
1590 1590
1591
**A simulated rail is admissible, and it cannot be mistaken for a real one.**
1592
`OpenAgents.Settlement.PaymentGateway.Simulated` exists so the loop can be
1593
driven end to end where no treasury is attached, and it is bounded three ways:
1594
it refuses outright when `runtime_environment` is `:production`, so pointing a
1595
live treasury at it fails the attempt rather than faking a payment; every
1596
`gateway_ref` it mints starts with `simulated:`, so a row it wrote is
1597
distinguishable from a real transfer in the database and in every export; and
1598
its payment hash is derived from the idempotency key alone, so a replay
1599
produces the identical hash and the unique `payment_hash` constraint refuses a
1600
second receipt. It keeps no ledger and vouches for no key it was not handed. It
1601
moves no sats: outbound payout stays on the self-custodial MoneyDevKit treasury
1602
bridge, which this repository does not implement.
1603
1604
Sats are integers everywhere on this path — the treasury rules, the priced
1605
amount, the intent, the receipt amount, and the fee — and the rail itself
1606
refuses an amount that is not a positive integer rather than rounding one into
1607
a receipt.
1608
1591 1609
Evidence: `OpenAgents.Settlement`, `OpenAgents.Settlement.PaymentGateway`, the
1592 1610
settlement schemas and tables, with their uniqueness and partial-uniqueness
1593 1611
constraints, and the pricing, claim, verification, duplicate, stale-commit,
1594 1612
approval, budget, retry, reconciliation, expiry, dispute, refund, privacy, and
1595
receipt-export cases in `test/openagents/settlement_test.exs`.
1613
receipt-export cases in `test/openagents/settlement_test.exs`. One bounty is
1614
driven the whole way on real material — a resolvable commit in a forge-hosted
1615
repository, a `bounty`-labelled issue, and an accepted `OUTCOME-001` completion
1616
claim closed at that revision — in
1617
`test/openagents/settlement/simulated_bounty_proof_test.exs`.
1618
1619
Two things this invariant deliberately does not claim. Settlement checks the
1620
shape of a commit sha and never asks the forge whether that commit exists, and
1621
nothing requires a verification's evidence digest to be taken over an accepted
1622
completion claim; the end-to-end proof does both by hand, so the binding is
1623
shown rather than enforced. Making either one policy is `#207`'s successor
1624
work, not this invariant.
1596 1625
1597 1626
### MODULE-001 — Every invocation pins one immutable admitted module
1598 1627

@@ -5378,7 +5407,7 @@ contract; the invariant prose above defines the assertion, not the filename.

5378 5407
| COLLECTIVE-003 | `test/openagents/collective_publication_test.exs` |
5379 5408
| COMPENSATION-001 | `test/openagents/compensation_test.exs` |
5380 5409
| REPUTATION-001 | `test/openagents/reputation_test.exs`, `test/openagents/forge/key_rotation_test.exs`, `test/openagents_web/controllers/reputation_controller_test.exs` |
5381
| SETTLEMENT-001 | `test/openagents/settlement_test.exs` |
5410
| SETTLEMENT-001 | `test/openagents/settlement_test.exs`, `test/openagents/settlement/constraints_test.exs`, `test/openagents/settlement/simulated_bounty_proof_test.exs` |
5382 5411
| MODULE-001 | `test/openagents/modules/registry_test.exs`, `test/openagents/tool_step_persistence_test.exs` |
5383 5412
| MODULE-002 | `test/openagents/modules/discovery_test.exs`, `test/openagents/modules/lifecycle_test.exs` |
5384 5413
| MODULE-003 | `test/openagents/modules/router_test.exs`, `test/openagents/turn_tool_loop_test.exs` |
lib/openagents/settlement/payment_gateway/simulated.ex added +111

@@ -0,0 +1,111 @@

1
defmodule OpenAgents.Settlement.PaymentGateway.Simulated do
2
  @moduledoc """
3
  A simulated bounty payment rail. No money moves and no key is held.
4
5
  The shipped alternative is `OpenAgents.Settlement.PaymentGateway.Unconfigured`,
6
  which refuses every request. That is correct for production and useless for a
7
  proof: with only those two, the settlement loop can be driven end to end
8
  nowhere except inside a private test module. This gateway exists so one
9
  sats-priced bounty can be driven from pricing through an accepted claim to a
10
  receipt, on a rail whose every artifact says on its face that it is simulated.
11
12
  What it is not: it opens no node connection, holds no key, custodies nothing,
13
  and reads no destination beyond checking that one is present. A `bolt12_offer`
14
  handed to it is never dialled. The evidence it returns is a hash of the
15
  idempotency key, not a preimage anyone can claim against a real invoice.
16
17
  Three properties make it safe to ship:
18
19
    * **It refuses in production.** `runtime_environment: :production` gets
20
      `{:error, "simulated_gateway_refused_in_production"}`, so pointing the
21
      live treasury at this module fakes no payment — it fails the attempt.
22
    * **Its evidence is marked.** Every `gateway_ref` starts with `simulated:`,
23
      so a receipt row minted here can never be mistaken for one a real rail
24
      produced, in the database or in an export.
25
    * **It is idempotent by construction.** The payment hash is derived from the
26
      idempotency key alone, so a replayed dispatch produces the identical hash
27
      and the unique `settlement_payment_receipts.payment_hash` constraint
28
      refuses the second receipt. Simulation cannot double-pay even by accident.
29
30
  `lookup/1` answers `{:unknown, nil}` for every key, because this rail keeps no
31
  ledger and will not vouch for a key it cannot see. That makes the
32
  lost-acknowledgement path unreachable here by design; `SETTLEMENT-001` proves
33
  reconciliation separately in `test/openagents/settlement_test.exs`.
34
35
  It moves no sats, so it proves the authority chain, not the transfer. The
36
  transfer stays on the self-custodial MoneyDevKit treasury path, which this
37
  repository does not implement.
38
  """
39
40
  @behaviour OpenAgents.Settlement.PaymentGateway
41
42
  alias OpenAgents.Provenance.Canonical
43
44
  @hash_domain "openagents.settlement.simulated.payment-hash.v1:"
45
  @preimage_domain "openagents.settlement.simulated.preimage.v1:"
46
47
  @doc "The prefix every simulated `gateway_ref` carries."
48
  def gateway_ref_prefix, do: "simulated:"
49
50
  @impl true
51
  def pay(request) do
52
    if admitted?() do
53
      settle(request)
54
    else
55
      {:error, "simulated_gateway_refused_in_production"}
56
    end
57
  end
58
59
  @impl true
60
  def lookup(_idempotency_key), do: {:unknown, nil}
61
62
  @doc """
63
  Whether this rail will answer at all.
64
65
  False in production, so the fail-closed posture survives a misconfiguration
66
  rather than depending on nobody making one.
67
  """
68
  def admitted?, do: Application.get_env(:openagents, :runtime_environment) != :production
69
70
  defp settle(request) when is_map(request) do
71
    with {:ok, key} <- idempotency_key(request),
72
         :ok <- integer_sats(request),
73
         :ok <- destination(request) do
74
      payment_hash = Canonical.sha256(@hash_domain <> key)
75
76
      {:ok,
77
       %{
78
         payment_hash: payment_hash,
79
         preimage_digest: Canonical.sha256(@preimage_domain <> key),
80
         fee_sats: 0,
81
         paid_at: DateTime.utc_now(),
82
         gateway_ref: gateway_ref_prefix() <> binary_part(payment_hash, 0, 32)
83
       }}
84
    end
85
  end
86
87
  defp settle(_request), do: {:error, "simulated_request_invalid"}
88
89
  defp idempotency_key(request) do
90
    case Map.get(request, :idempotency_key) do
91
      key when is_binary(key) and byte_size(key) >= 8 -> {:ok, key}
92
      _missing -> {:error, "simulated_idempotency_key_invalid"}
93
    end
94
  end
95
96
  # Sats are integers. A rail that accepted a float would let one reach a
97
  # receipt, so this refuses rather than rounds.
98
  defp integer_sats(request) do
99
    case Map.get(request, :amount_sats) do
100
      amount when is_integer(amount) and amount > 0 -> :ok
101
      _invalid -> {:error, "simulated_amount_sats_not_a_positive_integer"}
102
    end
103
  end
104
105
  defp destination(request) do
106
    case Map.get(request, :destination) do
107
      value when is_binary(value) and value != "" -> :ok
108
      _missing -> {:error, "simulated_destination_missing"}
109
    end
110
  end
111
end
test/openagents/settlement/simulated_bounty_proof_test.exs added +523

@@ -0,0 +1,523 @@

1
defmodule OpenAgents.Settlement.SimulatedBountyProofTest do
2
  @moduledoc """
3
  Issue `#207`: prove settlement once, on a simulated rail.
4
5
  `test/openagents/settlement_test.exs` proves every clause of `SETTLEMENT-001`
6
  in isolation, but it proves them against invented material: a commit sha of
7
  forty `a` characters, a work-job reference that names no work job, an
8
  evidence digest over nothing, and a gateway module private to that file.
9
  Each clause holds; the loop has never been driven.
10
11
  This file drives it, once, on real material:
12
13
    1. A repository with a real bare git repository behind it, and a real
14
       commit object in it. The sha the treasury pays for is a sha `git
15
       cat-file` resolves, not a string that matches a regex.
16
    2. An issue scoped to the accepted-outcome contract's four sections and
17
       labelled `bounty`.
18
    3. Real accepted work: an attempt bound to that commit, a qualification
19
       receipt published by someone who is not the requester, and a durable
20
       `OUTCOME-001` completion claim that grades `accepted` and closes the
21
       issue under the repository's two opt-ins.
22
    4. `SETTLEMENT-001` over that: an admitted treasury policy, a specification
23
       priced in whole sats and fingerprinted, a claim pinned to the
24
       fingerprint and to the claimant's own bolt12 offer, a verification of
25
       that exact commit whose evidence digest is taken over the completion
26
       claim, and a settlement carrying an approval reference and an
27
       idempotency key.
28
    5. A payment on `OpenAgents.Settlement.PaymentGateway.Simulated`, which
29
       moves no sats and holds no key, and stamps `simulated:` on the receipt
30
       so no reader can mistake it for a transfer.
31
32
  Then the parts that make "once" mean once: a replayed request returns the
33
  first receipt, a second idempotency key cannot pay again, the public
34
  projection carries the evidence chain and none of the private facts, and the
35
  claimant exports a receipt that needs no hosted wallet.
36
37
  No money moves here and nothing in this repository can make it move. Outbound
38
  payout lives on the self-custodial MoneyDevKit treasury bridge, outside this
39
  codebase; what is proven here is the authority chain that would authorize one.
40
41
  Two gaps this proof exposes rather than closes are named at the assertions
42
  that would otherwise hide them: settlement never asks the forge whether the
43
  commit it pays for exists, and nothing requires the verification's evidence
44
  digest to be taken over an accepted completion claim. The test does both by
45
  hand.
46
  """
47
48
  use OpenAgents.DataCase, async: false
49
50
  import OpenAgents.AccountsFixtures
51
  import OpenAgents.ForgePromotionFixtures
52
53
  alias OpenAgents.Conversations
54
  alias OpenAgents.Deployments.CheckResult
55
  alias OpenAgents.Forge.{Assignment, Browse}
56
  alias OpenAgents.Issues
57
  alias OpenAgents.Issues.{CompletionClaims, Evidence}
58
  alias OpenAgents.Machines
59
  alias OpenAgents.Provenance.Canonical
60
  alias OpenAgents.Settlement
61
  alias OpenAgents.Settlement.{Claim, PaymentGateway, PaymentIntent, PaymentReceipt}
62
  alias OpenAgents.Work
63
64
  @amount_sats 2_500
65
66
  @artifact_digest "sha256:" <> String.duplicate("9f", 32)
67
68
  @criterion "The bounty settles once against fingerprinted evidence."
69
70
  @scoped_body """
71
  ## Problem
72
73
  Settlement has never been driven end to end, so the loop is a claim rather
74
  than a fact.
75
76
  ## Scope
77
78
  One sats-priced bounty, from pricing to receipt, on a simulated rail.
79
80
  ## Acceptance criteria
81
82
  - The bounty settles once against fingerprinted evidence.
83
84
  ## Success metrics
85
86
  The receipt chain reconciles from the issue to the payment and back.
87
  """
88
89
  setup do
90
    isolate_forge_storage!()
91
92
    configured_gateway = Application.get_env(:openagents, :settlement_payment_gateway)
93
    configured_visibility = Application.get_env(:openagents, :forge_public_visibility)
94
    configured_environment = Application.get_env(:openagents, :runtime_environment)
95
96
    Application.put_env(
97
      :openagents,
98
      :settlement_payment_gateway,
99
      PaymentGateway.Simulated
100
    )
101
102
    on_exit(fn ->
103
      restore(:settlement_payment_gateway, configured_gateway)
104
      restore(:forge_public_visibility, configured_visibility)
105
      restore(:runtime_environment, configured_environment)
106
    end)
107
108
    # The buyer of the work and the owner of the repository. Somebody other
109
    # than this principal publishes the check, or the attempt would be
110
    # `unauthorized` under OUTCOME-001's producer-verifier separation.
111
    buyer = repository_user_fixture("bounty-buyer")
112
    repository = repository_with_member_fixture(buyer, %{}, "owner")
113
114
    # A real bare repository with a real commit in it, so the sha the treasury
115
    # pays for is one the forge can resolve.
116
    commit_sha = seeded_commit(repository.storage_key, "settle the bounty")
117
118
    {:ok, issue} =
119
      Issues.create_issue(repository, %{
120
        title: "Prove settlement once",
121
        body: @scoped_body
122
      })
123
124
    {:ok, issue} = Issues.add_labels(issue, ["bounty"], buyer)
125
126
    %{buyer: buyer, repository: repository, issue: issue, commit_sha: commit_sha}
127
  end
128
129
  describe "one sats-priced bounty, end to end, on the simulated rail" do
130
    test "it is priced, claimed, completed, verified, and paid exactly once", context do
131
      %{issue: issue, repository: repository, commit_sha: commit_sha} = context
132
133
      # ── the commit is real ───────────────────────────────────────────────
134
      # Settlement itself only checks the sha's shape (`validate_commit_sha/1`
135
      # is a regex). This proof resolves it through the forge so the payment
136
      # below is demonstrably for delivered bytes rather than a well-formed
137
      # string.
138
      assert {:ok, ^commit_sha} = Browse.resolve_commit(repository.storage_key, commit_sha)
139
      assert String.match?(commit_sha, ~r/\A[0-9a-f]{40}\z/)
140
141
      # ── the issue carries the bounty label ───────────────────────────────
142
      assert Enum.any?(issue.labels, &(&1["name"] == "bounty"))
143
144
      # ── the work is really accepted (OUTCOME-001) ────────────────────────
145
      completion = accepted_completion(context)
146
147
      assert completion.state == "accepted"
148
      assert completion.revision == commit_sha
149
      assert completion.closed
150
      assert completion.closed_by_actor == "system:accepted-outcome"
151
      assert Repo.get!(Issues.Issue, issue.id).state == "closed"
152
153
      # ── the treasury admits a policy that bounds every payment ───────────
154
      assert {:ok, policy} = Settlement.admit_treasury_policy(operator())
155
      assert policy.rules["unit"] == "sat"
156
      assert policy.rules["custody"] == "claimant_self_custodial_destination"
157
      assert is_integer(policy.rules["max_payment_sats"])
158
      assert is_integer(policy.rules["daily_budget_sats"])
159
160
      # ── the bounty is priced in whole sats and fingerprinted ─────────────
161
      assert {:ok, spec} = Settlement.price_bounty(issue, price_attributes(), operator())
162
      assert spec.amount_sats == @amount_sats
163
      assert is_integer(spec.amount_sats)
164
      assert spec.revision == 1
165
      assert String.match?(spec.spec_fingerprint, ~r/\A[0-9a-f]{64}\z/)
166
      assert Settlement.current_spec(issue).id == spec.id
167
168
      # ── the claimant claims it with their own destination ────────────────
169
      claimant = claimant(context, completion)
170
      assert {:ok, claim} = Settlement.claim_bounty(spec, claimant)
171
      assert claim.spec_fingerprint == spec.spec_fingerprint
172
      assert claim.state == "claimed"
173
      assert claim.destination == claimant.destination
174
      assert claim.destination_kind == "bolt12_offer"
175
      # The treasury records a digest of the destination and never a wallet of
176
      # its own for the claimant.
177
      assert claim.destination_digest == Canonical.sha256(claimant.destination)
178
179
      # ── the delivery is verified at that exact commit ────────────────────
180
      # SETTLEMENT-001 requires the verification to accept the exact commit the
181
      # claim delivered. It does not require the evidence digest to be taken
182
      # over an accepted completion claim, so this proof takes it over one.
183
      evidence_digest = completion_digest(issue, completion)
184
185
      assert {:ok, verification} =
186
               Settlement.verify_claim(claim, %{
187
                 commit_sha: commit_sha,
188
                 verifier_ref: "verifier:forge-qualification",
189
                 evidence_digest: evidence_digest,
190
                 outcome: "accepted",
191
                 reason_code: "acceptance_criteria_met",
192
                 auth_method: "session",
193
                 decision_receipt_ref: "decision:completion-claim:#{completion.id}",
194
                 work_job_ref: claimant.work_job_ref
195
               })
196
197
      assert verification.outcome == "accepted"
198
      assert verification.commit_sha == commit_sha
199
      assert verification.evidence_digest == evidence_digest
200
      assert verification.verifier_policy_digest == Canonical.digest!(spec.verification_policy)
201
      assert Repo.get!(Claim, claim.id).state == "verified"
202
203
      # ── the green above could have been red ──────────────────────────────
204
      # A different commit has no verification of its own, so the treasury
205
      # refuses it. Nothing here pays because a claim exists; it pays because
206
      # this commit was verified.
207
      other_sha = String.duplicate("a", 40)
208
      assert {:error, :not_found} = Browse.resolve_commit(repository.storage_key, other_sha)
209
210
      assert {:error, :stale_commit} =
211
               Settlement.settle(
212
                 Repo.get!(Claim, claim.id),
213
                 settlement_request(other_sha)
214
               )
215
216
      assert Repo.aggregate(PaymentReceipt, :count) == 0
217
218
      # ── the treasury pays, once, on a rail that moves nothing ────────────
219
      request = settlement_request(commit_sha)
220
      assert {:ok, settled} = Settlement.settle(claim, request)
221
222
      assert settled.claim.state == "paid"
223
      assert settled.intent.state == "paid"
224
      assert settled.intent.attempts == 1
225
      assert settled.intent.commit_sha == commit_sha
226
      assert settled.receipt.amount_sats == @amount_sats
227
228
      # Sats are integers on every field of the paid path.
229
      assert is_integer(settled.intent.amount_sats)
230
      assert is_integer(settled.receipt.amount_sats)
231
      assert is_integer(settled.receipt.fee_sats)
232
233
      # The receipt says on its face that no money moved.
234
      assert String.starts_with?(
235
               settled.receipt.gateway_ref,
236
               PaymentGateway.Simulated.gateway_ref_prefix()
237
             )
238
239
      assert String.match?(settled.receipt.payment_hash, ~r/\A[0-9a-f]{64}\z/)
240
      assert String.match?(settled.receipt.receipt_digest, ~r/\A[0-9a-f]{64}\z/)
241
242
      # ── once means once ──────────────────────────────────────────────────
243
      # The same key returns the first receipt.
244
      assert {:ok, replayed} = Settlement.settle(Repo.get!(Claim, claim.id), request)
245
      assert replayed.receipt.id == settled.receipt.id
246
247
      # A fresh key cannot pay the same claim again.
248
      assert {:error, {:claim_not_settleable, "paid"}} =
249
               Settlement.settle(
250
                 Repo.get!(Claim, claim.id),
251
                 settlement_request(commit_sha)
252
               )
253
254
      assert Repo.aggregate(PaymentReceipt, :count) == 1
255
      assert Repo.aggregate(PaymentIntent, :count) == 1
256
257
      # ── the public can read the chain, and none of the private facts ─────
258
      publish(repository, :l2)
259
      projection = Settlement.public_projection(Repo.get!(Issues.Issue, issue.id))
260
261
      assert projection["contract"] == "openagents.settlement.public.v1"
262
      assert projection["issue_number"] == issue.number
263
      assert projection["unit"] == "sat"
264
      assert projection["amount_sats"] == @amount_sats
265
      assert projection["state"] == "paid"
266
      assert projection["paid"] == true
267
      assert projection["commit_sha"] == commit_sha
268
      assert projection["spec_fingerprint"] == spec.spec_fingerprint
269
      assert projection["payment_hash"] == settled.receipt.payment_hash
270
271
      for private <- ~w(destination destination_digest claimant_ref buyer_ref work_job_ref
272
                        actor_id approval_receipt_ref gateway_ref preimage_digest) do
273
        refute Map.has_key?(projection, private)
274
      end
275
276
      # ── the claimant takes their receipt with them ───────────────────────
277
      assert {:ok, export} =
278
               Settlement.export_payment_receipt(
279
                 Repo.get!(Claim, claim.id),
280
                 claimant.claimant_ref
281
               )
282
283
      assert export["contract"] == "openagents.settlement.payment-receipt.v1"
284
      assert export["issue"]["number"] == issue.number
285
      assert export["issue"]["repository"] == repository.name
286
      assert export["unit"] == "sat"
287
      assert export["amount_sats"] == @amount_sats
288
      assert export["commit_sha"] == commit_sha
289
      assert export["destination"] == claimant.destination
290
      assert export["verification_evidence_digest"] == evidence_digest
291
      assert export["payment_hash"] == settled.receipt.payment_hash
292
293
      # Whatever the export carries, it carries no operator identity and no
294
      # gateway credential.
295
      for private <- ~w(actor_id approval_receipt_ref gateway_ref operator_id) do
296
        refute Map.has_key?(export, private)
297
      end
298
    end
299
  end
300
301
  describe "the simulated rail is safe to ship" do
302
    test "it refuses in production, so a misconfigured treasury fakes no payment", context do
303
      Application.put_env(:openagents, :runtime_environment, :production)
304
305
      refute PaymentGateway.Simulated.admitted?()
306
307
      assert {:error, "simulated_gateway_refused_in_production"} =
308
               PaymentGateway.Simulated.pay(gateway_request(context))
309
310
      # And the refusal reaches the domain as a failed attempt on a live
311
      # intent, not as a payment.
312
      %{claim: claim, commit_sha: commit_sha} = verified_claim(context)
313
314
      assert {:error, {:payment_failed, "simulated_gateway_refused_in_production"}} =
315
               Settlement.settle(claim, settlement_request(commit_sha))
316
317
      assert Repo.aggregate(PaymentReceipt, :count) == 0
318
      assert Repo.get_by!(PaymentIntent, claim_id: claim.id).state == "failed"
319
    end
320
321
    test "it refuses a sats amount that is not a positive integer", context do
322
      request = gateway_request(context)
323
324
      assert {:error, "simulated_amount_sats_not_a_positive_integer"} =
325
               PaymentGateway.Simulated.pay(%{request | amount_sats: 2_500.0})
326
327
      assert {:error, "simulated_amount_sats_not_a_positive_integer"} =
328
               PaymentGateway.Simulated.pay(%{request | amount_sats: 0})
329
    end
330
331
    test "it vouches for no key it was never handed" do
332
      assert {:unknown, nil} = PaymentGateway.Simulated.lookup("settlement-never-dispatched")
333
    end
334
  end
335
336
  # ── the accepted work ─────────────────────────────────────────────────────
337
338
  # An attempt that finished on the real commit, a qualification receipt for
339
  # those exact bytes published by somebody who is not the requester, and the
340
  # durable completion claim OUTCOME-001 grades from them.
341
  defp accepted_completion(context) do
342
    %{repository: repository, buyer: buyer, commit_sha: commit_sha} = context
343
344
    {:ok, _policy} =
345
      CompletionClaims.set_policy(
346
        repository,
347
        %{agents_enabled: true, verified_closing_enabled: true},
348
        buyer
349
      )
350
351
    assignment = attempt(context)
352
353
    check =
354
      %CheckResult{repository_id: repository.id}
355
      |> CheckResult.changeset(%{
356
        name: "precommit",
357
        commit_sha: commit_sha,
358
        artifact_digest: @artifact_digest,
359
        status: "succeeded"
360
      })
361
      |> Repo.insert!()
362
363
    [entry] = Evidence.record_check_result(check)
364
365
    {:ok, completion} =
366
      CompletionClaims.submit(assignment, :agent, %{
367
        evidence: [%{criterion: @criterion, evidence_id: entry.id}]
368
      })
369
370
    Map.put(completion, :assignment, assignment)
371
  end
372
373
  defp attempt(context) do
374
    %{repository: repository, issue: issue, buyer: buyer, commit_sha: commit_sha} = context
375
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
376
377
    %Assignment{}
378
    |> Assignment.changeset(%{
379
      target_kind: "computer",
380
      machine_id: paired_machine(buyer).id,
381
      repository_id: repository.id,
382
      issue_id: issue.id,
383
      requesting_principal: %{"type" => "user", "id" => buyer.id},
384
      branch: "agent/issue-#{issue.number}",
385
      state: "completed",
386
      terminal_commit: commit_sha,
387
      work_job_id: work_job().id,
388
      admitted_at: now,
389
      started_at: now,
390
      finished_at: now,
391
      deadline_at: DateTime.add(now, 3600, :second)
392
    })
393
    |> Repo.insert!()
394
  end
395
396
  defp work_job do
397
    key = "bounty-job-#{unique()}"
398
    {:ok, conversation} = Conversations.ensure_conversation(key)
399
    owner = Conversations.get_conversation_owner!(conversation)
400
401
    {:ok, job} =
402
      Work.create_job(%{
403
        conversation_id: conversation.id,
404
        owner_visitor_id: owner.id,
405
        surface: "text",
406
        goal: "settle one sats-priced bounty",
407
        budget_snapshot: %{"tokens" => 100_000, "seconds" => 900}
408
      })
409
410
    job
411
  end
412
413
  defp paired_machine(user) do
414
    {:ok, %{code: code}} =
415
      Machines.start_pairing(%{
416
        "name" => "bounty-agent-#{unique()}",
417
        "tier" => "curated",
418
        "platform" => "linux-x64",
419
        "agent_version" => "0.1.0",
420
        "roots" => []
421
      })
422
423
    {:ok, machine} = Machines.approve_pairing(user, code)
424
    machine
425
  end
426
427
  # ── the settlement material ───────────────────────────────────────────────
428
429
  defp verified_claim(context) do
430
    %{issue: issue, commit_sha: commit_sha} = context
431
    completion = accepted_completion(context)
432
433
    {:ok, _policy} = Settlement.admit_treasury_policy(operator())
434
    {:ok, spec} = Settlement.price_bounty(issue, price_attributes(), operator())
435
    claimant = claimant(context, completion)
436
    {:ok, claim} = Settlement.claim_bounty(spec, claimant)
437
438
    {:ok, _verification} =
439
      Settlement.verify_claim(claim, %{
440
        commit_sha: commit_sha,
441
        verifier_ref: "verifier:forge-qualification",
442
        evidence_digest: completion_digest(issue, completion),
443
        outcome: "accepted",
444
        reason_code: "acceptance_criteria_met",
445
        auth_method: "session",
446
        decision_receipt_ref: "decision:completion-claim:#{completion.id}"
447
      })
448
449
    %{claim: Repo.get!(Claim, claim.id), spec: spec, commit_sha: commit_sha}
450
  end
451
452
  # The settlement's evidence digest, taken over the accepted completion claim
453
  # so the payment is bound to the graded outcome and not to a free hex string.
454
  defp completion_digest(issue, completion) do
455
    Canonical.digest!(%{
456
      "contract" => "openagents.accepted-outcome.v1",
457
      "issue_number" => issue.number,
458
      "revision" => completion.revision,
459
      "state" => completion.state,
460
      "closed_by_actor" => completion.closed_by_actor,
461
      "criteria" => completion.criteria
462
    })
463
  end
464
465
  defp operator do
466
    %{
467
      actor_id: "user:treasury-operator",
468
      auth_method: "session",
469
      approval_receipt_ref: "approval:#{unique()}"
470
    }
471
  end
472
473
  defp price_attributes do
474
    %{
475
      buyer_ref: "buyer:openagents-treasury",
476
      amount_sats: @amount_sats,
477
      acceptance_criteria: [@criterion],
478
      verification_policy: %{
479
        "name" => "forge.qualification.v1",
480
        "requires" => ["accepted completion claim", "qualification receipt at the revision"]
481
      },
482
      destination_kind: "bolt12_offer",
483
      expires_at: DateTime.add(DateTime.utc_now(), 7 * 86_400, :second)
484
    }
485
  end
486
487
  defp claimant(%{buyer: buyer}, completion) do
488
    %{
489
      claimant_ref: "user:#{buyer.id}",
490
      work_job_ref: "work-job:#{completion.assignment.work_job_id}",
491
      destination_kind: "bolt12_offer",
492
      destination: "lno1#{String.duplicate("q", 40)}"
493
    }
494
  end
495
496
  defp settlement_request(commit_sha) do
497
    %{
498
      commit_sha: commit_sha,
499
      idempotency_key: "settlement-#{unique()}",
500
      actor_id: "user:treasury-operator",
501
      auth_method: "session",
502
      approval_receipt_ref: "approval:#{unique()}"
503
    }
504
  end
505
506
  defp gateway_request(%{commit_sha: commit_sha}) do
507
    %{
508
      idempotency_key: "settlement-#{unique()}",
509
      amount_sats: @amount_sats,
510
      destination_kind: "bolt12_offer",
511
      destination: "lno1#{String.duplicate("q", 40)}",
512
      memo: "bounty proof commit #{commit_sha}"
513
    }
514
  end
515
516
  defp publish(repository, level),
517
    do: Application.put_env(:openagents, :forge_public_visibility, %{repository.name => level})
518
519
  defp restore(key, nil), do: Application.delete_env(:openagents, key)
520
  defp restore(key, value), do: Application.put_env(:openagents, key, value)
521
522
  defp unique, do: System.unique_integer([:positive, :monotonic])
523
end

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