Add self-custodial Bitcoin tips and weighted forum ranking

ed2452ab44e5 · Devin AI · · parent f769743791fd

Add self-custodial Bitcoin tips and weighted forum ranking

An account attaches its own payment destination (Bolt 12, LNURL, or an
on-chain address) and the forum stores only the string it needs to pay
plus a non-reversible fingerprint: no seeds, keys, channels, or nodes,
and no destination on any public page. Payment leaves through an
admitted MoneyDevKit/LDK service, so the forum never takes custody. With
no service configured the adapter reports the service as unavailable and
tipping stays off.

A tip is an intent, a payment, and an append-only receipt. The
idempotency key is unique, so a retry returns the existing intent
instead of paying twice or counting twice, and an outage leaves the
intent in `created` for a later retry. Failures and refunds append their
own receipt and carry no ranking weight.

Settled, non-refunded tips feed a bounded ranking signal: each tip
counts at most 25,000 sats, each payer counts at most 25,000 sats per
post, and self-tips, reciprocal tips inside 30 days, and payer bursts
are recorded with an exclusion reason and count zero. Board order decays
the counted total with a 7-day half-life and reads only stored
aggregates, so ranking keeps working while the payment service is down.
Hiding or deleting a post withdraws its ranking weight and touches no
receipt, so moderation never touches funds.

Recipients verify settlement themselves: the tips page and
`GET /api/v3/forum/tips/received` return each payment hash, received and
refunded sats, and the destination fingerprint for export into their own
wallet.

Closes #87

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

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 config/config.exs
  • modified config/runtime.exs
  • modified config/test.exs
  • added docs/forum-bitcoin-tips.md
  • modified docs/openagents-cli/api.md
  • modified lib/openagents/forum.ex
  • modified lib/openagents/forum/post.ex
  • added lib/openagents/forum/tip_destination.ex
  • added lib/openagents/forum/tip_intent.ex
  • added lib/openagents/forum/tip_receipt.ex
  • added lib/openagents/forum/tips.ex
  • added lib/openagents/forum/tips/payment_service.ex
  • added lib/openagents/forum/tips/payment_service/money_dev_kit.ex
  • added lib/openagents/forum/tips/payment_service/unavailable.ex
  • modified lib/openagents/forum/topic.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/forum_api_controller.ex
  • modified lib/openagents_web/controllers/forum_api_json.ex
  • modified lib/openagents_web/live/forum_board_live.ex
  • added lib/openagents_web/live/forum_tips_live.ex
  • modified lib/openagents_web/live/forum_topic_live.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823053000_create_forum_tips.exs
  • added test/openagents/forum/tips_test.exs
  • added test/openagents_web/controllers/forum_tips_api_controller_test.exs
  • added test/openagents_web/live/forum_tips_live_test.exs
  • added test/support/forum/tip_payment_service.ex
  • modified test/test_helper.exs

Diff

29 files changed, +2826 -10

config/config.exs modified +6

@@ -360,6 +360,12 @@ config :logger, :default_formatter,

360 360
# Use Jason for JSON parsing in Phoenix
361 361
config :phoenix, :json_library, Jason
362 362
363
# Forum tips are off until an operator admits a self-custodial payment service.
364
# Tipping stays unavailable rather than routing sats through hosted custody.
365
config :openagents, :forum_tips,
366
  enabled: false,
367
  adapter: OpenAgents.Forum.Tips.PaymentService.Unavailable
368
363 369
# Import environment specific config. This must remain at the bottom
364 370
# of this file so it overrides the configuration defined above.
365 371
import_config "#{config_env()}.exs"
config/runtime.exs modified +13

@@ -589,3 +589,16 @@ if config_env() == :dev do

589 589
      ]
590 590
    ]
591 591
end
592
593
# Forum tips: the operator's own self-custodial wallet service moves the sats.
594
# Without a URL the adapter reports the service as unavailable, and the forum
595
# keeps ranking what already settled.
596
forum_tips_wallet_url = System.get_env("FORUM_TIPS_WALLET_URL")
597
598
if is_binary(forum_tips_wallet_url) and forum_tips_wallet_url != "" do
599
  config :openagents, :forum_tips,
600
    enabled: true,
601
    adapter: OpenAgents.Forum.Tips.PaymentService.MoneyDevKit,
602
    base_url: forum_tips_wallet_url,
603
    token: System.get_env("FORUM_TIPS_WALLET_TOKEN")
604
end
config/test.exs modified +5

@@ -37,6 +37,11 @@ config :openagents, :provider, OpenAgents.Providers.Test

37 37
config :openagents, :voice_call_provider, OpenAgents.Voice.TestCallProvider
38 38
config :openagents, :voice_sideband_provider, OpenAgents.Voice.TestSidebandProvider
39 39
40
# Tips are admitted in tests through a stub each test scripts itself.
41
config :openagents, :forum_tips,
42
  enabled: true,
43
  adapter: OpenAgents.Forum.TipPaymentServiceStub
44
40 45
# No project token is configured in tests, so OpenAgents.Analytics is a no-op.
41 46
# test_mode additionally drops any event that reaches the package directly.
42 47
config :posthog, test_mode: true
docs/forum-bitcoin-tips.md added +99

@@ -0,0 +1,99 @@

1
# Forum Bitcoin tips and weighted ranking
2
3
Date: 2026-08-23
4
5
Status: Current. The domain lives in `OpenAgents.Forum.Tips`; the browser
6
surface is `/forum/tips` and the tip buttons under each post.
7
8
## What custody means here
9
10
The forum never holds a payer's or a recipient's bitcoin. A recipient attaches
11
one destination it already controls — a Bolt 12 offer, an LNURL address, or an
12
on-chain address — and the forum stores that string only to hand it to the
13
payment service at settlement. The forum stores no seed, no private key, no
14
channel, no node, and no node credential, and it cannot spend a settled tip.
15
The only self-custody the forum records is the destination row and the
16
settlement receipts a recipient can verify in its own wallet.
17
18
Public projections carry the destination `kind` and a fingerprint, never the
19
destination. The fingerprint is a truncated, domain-separated SHA-256 digest,
20
so two people can compare destinations without the forum publishing one.
21
22
## The three records
23
24
| Record | Table | Purpose |
25
| --- | --- | --- |
26
| Destination | `forum_tip_destinations` | One active destination per account, with an opt-out flag |
27
| Intent | `forum_tip_intents` | One tip attempt, keyed by an idempotency key |
28
| Receipt | `forum_tip_receipts` | Append-only settled, failed, and refunded records |
29
30
An intent moves from `created` to exactly one of `settled`, `failed`, or
31
`refunded`. Receipts are append-only in the database: a trigger rejects every
32
update and delete, so a settlement a recipient already verified cannot be
33
rewritten later.
34
35
## Paying a tip
36
37
`Tips.tip_post/1` takes the post, the payer, an amount in sats, and an
38
idempotency key.
39
40
1. The key is unique, so a retry finds the existing intent instead of paying
41
   again. A terminal intent returns as it stands; a `created` intent resumes
42
   payment.
43
2. The payment goes through the `OpenAgents.Forum.Tips.PaymentService`
44
   behavior. The MoneyDevKit and LDK adapter posts the destination, amount, and
45
   idempotency key to the configured wallet service.
46
3. A settlement appends one `settled` receipt, records the payment hash and
47
   fee, and adds the tip to the post and topic totals in one transaction.
48
4. A rejection appends one `failed` receipt, records the failure code, and adds
49
   no ranking weight.
50
5. An unreachable service leaves the intent `created` and pays nothing, so the
51
   same key can retry later.
52
53
A refund appends a `refunded` receipt, keeps the original settlement, sets the
54
counted weight to zero, and removes the tip from both totals.
55
56
## Ranking
57
58
Tips are one bounded, decaying signal beside recency, not a price on
59
visibility:
60
61
- Only settled, non-refunded tips count.
62
- One tip contributes at most 25,000 sats, and one payer contributes at most
63
  25,000 sats to one post.
64
- The score is `ln(1 + counted_sats)` multiplied by an exponential age decay
65
  with a seven-day half-life, so a large tip cannot hold a topic at the top.
66
- Pinned topics still sort first.
67
68
Ordering reads stored totals rather than the payment service, so ranking keeps
69
working through a payment outage. The same read path serves the board whether
70
or not tips are enabled.
71
72
## Anti-manipulation
73
74
The domain records why a tip does not count in `exclusion_reason`, so an
75
excluded tip still pays the recipient and still shows in the gross total:
76
77
| Reason | Rule |
78
| --- | --- |
79
| `self_tip` | A payer tipping its own post gains no rank |
80
| `reciprocal` | A tip that returns value within 30 days stops counting |
81
| `payer_cap` | One payer's counted weight per post is capped |
82
| `rate_limited` | More than 20 settled tips in an hour from one payer stop counting |
83
| `refunded` | A refunded tip loses its weight |
84
85
Moderation stays independent of money. Hiding or deleting a post removes its
86
weight from the topic's ranking and never moves, holds, or reverses funds. The
87
receipts stay as they were.
88
89
## Configuration
90
91
Tips are off by default: the fallback adapter refuses every payment while reads
92
and ranking keep working. Set `FORUM_TIPS_WALLET_URL`, and optionally
93
`FORUM_TIPS_WALLET_TOKEN`, to admit the MoneyDevKit and LDK adapter.
94
95
## Related documentation
96
97
- [Forum port architecture](forum-port.md)
98
- [API endpoints](openagents-cli/api.md)
99
- [Vocabulary](taxonomy.md)
docs/openagents-cli/api.md modified +12

@@ -234,6 +234,17 @@ topic, and a hidden or deleted post never appear in a response to an

234 234
unauthorized caller: the board and the topic answer `404`, and the post is
235 235
absent from the thread.
236 236
237
| Method | Path | Purpose |
238
| --- | --- | --- |
239
| `POST` | `/forum/tips/destination` | Attach a payment destination: `kind`, `destination`, `label` |
240
| `PATCH` | `/forum/tips/destination` | Change `accepting_tips` or retire the destination |
241
| `GET` | `/forum/tips/destination` | Read the caller's destination kind and fingerprint |
242
| `POST` | `/forum/posts/:post_id/tips` | Tip a post: `amount_sats`, `idempotency_key` |
243
| `GET` | `/forum/tips/received` | Export the caller's settlements for a self-custodial wallet |
244
245
Tip responses never repeat the destination itself, only its kind and a
246
fingerprint. See [Bitcoin tips and weighted ranking](../forum-bitcoin-tips.md).
247
237 248
```sh
238 249
openagents api "forum/topics?forum=general"
239 250
openagents api "forum/topics?q=router+latency"

@@ -252,3 +263,4 @@ printf '%s' '{"status":"linked"}' |

252 263
- [CLI command reference](command-reference.md)
253 264
- [Install the CLI](install.md)
254 265
- [REST API assessment](../github-api-issues-projects-assessment.md)
266
- [Bitcoin tips and weighted ranking](../forum-bitcoin-tips.md)
lib/openagents/forum.ex modified +55 -6

@@ -11,9 +11,11 @@ defmodule OpenAgents.Forum do

11 11
  import Ecto.Query, warn: false
12 12
13 13
  alias Ecto.Multi
14
  alias OpenAgents.Forum.{ActorLink, Forum, Post, Topic}
14
  alias OpenAgents.Forum.{ActorLink, Forum, Post, Tips, Topic}
15 15
  alias OpenAgents.Repo
16 16
17
  @ranking_half_life_seconds 7 * 24 * 3600
18
17 19
  @topics_per_page 25
18 20
  @posts_per_page 50
19 21
  @maximum_page 10_000

@@ -79,22 +81,59 @@ defmodule OpenAgents.Forum do

79 81
80 82
  ## Topics
81 83
84
  @doc """
85
  One page of topics in a board.
86
87
  Pass `order: :ranked` to weigh settled tips alongside recency. Ranking reads
88
  stored settlement totals only, so it returns the same order whether or not a
89
  payment service is reachable.
90
  """
82 91
  def list_topics(%Forum{id: forum_id}, opts \\ []) when is_list(opts) do
83 92
    page = parse_page(opts[:page])
84 93
85 94
    from(t in Topic,
86 95
      where: t.forum_id == ^forum_id and is_nil(t.archived_at),
87
      order_by: [
88
        desc: t.pin_state,
89
        desc: t.updated_at,
90
        desc: t.id
91
      ],
92 96
      limit: ^@topics_per_page,
93 97
      offset: ^((page - 1) * @topics_per_page)
94 98
    )
99
    |> order_topics(opts[:order])
95 100
    |> Repo.all()
96 101
  end
97 102
103
  defp order_topics(query, :ranked) do
104
    order_by(
105
      query,
106
      ^[
107
        desc: dynamic([t], t.pin_state),
108
        desc: ranking_score(),
109
        desc: dynamic([t], t.updated_at),
110
        desc: dynamic([t], t.id)
111
      ]
112
    )
113
  end
114
115
  defp order_topics(query, _order) do
116
    order_by(query, [t], desc: t.pin_state, desc: t.updated_at, desc: t.id)
117
  end
118
119
  # Recency plus a bounded, decaying tip term. `ln` keeps a large tip from
120
  # dominating a board, and the exponential decay means sats buy attention for
121
  # about a week rather than forever.
122
  defp ranking_score do
123
    dynamic(
124
      [t],
125
      fragment(
126
        "ln(1 + ?::float) * exp(- EXTRACT(EPOCH FROM (now() - ?)) / ?)",
127
        t.tip_sats_counted,
128
        t.updated_at,
129
        ^@ranking_half_life_seconds
130
      )
131
    )
132
  end
133
134
  @doc "How long a settled tip keeps most of its ranking weight, in seconds."
135
  def ranking_half_life_seconds, do: @ranking_half_life_seconds
136
98 137
  def count_topics(%Forum{id: forum_id}) do
99 138
    Repo.one!(
100 139
      from t in Topic,

@@ -224,6 +263,14 @@ defmodule OpenAgents.Forum do

224 263
    )
225 264
  end
226 265
266
  @doc "One post by id, or `nil` when the id is unknown or malformed."
267
  def get_post(id) when is_binary(id) do
268
    case Ecto.UUID.cast(id) do
269
      {:ok, uuid} -> Repo.get(Post, uuid)
270
      :error -> nil
271
    end
272
  end
273
227 274
  @doc "The next post number in a topic."
228 275
  def next_post_number(topic_id) do
229 276
    Repo.one!(

@@ -324,6 +371,7 @@ defmodule OpenAgents.Forum do

324 371
325 372
    case result do
326 373
      {:ok, _} ->
374
        Tips.withdraw_post_weight(post)
327 375
        audit_moderation("forum.post.deleted", moderator, post)
328 376
329 377
      _ ->

@@ -341,6 +389,7 @@ defmodule OpenAgents.Forum do

341 389
342 390
    case result do
343 391
      {:ok, _} ->
392
        Tips.withdraw_post_weight(post)
344 393
        audit_moderation("forum.post.hidden", moderator, post)
345 394
346 395
      _ ->
lib/openagents/forum/post.ex modified +6

@@ -20,6 +20,12 @@ defmodule OpenAgents.Forum.Post do

20 20
    field :state, :string, default: "visible"
21 21
    field :archived_at, :utc_datetime_usec
22 22
23
    # Settlement facts, not a vote count. `tip_sats_counted` is the part
24
    # ranking may use after the tipping policy has bounded it.
25
    field :tip_sats_total, :integer, default: 0
26
    field :tip_sats_counted, :integer, default: 0
27
    field :tip_count, :integer, default: 0
28
23 29
    belongs_to :topic, Topic, type: :binary_id
24 30
25 31
    belongs_to :parent_post, {"forum_posts", __MODULE__},
lib/openagents/forum/tip_destination.ex added +114

@@ -0,0 +1,114 @@

1
defmodule OpenAgents.Forum.TipDestination do
2
  @moduledoc """
3
  Where an account wants tips to arrive.
4
5
  The row records a destination the account controls — a Bolt 12 offer, an
6
  LNURL address, or an on-chain address — plus a fingerprint the owner can
7
  compare against their own wallet. It never records a key, a seed, a channel,
8
  or a node credential, so the forum can route sats without being able to hold
9
  or spend them.
10
  """
11
12
  use Ecto.Schema
13
  import Ecto.Changeset
14
15
  alias OpenAgents.Accounts.User
16
17
  @kinds ["bolt12", "lnurl", "onchain"]
18
  @states ["active", "retired"]
19
  @maximum_destination_bytes 2048
20
21
  @primary_key {:id, :binary_id, autogenerate: true}
22
  schema "forum_tip_destinations" do
23
    field :kind, :string
24
    field :destination, :string
25
    field :fingerprint, :string
26
    field :label, :string
27
28
    field :state, :string, default: "active"
29
    field :accepting_tips, :boolean, default: true
30
    field :retired_at, :utc_datetime_usec
31
32
    belongs_to :user, User, type: :binary_id
33
34
    timestamps(type: :utc_datetime_usec)
35
  end
36
37
  def kinds, do: @kinds
38
39
  def changeset(destination, attrs) do
40
    destination
41
    |> cast(attrs, [
42
      :user_id,
43
      :kind,
44
      :destination,
45
      :label,
46
      :state,
47
      :accepting_tips,
48
      :retired_at
49
    ])
50
    |> update_change(:destination, &String.trim/1)
51
    |> validate_required([:user_id, :kind, :destination])
52
    |> validate_inclusion(:kind, @kinds)
53
    |> validate_inclusion(:state, @states)
54
    |> validate_length(:destination, min: 8, max: @maximum_destination_bytes)
55
    |> validate_length(:label, max: 80)
56
    |> validate_destination_shape()
57
    |> put_fingerprint()
58
    |> unique_constraint(:user_id,
59
      name: :forum_tip_destinations_one_active_per_user_index,
60
      message: "already has an active destination"
61
    )
62
  end
63
64
  @doc """
65
  A stable, non-reversible name for a destination.
66
67
  The owner can match it against their wallet without the forum publishing
68
  where the sats go.
69
  """
70
  @spec fingerprint(String.t()) :: String.t()
71
  def fingerprint(destination) when is_binary(destination) do
72
    :crypto.hash(:sha256, "openagents.forum.tip_destination.v1:" <> destination)
73
    |> Base.encode16(case: :lower)
74
    |> binary_part(0, 16)
75
  end
76
77
  defp put_fingerprint(changeset) do
78
    case get_field(changeset, :destination) do
79
      value when is_binary(value) -> put_change(changeset, :fingerprint, fingerprint(value))
80
      _missing -> changeset
81
    end
82
  end
83
84
  defp validate_destination_shape(changeset) do
85
    kind = get_field(changeset, :kind)
86
    value = get_field(changeset, :destination)
87
88
    cond do
89
      not is_binary(kind) or not is_binary(value) ->
90
        changeset
91
92
      String.match?(value, ~r/\s/) ->
93
        add_error(changeset, :destination, "must not contain whitespace")
94
95
      valid_shape?(kind, value) ->
96
        changeset
97
98
      true ->
99
        add_error(changeset, :destination, "does not look like a #{kind} destination")
100
    end
101
  end
102
103
  defp valid_shape?("bolt12", value), do: String.match?(value, ~r/\Alno1[a-z0-9]+\z/i)
104
105
  defp valid_shape?("lnurl", value) do
106
    String.match?(value, ~r/\A[^@\s]+@[a-z0-9.-]+\.[a-z]{2,}\z/i) or
107
      String.match?(value, ~r/\Alnurl1[a-z0-9]+\z/i)
108
  end
109
110
  defp valid_shape?("onchain", value),
111
    do: String.match?(value, ~r/\A(bc1[a-z0-9]{20,}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})\z/)
112
113
  defp valid_shape?(_kind, _value), do: false
114
end
lib/openagents/forum/tip_intent.ex added +68

@@ -0,0 +1,68 @@

1
defmodule OpenAgents.Forum.TipIntent do
2
  @moduledoc """
3
  One account's intent to send sats to one post.
4
5
  An intent carries the payment's own state and, separately, the `counted_sats`
6
  that ranking may use. The tipping policy sets `counted_sats` once, at
7
  settlement; a refund returns it to zero. A paid tip that ranking must ignore
8
  is therefore still a complete payment record.
9
  """
10
11
  use Ecto.Schema
12
  import Ecto.Changeset
13
14
  alias OpenAgents.Accounts.User
15
  alias OpenAgents.Forum.{Post, TipDestination, Topic}
16
17
  @states ["created", "settled", "failed", "refunded"]
18
  @exclusion_reasons ["self_tip", "reciprocal", "payer_cap", "rate_limited", "refunded"]
19
  @maximum_amount_sats 1_000_000
20
21
  @primary_key {:id, :binary_id, autogenerate: true}
22
  schema "forum_tip_intents" do
23
    field :idempotency_key, :string
24
    field :amount_sats, :integer
25
    field :counted_sats, :integer, default: 0
26
    field :exclusion_reason, :string
27
28
    field :payer_actor_ref, :string
29
    field :state, :string, default: "created"
30
    field :failure_code, :string
31
    field :settled_at, :utc_datetime_usec
32
    field :failed_at, :utc_datetime_usec
33
    field :refunded_at, :utc_datetime_usec
34
35
    belongs_to :post, Post, type: :binary_id
36
    belongs_to :topic, Topic, type: :binary_id
37
    belongs_to :payer_user, User, type: :binary_id
38
    belongs_to :recipient_user, User, type: :binary_id
39
    belongs_to :destination, TipDestination, type: :binary_id
40
41
    timestamps(type: :utc_datetime_usec)
42
  end
43
44
  def maximum_amount_sats, do: @maximum_amount_sats
45
46
  def exclusion_reasons, do: @exclusion_reasons
47
48
  def changeset(intent, attrs) do
49
    intent
50
    |> cast(attrs, [
51
      :idempotency_key,
52
      :amount_sats,
53
      :payer_actor_ref,
54
      :state,
55
      :counted_sats,
56
      :exclusion_reason
57
    ])
58
    |> validate_required([:idempotency_key, :amount_sats, :payer_actor_ref])
59
    |> validate_number(:amount_sats,
60
      greater_than: 0,
61
      less_than_or_equal_to: @maximum_amount_sats
62
    )
63
    |> validate_inclusion(:state, @states)
64
    |> validate_inclusion(:exclusion_reason, @exclusion_reasons)
65
    |> unique_constraint(:idempotency_key)
66
    |> check_constraint(:amount_sats, name: :forum_tip_intents_amount_check)
67
  end
68
end
lib/openagents/forum/tip_receipt.ex added +51

@@ -0,0 +1,51 @@

1
defmodule OpenAgents.Forum.TipReceipt do
2
  @moduledoc """
3
  The append-only record of what happened to one tip.
4
5
  A settled receipt carries the payment hash, which is what lets the recipient
6
  find the same payment in their own wallet. A database trigger refuses updates
7
  and deletes, and one receipt kind per intent means a retried settlement
8
  cannot append a second one.
9
  """
10
11
  use Ecto.Schema
12
  import Ecto.Changeset
13
14
  alias OpenAgents.Forum.TipIntent
15
16
  @kinds ["settled", "failed", "refunded"]
17
18
  @primary_key {:id, :binary_id, autogenerate: true}
19
  schema "forum_tip_receipts" do
20
    field :kind, :string
21
    field :amount_sats, :integer
22
    field :fee_sats, :integer, default: 0
23
    field :payment_hash, :string
24
    field :failure_code, :string
25
    field :occurred_at, :utc_datetime_usec
26
27
    belongs_to :intent, TipIntent, type: :binary_id
28
29
    timestamps(type: :utc_datetime_usec, updated_at: false)
30
  end
31
32
  def kinds, do: @kinds
33
34
  def changeset(receipt, attrs) do
35
    receipt
36
    |> cast(attrs, [
37
      :intent_id,
38
      :kind,
39
      :amount_sats,
40
      :fee_sats,
41
      :payment_hash,
42
      :failure_code,
43
      :occurred_at
44
    ])
45
    |> validate_required([:intent_id, :kind, :amount_sats, :occurred_at])
46
    |> validate_inclusion(:kind, @kinds)
47
    |> validate_length(:payment_hash, max: 128)
48
    |> validate_length(:failure_code, max: 64)
49
    |> unique_constraint([:intent_id, :kind])
50
  end
51
end
lib/openagents/forum/tips.ex added +567

@@ -0,0 +1,567 @@

1
defmodule OpenAgents.Forum.Tips do
2
  @moduledoc """
3
  Self-custodial Bitcoin tips on forum posts.
4
5
  Three facts stay separate here:
6
7
  1. **Where sats go** — a destination the recipient supplied and controls.
8
     The forum stores an offer, address, or Lightning address, never a key,
9
     seed, channel, or node credential, so it cannot hold or spend a tip.
10
  2. **What a payment did** — an intent plus an append-only receipt. A tip's
11
     `idempotency_key` is unique, so a retry returns the first result instead
12
     of paying twice.
13
  3. **What ranking may use** — `counted_sats`, set once at settlement by the
14
     policy in this module and returned to zero by a refund. A self-tip, a
15
     reciprocal tip, a tip past a payer's cap, and a burst of automated tips
16
     all settle normally and count nothing.
17
18
  Ranking reads only stored settlement facts, so an unavailable payment
19
  service stops new tips and changes nothing about what is already ranked.
20
  """
21
22
  import Ecto.Query, warn: false
23
24
  alias Ecto.Multi
25
  alias OpenAgents.Forum
26
  alias OpenAgents.Forum.{Post, TipDestination, TipIntent, TipReceipt, Topic}
27
  alias OpenAgents.Forum.Tips.PaymentService
28
  alias OpenAgents.Repo
29
30
  # A single tip can carry at most this much ranking weight, however large the
31
  # payment. Sats buy attention with diminishing returns, not a ranking bypass.
32
  @counted_sats_per_tip 25_000
33
34
  # And one payer can carry at most this much weight on one post, across tips.
35
  @counted_sats_per_payer_post 25_000
36
37
  # More settled tips than this from one payer within the window reads as
38
  # automation rather than judgment, so the excess counts nothing.
39
  @payer_burst_limit 20
40
  @payer_burst_window_seconds 3600
41
42
  # Two accounts tipping each other inside this window is treated as circular,
43
  # so the return tip carries no weight.
44
  @reciprocal_window_seconds 30 * 24 * 3600
45
46
  def counted_sats_per_tip, do: @counted_sats_per_tip
47
  def maximum_amount_sats, do: TipIntent.maximum_amount_sats()
48
49
  ## Destinations
50
51
  @doc "The destination an account currently receives tips at, if any."
52
  @spec active_destination(binary() | nil) :: TipDestination.t() | nil
53
  def active_destination(nil), do: nil
54
55
  def active_destination(user_id) when is_binary(user_id) do
56
    Repo.one(
57
      from d in TipDestination,
58
        where: d.user_id == ^user_id and d.state == "active",
59
        limit: 1
60
    )
61
  end
62
63
  @doc """
64
  Records where an account wants tips to arrive.
65
66
  Registering a new destination retires the previous one in the same
67
  transaction, so an account always has at most one active destination and the
68
  history of what it was stays intact.
69
  """
70
  @spec register_destination(map()) ::
71
          {:ok, TipDestination.t()} | {:error, Ecto.Changeset.t()}
72
  def register_destination(attrs) when is_map(attrs) do
73
    user_id = attrs[:user_id] || attrs["user_id"]
74
75
    Multi.new()
76
    |> Multi.run(:retire_previous, fn _repo, _changes ->
77
      case active_destination(user_id) do
78
        nil -> {:ok, nil}
79
        current -> retire_destination(current)
80
      end
81
    end)
82
    |> Multi.insert(:destination, TipDestination.changeset(%TipDestination{}, attrs))
83
    |> Repo.transaction()
84
    |> case do
85
      {:ok, %{destination: destination}} -> {:ok, destination}
86
      {:error, _step, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset}
87
    end
88
  end
89
90
  @doc "Stops using a destination without touching the tips it already received."
91
  @spec retire_destination(TipDestination.t()) ::
92
          {:ok, TipDestination.t()} | {:error, Ecto.Changeset.t()}
93
  def retire_destination(%TipDestination{} = destination) do
94
    destination
95
    |> TipDestination.changeset(%{
96
      state: "retired",
97
      accepting_tips: false,
98
      retired_at: DateTime.utc_now()
99
    })
100
    |> Repo.update()
101
  end
102
103
  @doc "Opts an account in or out of receiving tips, keeping its destination."
104
  @spec set_accepting_tips(TipDestination.t(), boolean()) ::
105
          {:ok, TipDestination.t()} | {:error, Ecto.Changeset.t()}
106
  def set_accepting_tips(%TipDestination{} = destination, accepting?)
107
      when is_boolean(accepting?) do
108
    destination
109
    |> TipDestination.changeset(%{accepting_tips: accepting?})
110
    |> Repo.update()
111
  end
112
113
  @doc """
114
  Whether a post can currently be tipped, and why not when it cannot.
115
  """
116
  @spec tip_availability(Post.t()) ::
117
          {:ok, TipDestination.t()}
118
          | {:error,
119
             :tipping_disabled | :post_not_visible | :no_destination | :not_accepting_tips}
120
  def tip_availability(%Post{} = post) do
121
    cond do
122
      not PaymentService.enabled?() ->
123
        {:error, :tipping_disabled}
124
125
      post.state != "visible" ->
126
        {:error, :post_not_visible}
127
128
      true ->
129
        case recipient_destination(post) do
130
          nil -> {:error, :no_destination}
131
          %TipDestination{accepting_tips: false} -> {:error, :not_accepting_tips}
132
          %TipDestination{} = destination -> {:ok, destination}
133
        end
134
    end
135
  end
136
137
  defp recipient_destination(%Post{} = post) do
138
    case Forum.actor_user(post.actor_ref) do
139
      nil -> nil
140
      user -> active_destination(user.id)
141
    end
142
  end
143
144
  ## Tips
145
146
  @doc """
147
  Sends sats from one account to the author of a post.
148
149
  The call is idempotent on `idempotency_key`: a retry returns the intent that
150
  already exists, so a repeated request cannot pay twice or count twice. When
151
  the payment service is unreachable the intent stays `created` and the same
152
  key can retry it later.
153
  """
154
  @spec tip_post(map()) ::
155
          {:ok, TipIntent.t()}
156
          | {:error,
157
             :tipping_disabled
158
             | :post_not_visible
159
             | :no_destination
160
             | :not_accepting_tips
161
             | :payment_service_unavailable
162
             | Ecto.Changeset.t()}
163
  def tip_post(%{
164
        post: %Post{} = post,
165
        payer_user: payer_user,
166
        payer_actor_ref: payer_actor_ref,
167
        amount_sats: amount_sats,
168
        idempotency_key: idempotency_key
169
      })
170
      when is_binary(payer_actor_ref) and is_binary(idempotency_key) do
171
    case get_intent_by_key(idempotency_key) do
172
      %TipIntent{} = existing ->
173
        resume(existing)
174
175
      nil ->
176
        with {:ok, destination} <- tip_availability(post),
177
             {:ok, intent} <-
178
               create_intent(post, payer_user, payer_actor_ref, amount_sats, destination,
179
                 idempotency_key: idempotency_key
180
               ) do
181
          pay(intent, destination)
182
        end
183
    end
184
  end
185
186
  @doc "One tip by its idempotency key."
187
  @spec get_intent_by_key(String.t()) :: TipIntent.t() | nil
188
  def get_intent_by_key(idempotency_key) when is_binary(idempotency_key),
189
    do: Repo.get_by(TipIntent, idempotency_key: idempotency_key)
190
191
  @doc "The receipts recorded for one tip, oldest first."
192
  @spec list_receipts(TipIntent.t()) :: [TipReceipt.t()]
193
  def list_receipts(%TipIntent{id: intent_id}) do
194
    Repo.all(
195
      from r in TipReceipt, where: r.intent_id == ^intent_id, order_by: [asc: r.occurred_at]
196
    )
197
  end
198
199
  # A retry never re-pays a tip that already reached a terminal state.
200
  defp resume(%TipIntent{state: "created"} = intent) do
201
    destination = Repo.get!(TipDestination, intent.destination_id)
202
203
    if destination.state == "active" and destination.accepting_tips do
204
      pay(intent, destination)
205
    else
206
      {:error, :not_accepting_tips}
207
    end
208
  end
209
210
  defp resume(%TipIntent{} = intent), do: {:ok, intent}
211
212
  defp create_intent(post, payer_user, payer_actor_ref, amount_sats, destination, opts) do
213
    recipient_user = Forum.actor_user(post.actor_ref)
214
215
    %TipIntent{
216
      post_id: post.id,
217
      topic_id: post.topic_id,
218
      payer_user_id: payer_user.id,
219
      recipient_user_id: recipient_user.id,
220
      destination_id: destination.id
221
    }
222
    |> TipIntent.changeset(%{
223
      idempotency_key: opts[:idempotency_key],
224
      payer_actor_ref: payer_actor_ref,
225
      amount_sats: amount_sats,
226
      state: "created"
227
    })
228
    |> Repo.insert()
229
    |> case do
230
      {:ok, intent} ->
231
        {:ok, intent}
232
233
      {:error, changeset} ->
234
        # Two concurrent retries: whichever lost the unique index reads the row.
235
        case get_intent_by_key(opts[:idempotency_key]) do
236
          %TipIntent{} = existing -> {:ok, existing}
237
          nil -> {:error, changeset}
238
        end
239
    end
240
  end
241
242
  defp pay(%TipIntent{} = intent, %TipDestination{} = destination) do
243
    request = %{
244
      kind: destination.kind,
245
      destination: destination.destination,
246
      amount_sats: intent.amount_sats,
247
      idempotency_key: intent.idempotency_key
248
    }
249
250
    case PaymentService.pay(request) do
251
      {:ok, settlement} -> settle(intent, settlement)
252
      {:error, {:payment_failed, failure_code}} -> fail(intent, failure_code)
253
      {:error, :payment_service_unavailable} -> {:error, :payment_service_unavailable}
254
    end
255
  end
256
257
  ## Payment state
258
259
  @doc """
260
  Records a settled payment and the ranking weight it earned.
261
262
  The receipt is written once per intent, so a duplicate settlement leaves both
263
  the receipt and the post totals alone.
264
  """
265
  @spec settle(TipIntent.t(), map()) :: {:ok, TipIntent.t()} | {:error, term()}
266
  def settle(%TipIntent{state: "created"} = intent, settlement) do
267
    settled_at = Map.get(settlement, :settled_at) || DateTime.utc_now()
268
    counted = counted_sats(intent, settled_at)
269
270
    Multi.new()
271
    |> Multi.insert(
272
      :receipt,
273
      TipReceipt.changeset(%TipReceipt{}, %{
274
        intent_id: intent.id,
275
        kind: "settled",
276
        amount_sats: intent.amount_sats,
277
        fee_sats: Map.get(settlement, :fee_sats, 0),
278
        payment_hash: Map.get(settlement, :payment_hash),
279
        occurred_at: settled_at
280
      })
281
    )
282
    |> Multi.update(
283
      :intent,
284
      TipIntent.changeset(intent, %{
285
        state: "settled",
286
        counted_sats: counted.sats,
287
        exclusion_reason: counted.exclusion_reason
288
      })
289
      |> Ecto.Changeset.put_change(:settled_at, settled_at)
290
    )
291
    |> add_totals(intent, intent.amount_sats, counted.sats, 1)
292
    |> Repo.transaction()
293
    |> case do
294
      {:ok, %{intent: settled}} -> {:ok, settled}
295
      {:error, _step, reason, _changes} -> {:error, reason}
296
    end
297
  end
298
299
  def settle(%TipIntent{} = intent, _settlement), do: {:ok, intent}
300
301
  @doc "Records a payment that did not go through. A failed tip counts nothing."
302
  @spec fail(TipIntent.t(), String.t()) ::
303
          {:error, {:payment_failed, TipIntent.t()}} | {:error, term()}
304
  def fail(%TipIntent{state: "created"} = intent, failure_code) when is_binary(failure_code) do
305
    failed_at = DateTime.utc_now()
306
307
    Multi.new()
308
    |> Multi.insert(
309
      :receipt,
310
      TipReceipt.changeset(%TipReceipt{}, %{
311
        intent_id: intent.id,
312
        kind: "failed",
313
        amount_sats: intent.amount_sats,
314
        failure_code: failure_code,
315
        occurred_at: failed_at
316
      })
317
    )
318
    |> Multi.update(
319
      :intent,
320
      TipIntent.changeset(intent, %{state: "failed"})
321
      |> Ecto.Changeset.put_change(:failure_code, failure_code)
322
      |> Ecto.Changeset.put_change(:failed_at, failed_at)
323
    )
324
    |> Repo.transaction()
325
    |> case do
326
      {:ok, %{intent: failed}} -> {:error, {:payment_failed, failed}}
327
      {:error, _step, reason, _changes} -> {:error, reason}
328
    end
329
  end
330
331
  def fail(%TipIntent{} = intent, _failure_code), do: {:error, {:payment_failed, intent}}
332
333
  @doc """
334
  Records a refund and removes the tip's ranking weight.
335
336
  The settled receipt stays; a refund appends its own. Totals lose both the
337
  gross amount and whatever weight the tip carried.
338
  """
339
  @spec refund(TipIntent.t(), String.t()) :: {:ok, TipIntent.t()} | {:error, term()}
340
  def refund(intent, failure_code \\ "refunded")
341
342
  def refund(%TipIntent{state: "settled"} = intent, failure_code) do
343
    refunded_at = DateTime.utc_now()
344
345
    Multi.new()
346
    |> Multi.insert(
347
      :receipt,
348
      TipReceipt.changeset(%TipReceipt{}, %{
349
        intent_id: intent.id,
350
        kind: "refunded",
351
        amount_sats: intent.amount_sats,
352
        failure_code: failure_code,
353
        occurred_at: refunded_at
354
      })
355
    )
356
    |> Multi.update(
357
      :intent,
358
      TipIntent.changeset(intent, %{
359
        state: "refunded",
360
        counted_sats: 0,
361
        exclusion_reason: "refunded"
362
      })
363
      |> Ecto.Changeset.put_change(:refunded_at, refunded_at)
364
    )
365
    |> add_totals(intent, -intent.amount_sats, -intent.counted_sats, -1)
366
    |> Repo.transaction()
367
    |> case do
368
      {:ok, %{intent: refunded}} -> {:ok, refunded}
369
      {:error, _step, reason, _changes} -> {:error, reason}
370
    end
371
  end
372
373
  def refund(%TipIntent{state: state}, _failure_code), do: {:error, {:not_refundable, state}}
374
375
  ## Ranking weight
376
377
  # How much ranking weight a settled tip earns. Everything that makes a tip
378
  # unusable as a signal is decided here, once, and recorded on the intent.
379
  defp counted_sats(%TipIntent{} = intent, settled_at) do
380
    cond do
381
      intent.payer_user_id == intent.recipient_user_id ->
382
        %{sats: 0, exclusion_reason: "self_tip"}
383
384
      reciprocal?(intent, settled_at) ->
385
        %{sats: 0, exclusion_reason: "reciprocal"}
386
387
      payer_burst?(intent, settled_at) ->
388
        %{sats: 0, exclusion_reason: "rate_limited"}
389
390
      true ->
391
        headroom = payer_post_headroom(intent)
392
        sats = Enum.min([intent.amount_sats, @counted_sats_per_tip, headroom])
393
394
        if sats > 0 do
395
          %{sats: sats, exclusion_reason: nil}
396
        else
397
          %{sats: 0, exclusion_reason: "payer_cap"}
398
        end
399
    end
400
  end
401
402
  # The recipient already paid this payer recently, so the sats went in a
403
  # circle and say nothing about the post.
404
  defp reciprocal?(%TipIntent{} = intent, settled_at) do
405
    since = DateTime.add(settled_at, -@reciprocal_window_seconds, :second)
406
407
    Repo.exists?(
408
      from i in TipIntent,
409
        where:
410
          i.payer_user_id == ^intent.recipient_user_id and
411
            i.recipient_user_id == ^intent.payer_user_id and
412
            i.state == "settled" and i.settled_at >= ^since
413
    )
414
  end
415
416
  defp payer_burst?(%TipIntent{} = intent, settled_at) do
417
    since = DateTime.add(settled_at, -@payer_burst_window_seconds, :second)
418
419
    recent =
420
      Repo.one!(
421
        from i in TipIntent,
422
          select: count(),
423
          where:
424
            i.payer_user_id == ^intent.payer_user_id and i.state == "settled" and
425
              i.settled_at >= ^since
426
      )
427
428
    recent >= @payer_burst_limit
429
  end
430
431
  defp payer_post_headroom(%TipIntent{} = intent) do
432
    counted =
433
      Repo.one!(
434
        from i in TipIntent,
435
          select: type(coalesce(sum(i.counted_sats), 0), :integer),
436
          where:
437
            i.post_id == ^intent.post_id and i.payer_user_id == ^intent.payer_user_id and
438
              i.state == "settled" and i.id != ^intent.id
439
      )
440
441
    max(@counted_sats_per_payer_post - counted, 0)
442
  end
443
444
  defp add_totals(multi, %TipIntent{} = intent, amount_sats, counted_sats, count) do
445
    multi
446
    |> Multi.update_all(
447
      :post_totals,
448
      fn _changes ->
449
        from p in Post, where: p.id == ^intent.post_id
450
      end,
451
      inc: [
452
        tip_sats_total: amount_sats,
453
        tip_sats_counted: counted_sats,
454
        tip_count: count
455
      ]
456
    )
457
    |> Multi.update_all(
458
      :topic_totals,
459
      fn _changes ->
460
        from t in Topic, where: t.id == ^intent.topic_id
461
      end,
462
      inc: [
463
        tip_sats_total: amount_sats,
464
        tip_sats_counted: counted_sats,
465
        tip_count: count
466
      ]
467
    )
468
  end
469
470
  @doc """
471
  Removes a post's ranking weight from its topic after moderation.
472
473
  A hidden or deleted post keeps its own totals, so nothing about the payments
474
  is rewritten, but its sats stop lifting the topic. Moderators need no access
475
  to funds to do this.
476
  """
477
  @spec withdraw_post_weight(Post.t()) :: :ok
478
  def withdraw_post_weight(%Post{} = post) do
479
    if post.tip_sats_counted > 0 do
480
      Repo.update_all(
481
        from(t in Topic, where: t.id == ^post.topic_id),
482
        inc: [tip_sats_counted: -post.tip_sats_counted]
483
      )
484
    end
485
486
    :ok
487
  end
488
489
  ## Recipient views
490
491
  @doc """
492
  What one account received, as the receipts a recipient can check.
493
494
  Each entry carries the payment hash from the settled receipt, which is what
495
  the recipient looks up in their own wallet to confirm the sats arrived.
496
  """
497
  @spec list_received(binary(), keyword()) :: [map()]
498
  def list_received(user_id, opts \\ []) when is_binary(user_id) do
499
    limit = min(Keyword.get(opts, :limit, 100), 500)
500
501
    Repo.all(
502
      from i in TipIntent,
503
        left_join: r in TipReceipt,
504
        on: r.intent_id == i.id and r.kind == "settled",
505
        where: i.recipient_user_id == ^user_id and i.state in ["settled", "refunded"],
506
        order_by: [desc: i.settled_at],
507
        limit: ^limit,
508
        select: %{
509
          intent_id: i.id,
510
          post_id: i.post_id,
511
          topic_id: i.topic_id,
512
          amount_sats: i.amount_sats,
513
          counted_sats: i.counted_sats,
514
          state: i.state,
515
          settled_at: i.settled_at,
516
          refunded_at: i.refunded_at,
517
          payment_hash: r.payment_hash,
518
          fee_sats: r.fee_sats
519
        }
520
    )
521
  end
522
523
  @doc """
524
  The totals an account received, for a self-custodial withdrawal.
525
526
  The forum holds nothing to withdraw. The export tells a recipient which
527
  settlements to expect in the wallet they control, and at which destination
528
  fingerprint they arrived.
529
  """
530
  @spec withdrawal_export(binary()) :: map()
531
  def withdrawal_export(user_id) when is_binary(user_id) do
532
    settled = list_received(user_id, limit: 500)
533
534
    received_sats =
535
      settled
536
      |> Enum.filter(&(&1.state == "settled"))
537
      |> Enum.map(& &1.amount_sats)
538
      |> Enum.sum()
539
540
    refunded_sats =
541
      settled
542
      |> Enum.filter(&(&1.state == "refunded"))
543
      |> Enum.map(& &1.amount_sats)
544
      |> Enum.sum()
545
546
    %{
547
      custody: "self",
548
      destination_fingerprint:
549
        case active_destination(user_id) do
550
          nil -> nil
551
          destination -> destination.fingerprint
552
        end,
553
      received_sats: received_sats,
554
      refunded_sats: refunded_sats,
555
      settlements:
556
        Enum.map(settled, fn entry ->
557
          %{
558
            post_id: entry.post_id,
559
            amount_sats: entry.amount_sats,
560
            state: entry.state,
561
            payment_hash: entry.payment_hash,
562
            settled_at: entry.settled_at
563
          }
564
        end)
565
    }
566
  end
567
end
lib/openagents/forum/tips/payment_service.ex added +56

@@ -0,0 +1,56 @@

1
defmodule OpenAgents.Forum.Tips.PaymentService do
2
  @moduledoc """
3
  The boundary between the forum and the wallet software that moves sats.
4
5
  The forum hands a request to an admitted payment service and stores what came
6
  back. It never holds funds, so a service that is missing or unreachable can
7
  only stop new tips from settling; totals, receipts, and ranking keep working
8
  from what already settled.
9
  """
10
11
  @type request :: %{
12
          kind: String.t(),
13
          destination: String.t(),
14
          amount_sats: pos_integer(),
15
          idempotency_key: String.t()
16
        }
17
18
  @type settlement :: %{
19
          payment_hash: String.t(),
20
          fee_sats: non_neg_integer(),
21
          settled_at: DateTime.t()
22
        }
23
24
  @doc """
25
  Pays one request.
26
27
  The same `idempotency_key` must never move sats twice: an admitted service
28
  either replays its first settlement or refuses the second call.
29
  """
30
  @callback pay(request()) ::
31
              {:ok, settlement()}
32
              | {:error, :payment_service_unavailable}
33
              | {:error, {:payment_failed, String.t()}}
34
35
  @doc "The admitted service for this runtime."
36
  @spec adapter() :: module()
37
  def adapter do
38
    Keyword.get(
39
      Application.get_env(:openagents, :forum_tips, []),
40
      :adapter,
41
      OpenAgents.Forum.Tips.PaymentService.Unavailable
42
    )
43
  end
44
45
  @doc "Whether the forum advertises tipping at all."
46
  @spec enabled?() :: boolean()
47
  def enabled? do
48
    Keyword.get(Application.get_env(:openagents, :forum_tips, []), :enabled, false) == true
49
  end
50
51
  @spec pay(request()) ::
52
          {:ok, settlement()}
53
          | {:error, :payment_service_unavailable}
54
          | {:error, {:payment_failed, String.t()}}
55
  def pay(request), do: adapter().pay(request)
56
end
lib/openagents/forum/tips/payment_service/money_dev_kit.ex added +109

@@ -0,0 +1,109 @@

1
defmodule OpenAgents.Forum.Tips.PaymentService.MoneyDevKit do
2
  @moduledoc """
3
  Pays tips through a MoneyDevKit or LDK wallet service the operator runs.
4
5
  The service holds the wallet; this module only asks it to send an amount to a
6
  destination the recipient supplied, and passes the tip's idempotency key so a
7
  retry cannot send twice. Configure it in `config/runtime.exs`:
8
9
      config :openagents, :forum_tips,
10
        enabled: true,
11
        adapter: OpenAgents.Forum.Tips.PaymentService.MoneyDevKit,
12
        base_url: System.get_env("FORUM_TIPS_WALLET_URL"),
13
        token: System.get_env("FORUM_TIPS_WALLET_TOKEN")
14
15
  Without `base_url` the adapter reports the service as unavailable rather than
16
  guessing an endpoint.
17
  """
18
19
  @behaviour OpenAgents.Forum.Tips.PaymentService
20
21
  require Logger
22
23
  @impl true
24
  def pay(%{destination: destination, amount_sats: amount_sats} = request) do
25
    config = Application.get_env(:openagents, :forum_tips, [])
26
    base_url = config[:base_url]
27
28
    if is_binary(base_url) and base_url != "" do
29
      send_payment(base_url, config, %{
30
        kind: request.kind,
31
        destination: destination,
32
        amount_sats: amount_sats,
33
        idempotency_key: request.idempotency_key
34
      })
35
    else
36
      {:error, :payment_service_unavailable}
37
    end
38
  end
39
40
  defp send_payment(base_url, config, body) do
41
    options =
42
      [
43
        url: String.trim_trailing(base_url, "/") <> "/v1/payments",
44
        json: body,
45
        receive_timeout: Keyword.get(config, :receive_timeout, 30_000),
46
        retry: false
47
      ]
48
      |> put_authorization(config[:token])
49
      |> Keyword.merge(Keyword.get(config, :req_options, []))
50
51
    case Req.post(options) do
52
      {:ok, %Req.Response{status: status, body: response}} when status in 200..299 ->
53
        settlement(response)
54
55
      {:ok, %Req.Response{status: status, body: response}} when status in 400..499 ->
56
        {:error, {:payment_failed, failure_code(response, "rejected_#{status}")}}
57
58
      {:ok, %Req.Response{status: status}} ->
59
        Logger.warning("forum_tip_payment_failed code=unexpected_status status=#{status}")
60
        {:error, :payment_service_unavailable}
61
62
      {:error, transport_error} ->
63
        Logger.warning("forum_tip_payment_failed code=#{transport_code(transport_error)}")
64
        {:error, :payment_service_unavailable}
65
    end
66
  end
67
68
  # Logs carry a transport reason class, never the destination or a response
69
  # body, so a wallet address cannot reach the log stream.
70
  defp transport_code(%{reason: reason}) when is_atom(reason), do: reason
71
72
  defp transport_code(%struct{}),
73
    do: struct |> Module.split() |> List.last() |> Macro.underscore()
74
75
  defp transport_code(_transport_error), do: "unreachable"
76
77
  defp put_authorization(options, token) when is_binary(token) and token != "",
78
    do: Keyword.put(options, :auth, {:bearer, token})
79
80
  defp put_authorization(options, _token), do: options
81
82
  defp settlement(%{"payment_hash" => payment_hash} = response) when is_binary(payment_hash) do
83
    {:ok,
84
     %{
85
       payment_hash: payment_hash,
86
       fee_sats: fee_sats(response["fee_sats"]),
87
       settled_at: settled_at(response["settled_at"])
88
     }}
89
  end
90
91
  defp settlement(response), do: {:error, {:payment_failed, failure_code(response, "no_receipt")}}
92
93
  defp fee_sats(value) when is_integer(value) and value >= 0, do: value
94
  defp fee_sats(_value), do: 0
95
96
  defp settled_at(value) when is_binary(value) do
97
    case DateTime.from_iso8601(value) do
98
      {:ok, settled_at, _offset} -> settled_at
99
      _error -> DateTime.utc_now()
100
    end
101
  end
102
103
  defp settled_at(_value), do: DateTime.utc_now()
104
105
  defp failure_code(%{"failure_code" => code}, _default) when is_binary(code),
106
    do: String.slice(code, 0, 64)
107
108
  defp failure_code(_response, default), do: default
109
end
lib/openagents/forum/tips/payment_service/unavailable.ex added +13

@@ -0,0 +1,13 @@

1
defmodule OpenAgents.Forum.Tips.PaymentService.Unavailable do
2
  @moduledoc """
3
  The default payment service: none.
4
5
  Without an admitted service the forum refuses to start payments rather than
6
  pretending to move sats. Reads, totals, and ranking are unaffected.
7
  """
8
9
  @behaviour OpenAgents.Forum.Tips.PaymentService
10
11
  @impl true
12
  def pay(_request), do: {:error, :payment_service_unavailable}
13
end
lib/openagents/forum/topic.ex modified +6

@@ -22,6 +22,12 @@ defmodule OpenAgents.Forum.Topic do

22 22
    field :latest_post_id, :binary_id
23 23
    field :archived_at, :utc_datetime_usec
24 24
25
    # The topic's share of settled tips. Moderation removes a hidden post's
26
    # weight from `tip_sats_counted`, so ranking follows moderation.
27
    field :tip_sats_total, :integer, default: 0
28
    field :tip_sats_counted, :integer, default: 0
29
    field :tip_count, :integer, default: 0
30
25 31
    belongs_to :forum, Forum, type: :binary_id
26 32
27 33
    has_many :posts, {"forum_posts", OpenAgents.Forum.Post}, foreign_key: :topic_id
lib/openagents_web/api_route_authority.ex modified +7

@@ -110,6 +110,13 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

110 110
      "patch /api/v3/forum/posts/:id" => :required_bearer,
111 111
      "get /api/v3/forum/claims/pending" => :required_bearer,
112 112
      "patch /api/v3/forum/claims/:id" => :required_bearer,
113
      # Tips: a destination, a payment, and a settlement history each belong to
114
      # one account, so anonymous callers never reach them.
115
      "post /api/v3/forum/tips/destination" => :required_bearer,
116
      "patch /api/v3/forum/tips/destination" => :required_bearer,
117
      "get /api/v3/forum/tips/destination" => :required_bearer,
118
      "get /api/v3/forum/tips/received" => :required_bearer,
119
      "post /api/v3/forum/posts/:post_id/tips" => :required_bearer,
113 120
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
114 121
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" => :required_bearer,
115 122
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies" => :required_bearer,
lib/openagents_web/controllers/forum_api_controller.ex modified +118

@@ -16,6 +16,7 @@ defmodule OpenAgentsWeb.ForumApiController do

16 16
17 17
  alias OpenAgents.Accounts
18 18
  alias OpenAgents.Forum
19
  alias OpenAgents.Forum.Tips
19 20
20 21
  def boards(conn, _params) do
21 22
    render(conn, :boards, forums: Forum.list_readable_forums(scope(conn)))

@@ -253,6 +254,112 @@ defmodule OpenAgentsWeb.ForumApiController do

253 254
254 255
  defp review_claim(_link, _status), do: {:error, :invalid_status}
255 256
257
  ## Tips
258
259
  @doc """
260
  Records where the token's account wants tips to arrive.
261
262
  The forum stores a destination the account controls and never a wallet
263
  secret, so it routes sats without being able to hold them.
264
  """
265
  def put_tip_destination(conn, %{"kind" => kind, "destination" => destination} = params) do
266
    attrs = %{
267
      user_id: conn.assigns.current_user.id,
268
      kind: kind,
269
      destination: destination,
270
      label: params["label"],
271
      accepting_tips: params["accepting_tips"] != false
272
    }
273
274
    case Tips.register_destination(attrs) do
275
      {:ok, tip_destination} ->
276
        conn |> put_status(:created) |> render(:tip_destination, destination: tip_destination)
277
278
      {:error, %Ecto.Changeset{} = changeset} ->
279
        conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
280
    end
281
  end
282
283
  def put_tip_destination(conn, _params), do: unprocessable(conn, :destination)
284
285
  def show_tip_destination(conn, _params) do
286
    render(conn, :tip_destination,
287
      destination: Tips.active_destination(conn.assigns.current_user.id)
288
    )
289
  end
290
291
  @doc "Opts the token's account in or out of receiving tips."
292
  def update_tip_destination(conn, %{"accepting_tips" => accepting?})
293
      when is_boolean(accepting?) do
294
    case Tips.active_destination(conn.assigns.current_user.id) do
295
      nil ->
296
        not_found(conn)
297
298
      destination ->
299
        case Tips.set_accepting_tips(destination, accepting?) do
300
          {:ok, updated} ->
301
            render(conn, :tip_destination, destination: updated)
302
303
          {:error, changeset} ->
304
            conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
305
        end
306
    end
307
  end
308
309
  def update_tip_destination(conn, _params), do: unprocessable(conn, :accepting_tips)
310
311
  @doc """
312
  Tips a post in sats.
313
314
  Pass `idempotency_key` to make a retry safe: the same key returns the tip
315
  that already exists rather than paying a second time.
316
  """
317
  def create_tip(conn, %{"post_id" => post_id, "amount_sats" => amount_sats} = params) do
318
    with {:ok, amount} <- parse_amount(amount_sats),
319
         post when not is_nil(post) <- Forum.get_post(post_id) do
320
      request = %{
321
        post: post,
322
        payer_user: conn.assigns.current_user,
323
        payer_actor_ref: "user:#{conn.assigns.current_user.id}",
324
        amount_sats: amount,
325
        idempotency_key: params["idempotency_key"] || Ecto.UUID.generate()
326
      }
327
328
      case Tips.tip_post(request) do
329
        {:ok, intent} ->
330
          conn
331
          |> put_status(:created)
332
          |> render(:tip, intent: intent, receipts: Tips.list_receipts(intent))
333
334
        {:error, {:payment_failed, intent}} ->
335
          conn
336
          |> put_status(:payment_required)
337
          |> render(:tip, intent: intent, receipts: Tips.list_receipts(intent))
338
339
        {:error, :payment_service_unavailable} ->
340
          conn
341
          |> put_status(:service_unavailable)
342
          |> json(%{error: "payment_service_unavailable"})
343
344
        {:error, reason} when is_atom(reason) ->
345
          conn |> put_status(:conflict) |> json(%{error: to_string(reason)})
346
347
        {:error, %Ecto.Changeset{} = changeset} ->
348
          conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
349
      end
350
    else
351
      nil -> not_found(conn)
352
      :error -> unprocessable(conn, :amount_sats)
353
    end
354
  end
355
356
  def create_tip(conn, _params), do: unprocessable(conn, :amount_sats)
357
358
  @doc "What the token's account received, and where to verify it."
359
  def list_received_tips(conn, _params) do
360
    render(conn, :received_tips, export: Tips.withdrawal_export(conn.assigns.current_user.id))
361
  end
362
256 363
  ## Helpers
257 364
258 365
  defp first_post(topic) do

@@ -310,6 +417,17 @@ defmodule OpenAgentsWeb.ForumApiController do

310 417
    |> String.slice(0, 80)
311 418
  end
312 419
420
  defp parse_amount(amount) when is_integer(amount) and amount > 0, do: {:ok, amount}
421
422
  defp parse_amount(amount) when is_binary(amount) do
423
    case Integer.parse(amount) do
424
      {value, ""} when value > 0 -> {:ok, value}
425
      _invalid -> :error
426
    end
427
  end
428
429
  defp parse_amount(_amount), do: :error
430
313 431
  defp valid_text?(value) when is_binary(value) and byte_size(value) > 0, do: true
314 432
  defp valid_text?(_value), do: false
315 433
lib/openagents_web/controllers/forum_api_json.ex modified +79

@@ -48,6 +48,68 @@ defmodule OpenAgentsWeb.ForumApiJSON do

48 48
    %{claims: Enum.map(claims, &claim_json/1)}
49 49
  end
50 50
51
  @doc """
52
  The caller's own destination, by fingerprint.
53
54
  The fingerprint identifies the destination without publishing it, so an
55
  offer or address never travels back over the API.
56
  """
57
  def render("tip_destination.json", %{destination: nil}) do
58
    %{destination: nil}
59
  end
60
61
  def render("tip_destination.json", %{destination: destination}) do
62
    %{
63
      destination: %{
64
        id: destination.id,
65
        kind: destination.kind,
66
        fingerprint: destination.fingerprint,
67
        label: destination.label,
68
        state: destination.state,
69
        accepting_tips: destination.accepting_tips,
70
        custody: "self"
71
      }
72
    }
73
  end
74
75
  def render("tip.json", %{intent: intent, receipts: receipts}) do
76
    %{
77
      tip: %{
78
        id: intent.id,
79
        post_id: intent.post_id,
80
        topic_id: intent.topic_id,
81
        amount_sats: intent.amount_sats,
82
        counted_sats: intent.counted_sats,
83
        excluded_from_ranking: intent.counted_sats == 0,
84
        exclusion_reason: intent.exclusion_reason,
85
        state: intent.state,
86
        failure_code: intent.failure_code,
87
        settled_at: iso(intent.settled_at),
88
        refunded_at: iso(intent.refunded_at)
89
      },
90
      receipts: Enum.map(receipts, &receipt_json/1)
91
    }
92
  end
93
94
  def render("received_tips.json", %{export: export}) do
95
    %{
96
      custody: export.custody,
97
      destination_fingerprint: export.destination_fingerprint,
98
      received_sats: export.received_sats,
99
      refunded_sats: export.refunded_sats,
100
      settlements:
101
        Enum.map(export.settlements, fn settlement ->
102
          %{
103
            post_id: settlement.post_id,
104
            amount_sats: settlement.amount_sats,
105
            state: settlement.state,
106
            payment_hash: settlement.payment_hash,
107
            settled_at: iso(settlement.settled_at)
108
          }
109
        end)
110
    }
111
  end
112
51 113
  def render("error.json", %{changeset: changeset}) do
52 114
    %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
53 115
  end

@@ -81,6 +143,8 @@ defmodule OpenAgentsWeb.ForumApiJSON do

81 143
        display_name: topic.actor_display_name,
82 144
        is_agent: topic.actor_is_agent
83 145
      },
146
      tip_sats: topic.tip_sats_total,
147
      tip_count: topic.tip_count,
84 148
      created_at: iso(topic.created_at),
85 149
      updated_at: iso(topic.updated_at),
86 150
      url: "https://openagents.com/forum/t/#{topic.id}"

@@ -106,6 +170,8 @@ defmodule OpenAgentsWeb.ForumApiJSON do

106 170
      post_number: post.post_number,
107 171
      body_text: post.body_text,
108 172
      state: post.state,
173
      tip_sats: post.tip_sats_total,
174
      tip_count: post.tip_count,
109 175
      author: %{
110 176
        ref: post.actor_ref,
111 177
        display_name: post.actor_display_name,

@@ -116,6 +182,19 @@ defmodule OpenAgentsWeb.ForumApiJSON do

116 182
    }
117 183
  end
118 184
185
  # A receipt carries the payment hash, which is what a recipient looks up in
186
  # their own wallet. It never carries a destination.
187
  defp receipt_json(receipt) do
188
    %{
189
      kind: receipt.kind,
190
      amount_sats: receipt.amount_sats,
191
      fee_sats: receipt.fee_sats,
192
      payment_hash: receipt.payment_hash,
193
      failure_code: receipt.failure_code,
194
      occurred_at: iso(receipt.occurred_at)
195
    }
196
  end
197
119 198
  defp claim_json(%ActorLink{} = link) do
120 199
    %{
121 200
      id: link.id,
lib/openagents_web/live/forum_board_live.ex modified +10 -3

@@ -19,8 +19,8 @@ defmodule OpenAgentsWeb.ForumBoardLive do

19 19
         socket
20 20
         |> assign(:current_scope, socket.assigns[:current_scope])
21 21
         |> assign(:forum, forum)
22
         |> assign(:topics, Forum.list_topics(forum))
23
         |> stream(:topics, Forum.list_topics(forum))
22
         |> assign(:topics, ranked_topics(forum))
23
         |> stream(:topics, ranked_topics(forum))
24 24
         |> assign(:form, to_form(%{"title" => "", "body_text" => ""}, as: :topic))}
25 25
    end
26 26
  end

@@ -48,7 +48,7 @@ defmodule OpenAgentsWeb.ForumBoardLive do

48 48
          {:ok, _topic} ->
49 49
            {:noreply,
50 50
             socket
51
             |> stream(:topics, Forum.list_topics(socket.assigns.forum), reset: true)
51
             |> stream(:topics, ranked_topics(socket.assigns.forum), reset: true)
52 52
             |> assign(:form, to_form(%{"title" => "", "body_text" => ""}, as: :topic))
53 53
             |> put_flash(:info, "Topic created")}
54 54

@@ -58,6 +58,10 @@ defmodule OpenAgentsWeb.ForumBoardLive do

58 58
    end
59 59
  end
60 60
61
  # Settled tips are one bounded, decaying ranking signal beside recency.
62
  # Ordering reads stored totals, so it works whether or not tips are enabled.
63
  defp ranked_topics(forum), do: Forum.list_topics(forum, order: :ranked)
64
61 65
  defp slugify(nil), do: nil
62 66
63 67
  defp slugify(title) do

@@ -102,6 +106,9 @@ defmodule OpenAgentsWeb.ForumBoardLive do

102 106
          <div class="flex items-center gap-3 text-sm text-muted-foreground mt-1">
103 107
            <span>{topic.actor_display_name}</span>
104 108
            <span>{topic.post_count} posts</span>
109
            <%= if topic.tip_count > 0 do %>
110
              <span class="badge" data-variant="dim">{topic.tip_sats_total} sats</span>
111
            <% end %>
105 112
            <span>{Calendar.strftime(topic.updated_at, "%b %d, %Y")}</span>
106 113
            <%= if topic.pin_state == "pinned" do %>
107 114
              <span class="badge" data-variant="dim">pinned</span>
lib/openagents_web/live/forum_tips_live.ex added +197

@@ -0,0 +1,197 @@

1
defmodule OpenAgentsWeb.ForumTipsLive do
2
  @moduledoc """
3
  Your tip destination and what arrived at it.
4
5
  The page records where you want tips to go and lists the settlements you can
6
  check in your own wallet. The forum holds nothing here, so there is no
7
  balance to withdraw — only payment hashes to verify.
8
  """
9
  use OpenAgentsWeb, :live_view
10
11
  alias OpenAgents.Forum.Tips
12
13
  def mount(_params, _session, socket) do
14
    user = socket.assigns.current_user
15
16
    {:ok,
17
     socket
18
     |> assign(:current_scope, socket.assigns[:current_scope])
19
     |> assign(:destination, Tips.active_destination(user.id))
20
     |> assign(:export, Tips.withdrawal_export(user.id))
21
     |> assign(:form, destination_form())}
22
  end
23
24
  def handle_event("save_destination", %{"destination" => params}, socket) do
25
    user = socket.assigns.current_user
26
27
    attrs = %{
28
      user_id: user.id,
29
      kind: params["kind"],
30
      destination: params["destination"],
31
      label: params["label"]
32
    }
33
34
    case Tips.register_destination(attrs) do
35
      {:ok, destination} ->
36
        {:noreply,
37
         socket
38
         |> assign(:destination, destination)
39
         |> assign(:export, Tips.withdrawal_export(user.id))
40
         |> assign(:form, destination_form())
41
         |> put_flash(:info, "Destination saved. Tips go straight to your wallet.")}
42
43
      {:error, changeset} ->
44
        {:noreply, assign(socket, :form, to_form(changeset, as: :destination))}
45
    end
46
  end
47
48
  def handle_event("toggle_accepting", _params, socket) do
49
    case socket.assigns.destination do
50
      nil ->
51
        {:noreply, put_flash(socket, :error, "Add a destination first")}
52
53
      destination ->
54
        {:ok, updated} = Tips.set_accepting_tips(destination, not destination.accepting_tips)
55
56
        {:noreply,
57
         socket
58
         |> assign(:destination, updated)
59
         |> put_flash(
60
           :info,
61
           if(updated.accepting_tips, do: "Tips are on", else: "Tips are off")
62
         )}
63
    end
64
  end
65
66
  def handle_event("retire_destination", _params, socket) do
67
    case socket.assigns.destination do
68
      nil ->
69
        {:noreply, socket}
70
71
      destination ->
72
        {:ok, _retired} = Tips.retire_destination(destination)
73
74
        {:noreply,
75
         socket
76
         |> assign(:destination, nil)
77
         |> assign(:export, Tips.withdrawal_export(socket.assigns.current_user.id))
78
         |> put_flash(:info, "Destination retired")}
79
    end
80
  end
81
82
  defp destination_form do
83
    to_form(%{"kind" => "bolt12", "destination" => "", "label" => ""}, as: :destination)
84
  end
85
86
  defp kind_options do
87
    [
88
      {"Bolt 12 offer", "bolt12"},
89
      {"Lightning address or LNURL", "lnurl"},
90
      {"On-chain address", "onchain"}
91
    ]
92
  end
93
94
  def render(assigns) do
95
    ~H"""
96
    <Layouts.app
97
      flash={@flash}
98
      sidebar_sections={assigns[:sidebar_sections]}
99
      current_scope={@current_scope}
100
    >
101
      <div class="flex items-center gap-2 mb-4">
102
        <.link navigate={~p"/forum"} class="text-sm text-muted-foreground hover:text-foreground">
103
          Forum
104
        </.link>
105
        <span class="text-muted-foreground">/</span>
106
        <h1 class="text-2xl font-bold">Tips</h1>
107
      </div>
108
109
      <section class="card !mx-0 !mt-0 mb-6">
110
        <header class="mb-2">
111
          <h2 class="text-lg font-semibold">Your destination</h2>
112
          <p class="text-sm text-muted-foreground">
113
            Tips are paid to a wallet you control. OpenAgents stores where to send sats,
114
            never a key or a seed, so it can neither hold nor spend them.
115
          </p>
116
        </header>
117
118
        <%= if @destination do %>
119
          <dl class="grid grid-cols-2 gap-2 text-sm mb-3" id="tip-destination">
120
            <dt class="text-muted-foreground">Kind</dt>
121
            <dd>{@destination.kind}</dd>
122
            <dt class="text-muted-foreground">Fingerprint</dt>
123
            <dd class="font-mono">{@destination.fingerprint}</dd>
124
            <dt class="text-muted-foreground">Accepting tips</dt>
125
            <dd>{if @destination.accepting_tips, do: "yes", else: "no"}</dd>
126
          </dl>
127
          <div class="flex gap-2">
128
            <.button variant={:secondary} phx-click="toggle_accepting" id="toggle-accepting">
129
              {if @destination.accepting_tips, do: "Stop accepting tips", else: "Accept tips"}
130
            </.button>
131
            <.button variant={:ghost} tone={:danger} phx-click="retire_destination">
132
              Retire destination
133
            </.button>
134
          </div>
135
        <% end %>
136
137
        <.form
138
          for={@form}
139
          id="tip-destination-form"
140
          phx-submit="save_destination"
141
          class="mt-4 space-y-3"
142
        >
143
          <.input field={@form[:kind]} type="select" label="Kind" options={kind_options()} required />
144
          <.input
145
            field={@form[:destination]}
146
            label="Destination"
147
            placeholder="lno1… , you@example.com, or bc1…"
148
            required
149
          />
150
          <.input field={@form[:label]} label="Label" placeholder="Phone wallet" />
151
          <footer class="flex justify-end">
152
            <.button type="submit" variant={:primary}>
153
              {if @destination, do: "Replace destination", else: "Save destination"}
154
            </.button>
155
          </footer>
156
        </.form>
157
      </section>
158
159
      <section class="card !mx-0">
160
        <header class="mb-2">
161
          <h2 class="text-lg font-semibold">Settlements</h2>
162
          <p class="text-sm text-muted-foreground">
163
            {@export.received_sats} sats received, {@export.refunded_sats} sats refunded.
164
            Check each payment hash in your own wallet.
165
          </p>
166
        </header>
167
168
        <%= if @export.settlements == [] do %>
169
          <p class="text-sm text-muted-foreground">No tips yet.</p>
170
        <% else %>
171
          <table class="table" id="tip-settlements">
172
            <thead>
173
              <tr>
174
                <th>Sats</th>
175
                <th>State</th>
176
                <th>Payment hash</th>
177
                <th>Settled</th>
178
              </tr>
179
            </thead>
180
            <tbody>
181
              <tr :for={settlement <- @export.settlements}>
182
                <td>{settlement.amount_sats}</td>
183
                <td>{settlement.state}</td>
184
                <td class="font-mono text-xs">{settlement.payment_hash}</td>
185
                <td>
186
                  {settlement.settled_at &&
187
                    Calendar.strftime(settlement.settled_at, "%b %d, %Y %H:%M")}
188
                </td>
189
              </tr>
190
            </tbody>
191
          </table>
192
        <% end %>
193
      </section>
194
    </Layouts.app>
195
    """
196
  end
197
end
lib/openagents_web/live/forum_topic_live.ex modified +83

@@ -3,8 +3,12 @@ defmodule OpenAgentsWeb.ForumTopicLive do

3 3
  use OpenAgentsWeb, :live_view
4 4
5 5
  alias OpenAgents.Forum
6
  alias OpenAgents.Forum.Tips
6 7
  alias OpenAgents.Markdown
7 8
9
  # Preset amounts keep tipping one click. Larger amounts go through the API.
10
  @tip_amounts [100, 1_000]
11
8 12
  def mount(%{"id" => id}, _session, socket) do
9 13
    scope = [operator?: OpenAgents.Accounts.admin?(socket.assigns[:current_user])]
10 14

@@ -62,6 +66,36 @@ defmodule OpenAgentsWeb.ForumTopicLive do

62 66
    end
63 67
  end
64 68
69
  # Tipping pays the author's own destination. A fresh idempotency key per
70
  # click means a double click cannot pay twice for the same request.
71
  def handle_event("tip", %{"id" => id, "amount" => amount}, socket) do
72
    with %{} = user <- current_user(socket),
73
         {sats, ""} <- Integer.parse(amount),
74
         post when not is_nil(post) <- Enum.find(socket.assigns.posts, &(&1.id == id)) do
75
      request = %{
76
        post: post,
77
        payer_user: user,
78
        payer_actor_ref: "user:#{user.id}",
79
        amount_sats: sats,
80
        idempotency_key: Ecto.UUID.generate()
81
      }
82
83
      case Tips.tip_post(request) do
84
        {:ok, intent} ->
85
          {:noreply, socket |> refresh_posts() |> put_flash(:info, tip_message(intent))}
86
87
        {:error, {:payment_failed, intent}} ->
88
          {:noreply,
89
           put_flash(socket, :error, "Payment failed: #{intent.failure_code}. Nothing was sent.")}
90
91
        {:error, reason} ->
92
          {:noreply, put_flash(socket, :error, tip_error(reason))}
93
      end
94
    else
95
      _unavailable -> {:noreply, put_flash(socket, :error, "Sign in to tip")}
96
    end
97
  end
98
65 99
  def handle_event("toggle_closed", _params, socket) do
66 100
    with %{} = user <- current_user(socket),
67 101
         true <- OpenAgents.Accounts.admin?(user) do

@@ -101,6 +135,9 @@ defmodule OpenAgentsWeb.ForumTopicLive do

101 135
        </.link>
102 136
        <span class="text-muted-foreground">/</span>
103 137
        <h1 class="text-2xl font-bold">{@topic.title}</h1>
138
        <.link navigate={~p"/forum/tips"} class="text-sm text-muted-foreground hover:text-foreground">
139
          Tips
140
        </.link>
104 141
        <%= if @topic.state == "closed" do %>
105 142
          <span class="badge" data-variant="dim">closed</span>
106 143
        <% end %>

@@ -130,12 +167,31 @@ defmodule OpenAgentsWeb.ForumTopicLive do

130 167
                  Hide
131 168
                </button>
132 169
              <% end %>
170
              <%= if post.tip_count > 0 do %>
171
                <span class="badge" data-variant="dim" title="Settled tips">
172
                  {post.tip_sats_total} sats
173
                </span>
174
              <% end %>
133 175
              <span class="text-xs text-muted-foreground"># {post.post_number}</span>
134 176
            </span>
135 177
          </header>
136 178
          <div class="prose prose-sm dark:prose-invert max-w-none">
137 179
            {Markdown.to_html(post.body_text)}
138 180
          </div>
181
          <footer :if={@current_user} class="flex items-center gap-2 mt-3">
182
            <span class="text-xs text-muted-foreground">Tip the author</span>
183
            <button
184
              :for={amount <- tip_amounts()}
185
              class="btn"
186
              data-variant="ghost"
187
              data-size="sm"
188
              phx-click="tip"
189
              phx-value-id={post.id}
190
              phx-value-amount={amount}
191
            >
192
              {amount} sats
193
            </button>
194
          </footer>
139 195
        </div>
140 196
      </div>
141 197

@@ -152,4 +208,31 @@ defmodule OpenAgentsWeb.ForumTopicLive do

152 208
  end
153 209
154 210
  defp current_user(socket), do: socket.assigns[:current_user]
211
212
  defp refresh_posts(socket) do
213
    posts = Forum.list_posts(socket.assigns.topic)
214
215
    socket
216
    |> assign(:posts, posts)
217
    |> stream(:posts, posts, reset: true)
218
  end
219
220
  defp tip_message(%{counted_sats: 0, exclusion_reason: reason, amount_sats: sats})
221
       when is_binary(reason) do
222
    "Sent #{sats} sats. This tip does not change ranking (#{String.replace(reason, "_", " ")})."
223
  end
224
225
  defp tip_message(%{amount_sats: sats}), do: "Sent #{sats} sats"
226
227
  defp tip_error(:tipping_disabled), do: "Tipping is not enabled here yet"
228
  defp tip_error(:no_destination), do: "This author has no tip destination yet"
229
  defp tip_error(:not_accepting_tips), do: "This author is not accepting tips"
230
  defp tip_error(:post_not_visible), do: "This post cannot be tipped"
231
232
  defp tip_error(:payment_service_unavailable),
233
    do: "The payment service is unavailable. Nothing was sent."
234
235
  defp tip_error(_reason), do: "That tip could not be sent"
236
237
  defp tip_amounts, do: @tip_amounts
155 238
end
lib/openagents_web/router.ex modified +9

@@ -122,6 +122,7 @@ defmodule OpenAgentsWeb.Router do

122 122
      live "/forum/f/:slug", ForumBoardLive, :show
123 123
      live "/forum/t/:id", ForumTopicLive, :show
124 124
      live "/forum/claim", ForumClaimLive, :new
125
      live "/forum/tips", ForumTipsLive, :show
125 126
126 127
      live "/:owner/:repo/issues/new", IssueNewLive, :new
127 128

@@ -297,6 +298,14 @@ defmodule OpenAgentsWeb.Router do

297 298
    patch "/forum/claims/:id", ForumApiController, :update_claim
298 299
    patch "/forum/topics/:id", ForumApiController, :update_topic
299 300
    patch "/forum/posts/:id", ForumApiController, :update_post
301
302
    # Tips. A destination and a settlement history belong to one account, so
303
    # every tip route requires that account's own token.
304
    post "/forum/tips/destination", ForumApiController, :put_tip_destination
305
    patch "/forum/tips/destination", ForumApiController, :update_tip_destination
306
    get "/forum/tips/destination", ForumApiController, :show_tip_destination
307
    get "/forum/tips/received", ForumApiController, :list_received_tips
308
    post "/forum/posts/:post_id/tips", ForumApiController, :create_tip
300 309
  end
301 310
302 311
  scope "/api/v3", OpenAgentsWeb do
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -241,7 +241,8 @@

241 241
    20260823043000,
242 242
    20260823050000,
243 243
    20260823051500,
244
    20260823052000
244
    20260823052000,
245
    20260823053000
245 246
  ],
246 247
  "required_tables": [
247 248
    "users",
priv/repo/migrations/20260823053000_create_forum_tips.exs added +162

@@ -0,0 +1,162 @@

1
defmodule OpenAgents.Repo.Migrations.CreateForumTips do
2
  use Ecto.Migration
3
4
  def up do
5
    create table(:forum_tip_destinations, primary_key: false) do
6
      add :id, :uuid, primary_key: true
7
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
8
9
      # The destination belongs to the account. The forum stores where to send
10
      # sats and never a key, seed, channel, or node credential, so it cannot
11
      # spend or hold what it routes.
12
      add :kind, :string, null: false
13
      add :destination, :text, null: false
14
      add :fingerprint, :string, null: false
15
      add :label, :string
16
17
      add :state, :string, null: false, default: "active"
18
      add :accepting_tips, :boolean, null: false, default: true
19
      add :retired_at, :utc_datetime_usec
20
21
      timestamps(type: :utc_datetime_usec)
22
    end
23
24
    create unique_index(:forum_tip_destinations, [:user_id],
25
             where: "state = 'active'",
26
             name: :forum_tip_destinations_one_active_per_user_index
27
           )
28
29
    create constraint(:forum_tip_destinations, :forum_tip_destinations_kind_check,
30
             check: "kind IN ('bolt12', 'lnurl', 'onchain')"
31
           )
32
33
    create constraint(:forum_tip_destinations, :forum_tip_destinations_state_check,
34
             check: "state IN ('active', 'retired')"
35
           )
36
37
    create table(:forum_tip_intents, primary_key: false) do
38
      add :id, :uuid, primary_key: true
39
40
      add :post_id, references(:forum_posts, type: :uuid, on_delete: :restrict), null: false
41
      add :topic_id, references(:forum_topics, type: :uuid, on_delete: :restrict), null: false
42
43
      add :payer_user_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
44
      add :payer_actor_ref, :string, null: false
45
46
      add :recipient_user_id, references(:users, type: :binary_id, on_delete: :restrict),
47
        null: false
48
49
      add :destination_id,
50
          references(:forum_tip_destinations, type: :uuid, on_delete: :restrict),
51
          null: false
52
53
      add :idempotency_key, :string, null: false
54
      add :amount_sats, :bigint, null: false
55
56
      # What ranking is allowed to see. The policy sets it once at settlement
57
      # and refunds return it to zero, so a payment and its ranking weight
58
      # stay separate facts.
59
      add :counted_sats, :bigint, null: false, default: 0
60
      add :exclusion_reason, :string
61
62
      add :state, :string, null: false, default: "created"
63
      add :failure_code, :string
64
      add :settled_at, :utc_datetime_usec
65
      add :failed_at, :utc_datetime_usec
66
      add :refunded_at, :utc_datetime_usec
67
68
      timestamps(type: :utc_datetime_usec)
69
    end
70
71
    create unique_index(:forum_tip_intents, [:idempotency_key])
72
    create index(:forum_tip_intents, [:post_id, :state])
73
    create index(:forum_tip_intents, [:payer_user_id, :state, :settled_at])
74
    create index(:forum_tip_intents, [:recipient_user_id, :state])
75
76
    create constraint(:forum_tip_intents, :forum_tip_intents_amount_check,
77
             check: "amount_sats > 0 AND amount_sats <= 1000000"
78
           )
79
80
    create constraint(:forum_tip_intents, :forum_tip_intents_counted_check,
81
             check: "counted_sats >= 0 AND counted_sats <= amount_sats"
82
           )
83
84
    create constraint(:forum_tip_intents, :forum_tip_intents_state_check,
85
             check: "state IN ('created', 'settled', 'failed', 'refunded')"
86
           )
87
88
    create table(:forum_tip_receipts, primary_key: false) do
89
      add :id, :uuid, primary_key: true
90
91
      add :intent_id, references(:forum_tip_intents, type: :uuid, on_delete: :restrict),
92
        null: false
93
94
      add :kind, :string, null: false
95
      add :amount_sats, :bigint, null: false
96
      add :fee_sats, :bigint, null: false, default: 0
97
98
      # The payment hash proves the payment in the recipient's own wallet. It
99
      # is not a credential, and it reaches only the two accounts on the tip.
100
      add :payment_hash, :string
101
      add :failure_code, :string
102
      add :occurred_at, :utc_datetime_usec, null: false
103
104
      timestamps(type: :utc_datetime_usec, updated_at: false)
105
    end
106
107
    create unique_index(:forum_tip_receipts, [:intent_id, :kind])
108
    create index(:forum_tip_receipts, [:occurred_at])
109
110
    create constraint(:forum_tip_receipts, :forum_tip_receipts_kind_check,
111
             check: "kind IN ('settled', 'failed', 'refunded')"
112
           )
113
114
    execute("""
115
    CREATE FUNCTION reject_forum_tip_receipt_mutation()
116
    RETURNS trigger AS $$
117
    BEGIN
118
      RAISE EXCEPTION 'forum tip receipts are append-only';
119
    END;
120
    $$ LANGUAGE plpgsql;
121
    """)
122
123
    execute("""
124
    CREATE TRIGGER forum_tip_receipts_append_only
125
    BEFORE UPDATE OR DELETE ON forum_tip_receipts
126
    FOR EACH ROW EXECUTE FUNCTION reject_forum_tip_receipt_mutation();
127
    """)
128
129
    alter table(:forum_posts) do
130
      add :tip_sats_total, :bigint, null: false, default: 0
131
      add :tip_sats_counted, :bigint, null: false, default: 0
132
      add :tip_count, :bigint, null: false, default: 0
133
    end
134
135
    alter table(:forum_topics) do
136
      add :tip_sats_total, :bigint, null: false, default: 0
137
      add :tip_sats_counted, :bigint, null: false, default: 0
138
      add :tip_count, :bigint, null: false, default: 0
139
    end
140
  end
141
142
  def down do
143
    alter table(:forum_topics) do
144
      remove :tip_sats_total
145
      remove :tip_sats_counted
146
      remove :tip_count
147
    end
148
149
    alter table(:forum_posts) do
150
      remove :tip_sats_total
151
      remove :tip_sats_counted
152
      remove :tip_count
153
    end
154
155
    execute("DROP TRIGGER IF EXISTS forum_tip_receipts_append_only ON forum_tip_receipts")
156
    execute("DROP FUNCTION IF EXISTS reject_forum_tip_receipt_mutation()")
157
158
    drop table(:forum_tip_receipts)
159
    drop table(:forum_tip_intents)
160
    drop table(:forum_tip_destinations)
161
  end
162
end
test/openagents/forum/tips_test.exs added +483

@@ -0,0 +1,483 @@

1
defmodule OpenAgents.Forum.TipsTest do
2
  use OpenAgents.DataCase, async: true
3
4
  alias OpenAgents.Forum
5
  alias OpenAgents.Forum.{TipIntent, TipPaymentServiceStub, TipReceipt, Tips}
6
7
  setup do
8
    TipPaymentServiceStub.settle()
9
    :ok
10
  end
11
12
  defp user(login_prefix) do
13
    {:ok, user} =
14
      %OpenAgents.Accounts.User{}
15
      |> Ecto.Changeset.change(%{
16
        github_id: System.unique_integer([:positive]),
17
        github_login: "#{login_prefix}-#{System.unique_integer([:positive])}",
18
        github_name: "Tips Test",
19
        github_avatar_url: "https://example.com/a.png"
20
      })
21
      |> Repo.insert()
22
23
    user
24
  end
25
26
  defp forum do
27
    {:ok, forum} =
28
      %OpenAgents.Forum.Forum{}
29
      |> OpenAgents.Forum.Forum.changeset(%{
30
        slug: "general-#{System.unique_integer([:positive])}",
31
        title: "General"
32
      })
33
      |> Repo.insert()
34
35
    forum
36
  end
37
38
  # An author with a linked identity and a destination it controls: the only
39
  # shape a tip can be paid to.
40
  defp author(opts \\ []) do
41
    user = user("author")
42
    actor_ref = "agent:author-#{System.unique_integer([:positive])}"
43
44
    {:ok, link} = Forum.start_actor_link(user, actor_ref)
45
    {:ok, _linked} = Forum.approve_actor_link(link)
46
47
    if Keyword.get(opts, :destination, true) do
48
      {:ok, _destination} =
49
        Tips.register_destination(%{
50
          user_id: user.id,
51
          kind: "bolt12",
52
          destination: "lno1qsgz#{System.unique_integer([:positive])}",
53
          accepting_tips: Keyword.get(opts, :accepting_tips, true)
54
        })
55
    end
56
57
    %{user: user, actor_ref: actor_ref}
58
  end
59
60
  defp topic_with_post(author, title \\ "Hello") do
61
    {:ok, topic} =
62
      Forum.create_topic(forum(), %{
63
        title: title,
64
        slug: "hello-#{System.unique_integer([:positive])}",
65
        body_text: "First post",
66
        actor_ref: author.actor_ref,
67
        actor_display_name: "Author",
68
        actor_slug: "author"
69
      })
70
71
    [post] = Forum.list_posts(topic)
72
    {topic, post}
73
  end
74
75
  defp tip(post, payer, amount_sats, opts \\ []) do
76
    Tips.tip_post(%{
77
      post: post,
78
      payer_user: payer,
79
      payer_actor_ref: "user:#{payer.id}",
80
      amount_sats: amount_sats,
81
      idempotency_key: Keyword.get(opts, :idempotency_key, Ecto.UUID.generate())
82
    })
83
  end
84
85
  describe "destinations" do
86
    test "registering a destination retires the previous one" do
87
      user = user("recipient")
88
89
      {:ok, first} =
90
        Tips.register_destination(%{user_id: user.id, kind: "bolt12", destination: "lno1first"})
91
92
      {:ok, second} =
93
        Tips.register_destination(%{user_id: user.id, kind: "bolt12", destination: "lno1second"})
94
95
      assert Repo.reload!(first).state == "retired"
96
      assert Tips.active_destination(user.id).id == second.id
97
    end
98
99
    test "a destination is fingerprinted and never stores a wallet secret" do
100
      user = user("recipient")
101
102
      {:ok, destination} =
103
        Tips.register_destination(%{user_id: user.id, kind: "bolt12", destination: "lno1abcdef"})
104
105
      assert destination.fingerprint != "lno1abcdef"
106
      assert byte_size(destination.fingerprint) == 16
107
108
      assert {:error, changeset} =
109
               Tips.register_destination(%{
110
                 user_id: user("other").id,
111
                 kind: "bolt12",
112
                 destination: "seed words here"
113
               })
114
115
      assert %{destination: _errors} = errors_on(changeset)
116
    end
117
118
    test "opting out stops tips without discarding the destination" do
119
      author = author()
120
      {_topic, post} = topic_with_post(author)
121
      destination = Tips.active_destination(author.user.id)
122
123
      {:ok, _off} = Tips.set_accepting_tips(destination, false)
124
125
      assert {:error, :not_accepting_tips} = tip(post, user("payer"), 100)
126
    end
127
128
    test "a post whose author has no destination cannot be tipped" do
129
      author = author(destination: false)
130
      {_topic, post} = topic_with_post(author)
131
132
      assert {:error, :no_destination} = tip(post, user("payer"), 100)
133
    end
134
  end
135
136
  describe "tip_post/1" do
137
    test "settles a tip, records one receipt, and counts it for ranking" do
138
      author = author()
139
      {topic, post} = topic_with_post(author)
140
141
      assert {:ok, intent} = tip(post, user("payer"), 1_000)
142
      assert intent.state == "settled"
143
      assert intent.counted_sats == 1_000
144
      assert is_nil(intent.exclusion_reason)
145
146
      assert [%TipReceipt{kind: "settled", payment_hash: hash}] = Tips.list_receipts(intent)
147
      assert is_binary(hash)
148
149
      post = Repo.reload!(post)
150
      assert post.tip_sats_total == 1_000
151
      assert post.tip_sats_counted == 1_000
152
      assert post.tip_count == 1
153
154
      topic = Repo.reload!(topic)
155
      assert topic.tip_sats_total == 1_000
156
      assert topic.tip_sats_counted == 1_000
157
    end
158
159
    test "a retry with the same key pays once and counts once" do
160
      author = author()
161
      {_topic, post} = topic_with_post(author)
162
      payer = user("payer")
163
      key = Ecto.UUID.generate()
164
165
      assert {:ok, first} = tip(post, payer, 500, idempotency_key: key)
166
      assert {:ok, second} = tip(post, payer, 500, idempotency_key: key)
167
168
      assert first.id == second.id
169
      assert [_one_request] = TipPaymentServiceStub.requests()
170
      assert Repo.reload!(post).tip_sats_total == 500
171
      assert Repo.reload!(post).tip_count == 1
172
      assert [%TipReceipt{kind: "settled"}] = Tips.list_receipts(first)
173
    end
174
175
    test "a failed payment records the failure and counts nothing" do
176
      author = author()
177
      {topic, post} = topic_with_post(author)
178
      TipPaymentServiceStub.fail("no_route")
179
180
      assert {:error, {:payment_failed, intent}} = tip(post, user("payer"), 1_000)
181
      assert intent.state == "failed"
182
      assert intent.failure_code == "no_route"
183
      assert intent.counted_sats == 0
184
185
      assert [%TipReceipt{kind: "failed", failure_code: "no_route"}] = Tips.list_receipts(intent)
186
      assert Repo.reload!(post).tip_sats_total == 0
187
      assert Repo.reload!(topic).tip_sats_counted == 0
188
    end
189
190
    test "an unavailable payment service leaves the tip retryable and unpaid" do
191
      author = author()
192
      {_topic, post} = topic_with_post(author)
193
      payer = user("payer")
194
      key = Ecto.UUID.generate()
195
196
      TipPaymentServiceStub.unavailable()
197
198
      assert {:error, :payment_service_unavailable} =
199
               tip(post, payer, 1_000, idempotency_key: key)
200
201
      intent = Tips.get_intent_by_key(key)
202
      assert intent.state == "created"
203
      assert Tips.list_receipts(intent) == []
204
      assert Repo.reload!(post).tip_sats_total == 0
205
206
      TipPaymentServiceStub.settle()
207
      assert {:ok, settled} = tip(post, payer, 1_000, idempotency_key: key)
208
      assert settled.id == intent.id
209
      assert settled.state == "settled"
210
      assert Repo.reload!(post).tip_sats_counted == 1_000
211
    end
212
213
    test "a tip larger than the per-tip cap counts only up to the cap" do
214
      author = author()
215
      {_topic, post} = topic_with_post(author)
216
217
      assert {:ok, intent} = tip(post, user("payer"), Tips.counted_sats_per_tip() * 2)
218
      assert intent.counted_sats == Tips.counted_sats_per_tip()
219
      assert intent.amount_sats == Tips.counted_sats_per_tip() * 2
220
    end
221
222
    test "an amount past the maximum is refused" do
223
      author = author()
224
      {_topic, post} = topic_with_post(author)
225
226
      assert {:error, %Ecto.Changeset{}} =
227
               tip(post, user("payer"), Tips.maximum_amount_sats() + 1)
228
    end
229
  end
230
231
  describe "anti-manipulation" do
232
    test "a self-tip pays but does not improve rank" do
233
      author = author()
234
      {topic, post} = topic_with_post(author)
235
236
      assert {:ok, intent} = tip(post, author.user, 5_000)
237
      assert intent.state == "settled"
238
      assert intent.counted_sats == 0
239
      assert intent.exclusion_reason == "self_tip"
240
241
      assert Repo.reload!(post).tip_sats_total == 5_000
242
      assert Repo.reload!(post).tip_sats_counted == 0
243
      assert Repo.reload!(topic).tip_sats_counted == 0
244
    end
245
246
    test "tips that go in a circle stop counting" do
247
      first = author()
248
      second = author()
249
      {_first_topic, first_post} = topic_with_post(first)
250
      {_second_topic, second_post} = topic_with_post(second)
251
252
      assert {:ok, outbound} = tip(second_post, first.user, 2_000)
253
      assert outbound.counted_sats == 2_000
254
255
      assert {:ok, returned} = tip(first_post, second.user, 2_000)
256
      assert returned.counted_sats == 0
257
      assert returned.exclusion_reason == "reciprocal"
258
      assert Repo.reload!(first_post).tip_sats_counted == 0
259
    end
260
261
    test "one payer cannot exceed the per-post cap by splitting tips" do
262
      author = author()
263
      {_topic, post} = topic_with_post(author)
264
      payer = user("payer")
265
266
      assert {:ok, first} = tip(post, payer, Tips.counted_sats_per_tip())
267
      assert first.counted_sats == Tips.counted_sats_per_tip()
268
269
      assert {:ok, second} = tip(post, payer, 1_000)
270
      assert second.counted_sats == 0
271
      assert second.exclusion_reason == "payer_cap"
272
273
      assert Repo.reload!(post).tip_sats_counted == Tips.counted_sats_per_tip()
274
    end
275
276
    test "an automated burst of tips stops counting" do
277
      author = author()
278
      payer = user("payer")
279
280
      counted =
281
        Enum.map(1..22, fn index ->
282
          {_topic, post} = topic_with_post(author, "Topic #{index}")
283
          {:ok, intent} = tip(post, payer, 10)
284
          intent.counted_sats
285
        end)
286
287
      assert Enum.take(counted, 20) == List.duplicate(10, 20)
288
      assert Enum.drop(counted, 20) == [0, 0]
289
290
      assert Repo.all(from(i in TipIntent, where: i.exclusion_reason == "rate_limited"))
291
             |> length() == 2
292
    end
293
  end
294
295
  describe "refunds" do
296
    test "a refund removes ranking weight and keeps both receipts" do
297
      author = author()
298
      {topic, post} = topic_with_post(author)
299
300
      {:ok, intent} = tip(post, user("payer"), 3_000)
301
      assert {:ok, refunded} = Tips.refund(intent)
302
303
      assert refunded.state == "refunded"
304
      assert refunded.counted_sats == 0
305
      assert refunded.exclusion_reason == "refunded"
306
307
      assert Enum.map(Tips.list_receipts(refunded), & &1.kind) == ["settled", "refunded"]
308
      assert Repo.reload!(post).tip_sats_total == 0
309
      assert Repo.reload!(post).tip_sats_counted == 0
310
      assert Repo.reload!(post).tip_count == 0
311
      assert Repo.reload!(topic).tip_sats_counted == 0
312
    end
313
314
    test "only a settled tip can be refunded" do
315
      author = author()
316
      {_topic, post} = topic_with_post(author)
317
      TipPaymentServiceStub.fail("insufficient_balance")
318
319
      {:error, {:payment_failed, failed}} = tip(post, user("payer"), 100)
320
321
      assert {:error, {:not_refundable, "failed"}} = Tips.refund(failed)
322
    end
323
324
    test "a receipt cannot be rewritten or deleted" do
325
      author = author()
326
      {_topic, post} = topic_with_post(author)
327
      {:ok, intent} = tip(post, user("payer"), 100)
328
      [receipt] = Tips.list_receipts(intent)
329
330
      assert_raise Postgrex.Error, fn ->
331
        Repo.update_all(from(r in TipReceipt, where: r.id == ^receipt.id),
332
          set: [amount_sats: 1_000_000]
333
        )
334
      end
335
    end
336
  end
337
338
  describe "moderation" do
339
    test "hiding a post removes its tips from topic ranking without moving funds" do
340
      author = author()
341
      {topic, post} = topic_with_post(author)
342
      moderator = user("moderator")
343
344
      {:ok, intent} = tip(post, user("payer"), 4_000)
345
      assert Repo.reload!(topic).tip_sats_counted == 4_000
346
347
      {:ok, _hidden} = Forum.hide_post(Repo.reload!(post), moderator)
348
349
      assert Repo.reload!(topic).tip_sats_counted == 0
350
      assert Repo.reload!(post).tip_sats_total == 4_000
351
      assert Repo.reload!(intent).state == "settled"
352
      assert Enum.map(Tips.list_receipts(intent), & &1.kind) == ["settled"]
353
    end
354
  end
355
356
  describe "ranking" do
357
    test "a tipped topic ranks above an untipped one of the same age" do
358
      board = forum()
359
      author = author()
360
361
      {:ok, quiet} =
362
        Forum.create_topic(board, %{
363
          title: "Quiet",
364
          slug: "quiet",
365
          body_text: "Nothing here",
366
          actor_ref: author.actor_ref,
367
          actor_display_name: "Author",
368
          actor_slug: "author"
369
        })
370
371
      {:ok, tipped} =
372
        Forum.create_topic(board, %{
373
          title: "Tipped",
374
          slug: "tipped",
375
          body_text: "Worth sats",
376
          actor_ref: author.actor_ref,
377
          actor_display_name: "Author",
378
          actor_slug: "author"
379
        })
380
381
      [post] = Forum.list_posts(tipped)
382
      {:ok, _intent} = tip(post, user("payer"), 5_000)
383
384
      assert Enum.map(Forum.list_topics(board, order: :ranked), & &1.id) == [tipped.id, quiet.id]
385
      assert Enum.map(Forum.list_topics(board), & &1.id) == [tipped.id, quiet.id]
386
    end
387
388
    test "ranking keeps working when the payment service is unavailable" do
389
      board = forum()
390
      author = author()
391
392
      {:ok, tipped} =
393
        Forum.create_topic(board, %{
394
          title: "Tipped",
395
          slug: "tipped",
396
          body_text: "Worth sats",
397
          actor_ref: author.actor_ref,
398
          actor_display_name: "Author",
399
          actor_slug: "author"
400
        })
401
402
      [post] = Forum.list_posts(tipped)
403
      {:ok, _intent} = tip(post, user("payer"), 5_000)
404
405
      TipPaymentServiceStub.unavailable()
406
407
      assert Enum.map(Forum.list_topics(board, order: :ranked), & &1.id) == [tipped.id]
408
      assert {:error, :payment_service_unavailable} = tip(post, user("payer"), 100)
409
      assert Repo.reload!(tipped).tip_sats_counted == 5_000
410
    end
411
412
    test "a decayed tip ranks below a fresh tip of the same size" do
413
      board = forum()
414
      author = author()
415
416
      {:ok, old} =
417
        Forum.create_topic(board, %{
418
          title: "Old",
419
          slug: "old",
420
          body_text: "Tipped a month ago",
421
          actor_ref: author.actor_ref,
422
          actor_display_name: "Author",
423
          actor_slug: "author"
424
        })
425
426
      {:ok, fresh} =
427
        Forum.create_topic(board, %{
428
          title: "Fresh",
429
          slug: "fresh",
430
          body_text: "Tipped now",
431
          actor_ref: author.actor_ref,
432
          actor_display_name: "Author",
433
          actor_slug: "author"
434
        })
435
436
      for topic <- [old, fresh] do
437
        [post] = Forum.list_posts(topic)
438
        {:ok, _intent} = tip(post, user("payer"), 5_000)
439
      end
440
441
      month_ago = DateTime.add(DateTime.utc_now(), -30 * 24 * 3600, :second)
442
443
      Repo.update_all(from(t in OpenAgents.Forum.Topic, where: t.id == ^old.id),
444
        set: [updated_at: month_ago]
445
      )
446
447
      assert Enum.map(Forum.list_topics(board, order: :ranked), & &1.id) == [fresh.id, old.id]
448
    end
449
  end
450
451
  describe "recipient views" do
452
    test "the export lists settlements to verify and no destination" do
453
      author = author()
454
      {_topic, post} = topic_with_post(author)
455
      {:ok, intent} = tip(post, user("payer"), 700)
456
457
      export = Tips.withdrawal_export(author.user.id)
458
459
      assert export.custody == "self"
460
      assert export.received_sats == 700
461
      assert export.refunded_sats == 0
462
      assert [settlement] = export.settlements
463
      assert settlement.state == "settled"
464
      assert [%{payment_hash: hash}] = Tips.list_receipts(intent)
465
      assert settlement.payment_hash == hash
466
467
      destination = Tips.active_destination(author.user.id)
468
      assert export.destination_fingerprint == destination.fingerprint
469
      refute export.destination_fingerprint == destination.destination
470
    end
471
472
    test "a refund shows in the export as refunded" do
473
      author = author()
474
      {_topic, post} = topic_with_post(author)
475
      {:ok, intent} = tip(post, user("payer"), 700)
476
      {:ok, _refunded} = Tips.refund(intent)
477
478
      export = Tips.withdrawal_export(author.user.id)
479
      assert export.received_sats == 0
480
      assert export.refunded_sats == 700
481
    end
482
  end
483
end
test/openagents_web/controllers/forum_tips_api_controller_test.exs added +276

@@ -0,0 +1,276 @@

1
defmodule OpenAgentsWeb.ForumTipsApiControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.Forum
5
  alias OpenAgents.Forum.{TipPaymentServiceStub, Tips}
6
  alias OpenAgents.Repo
7
8
  setup %{conn: conn} do
9
    TipPaymentServiceStub.settle()
10
11
    {:ok, forum} =
12
      %Forum.Forum{}
13
      |> Forum.Forum.changeset(%{slug: "general", title: "General"})
14
      |> Repo.insert()
15
16
    {:ok, conn: conn, forum: forum}
17
  end
18
19
  # An author who holds the linked identity and its own payment destination.
20
  defp author(forum) do
21
    # The same key `put_forge_api_token/2` derives its user from, so the test
22
    # can also call the API as the author.
23
    user = github_user("api-token-forum-tips-author")
24
    actor_ref = "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20"
25
26
    {:ok, link} = Forum.start_actor_link(user, actor_ref)
27
    {:ok, _linked} = Forum.approve_actor_link(link)
28
29
    {:ok, destination} =
30
      Tips.register_destination(%{
31
        user_id: user.id,
32
        kind: "bolt12",
33
        destination: "lno1qsgauthor",
34
        label: "Phone wallet"
35
      })
36
37
    {:ok, topic} =
38
      Forum.create_topic(forum, %{
39
        title: "Hello world",
40
        slug: "hello-world",
41
        body_text: "First post body",
42
        idempotency_key: Ecto.UUID.generate(),
43
        actor_ref: actor_ref,
44
        actor_display_name: "Orrery",
45
        actor_slug: "orrery"
46
      })
47
48
    [post] = Forum.list_posts(topic)
49
50
    %{user: user, destination: destination, topic: topic, post: post}
51
  end
52
53
  describe "destinations" do
54
    test "a caller registers a destination and reads back only its fingerprint", %{conn: conn} do
55
      authed = put_forge_api_token(conn, "forum-tips-destination")
56
57
      created =
58
        post(authed, ~p"/api/v3/forum/tips/destination", %{
59
          kind: "bolt12",
60
          destination: "lno1qsgpayer",
61
          label: "Phone wallet"
62
        })
63
64
      assert %{"destination" => destination} = json_response(created, 201)
65
      assert destination["kind"] == "bolt12"
66
      assert destination["custody"] == "self"
67
      assert destination["accepting_tips"] == true
68
      assert is_binary(destination["fingerprint"])
69
      refute Map.has_key?(destination, "destination")
70
71
      read =
72
        conn
73
        |> put_forge_api_token("forum-tips-destination")
74
        |> get(~p"/api/v3/forum/tips/destination")
75
76
      assert %{"destination" => same} = json_response(read, 200)
77
      assert same["fingerprint"] == destination["fingerprint"]
78
      refute Map.has_key?(same, "destination")
79
    end
80
81
    test "a caller opts out of tips without deleting the destination", %{conn: conn} do
82
      authed = put_forge_api_token(conn, "forum-tips-optout")
83
84
      post(authed, ~p"/api/v3/forum/tips/destination", %{
85
        kind: "lnurl",
86
        destination: "lnurl1dp68gurn8ghj7"
87
      })
88
89
      updated =
90
        patch(
91
          put_forge_api_token(conn, "forum-tips-optout"),
92
          ~p"/api/v3/forum/tips/destination",
93
          %{accepting_tips: false}
94
        )
95
96
      assert %{"destination" => destination} = json_response(updated, 200)
97
      assert destination["accepting_tips"] == false
98
      assert destination["state"] == "active"
99
    end
100
101
    test "the destination endpoints need a bearer token", %{conn: conn} do
102
      assert json_response(get(conn, ~p"/api/v3/forum/tips/destination"), 401)
103
104
      assert json_response(
105
               post(conn, ~p"/api/v3/forum/tips/destination", %{
106
                 kind: "bolt12",
107
                 destination: "lno1x"
108
               }),
109
               401
110
             )
111
    end
112
  end
113
114
  describe "tips" do
115
    test "a tip settles and returns its counted weight", %{conn: conn, forum: forum} do
116
      author = author(forum)
117
118
      created =
119
        conn
120
        |> put_forge_api_token("forum-tips-payer")
121
        |> post(~p"/api/v3/forum/posts/#{author.post.id}/tips", %{
122
          amount_sats: 1_000,
123
          idempotency_key: Ecto.UUID.generate()
124
        })
125
126
      assert %{"tip" => tip, "receipts" => [receipt]} = json_response(created, 201)
127
      assert tip["state"] == "settled"
128
      assert tip["amount_sats"] == 1_000
129
      assert tip["counted_sats"] == 1_000
130
      assert tip["excluded_from_ranking"] == false
131
      assert receipt["kind"] == "settled"
132
      assert is_binary(receipt["payment_hash"])
133
    end
134
135
    test "a retry with the same key returns the same tip", %{conn: conn, forum: forum} do
136
      author = author(forum)
137
      key = Ecto.UUID.generate()
138
139
      first =
140
        conn
141
        |> put_forge_api_token("forum-tips-retry")
142
        |> post(~p"/api/v3/forum/posts/#{author.post.id}/tips", %{
143
          amount_sats: 400,
144
          idempotency_key: key
145
        })
146
147
      second =
148
        conn
149
        |> put_forge_api_token("forum-tips-retry")
150
        |> post(~p"/api/v3/forum/posts/#{author.post.id}/tips", %{
151
          amount_sats: 400,
152
          idempotency_key: key
153
        })
154
155
      assert json_response(first, 201)["tip"]["id"] == json_response(second, 201)["tip"]["id"]
156
      assert [_one_payment] = TipPaymentServiceStub.requests()
157
      assert Repo.reload!(author.post).tip_sats_total == 400
158
    end
159
160
    test "a failed payment answers 402 and counts nothing", %{conn: conn, forum: forum} do
161
      author = author(forum)
162
      TipPaymentServiceStub.fail("no_route")
163
164
      created =
165
        conn
166
        |> put_forge_api_token("forum-tips-failed")
167
        |> post(~p"/api/v3/forum/posts/#{author.post.id}/tips", %{
168
          amount_sats: 100,
169
          idempotency_key: Ecto.UUID.generate()
170
        })
171
172
      assert %{"tip" => tip} = json_response(created, 402)
173
      assert tip["state"] == "failed"
174
      assert tip["failure_code"] == "no_route"
175
      assert tip["counted_sats"] == 0
176
      assert Repo.reload!(author.post).tip_sats_counted == 0
177
    end
178
179
    test "an unavailable payment service answers 503 and keeps reads working", %{
180
      conn: conn,
181
      forum: forum
182
    } do
183
      author = author(forum)
184
      TipPaymentServiceStub.unavailable()
185
186
      created =
187
        conn
188
        |> put_forge_api_token("forum-tips-outage")
189
        |> post(~p"/api/v3/forum/posts/#{author.post.id}/tips", %{
190
          amount_sats: 100,
191
          idempotency_key: Ecto.UUID.generate()
192
        })
193
194
      assert json_response(created, 503)["error"]
195
196
      listed = get(conn, ~p"/api/v3/forum/topics?forum=general")
197
      assert [topic] = json_response(listed, 200)["topics"]
198
      assert topic["tip_sats"] == 0
199
    end
200
201
    test "tipping needs a bearer token", %{conn: conn, forum: forum} do
202
      author = author(forum)
203
204
      assert json_response(
205
               post(conn, ~p"/api/v3/forum/posts/#{author.post.id}/tips", %{amount_sats: 100}),
206
               401
207
             )
208
    end
209
210
    test "an unknown post is 404", %{conn: conn} do
211
      created =
212
        conn
213
        |> put_forge_api_token("forum-tips-missing")
214
        |> post(~p"/api/v3/forum/posts/#{Ecto.UUID.generate()}/tips", %{
215
          amount_sats: 100,
216
          idempotency_key: Ecto.UUID.generate()
217
        })
218
219
      assert json_response(created, 404)
220
    end
221
  end
222
223
  describe "received tips" do
224
    test "a recipient lists settlements it can verify in its own wallet", %{
225
      conn: conn,
226
      forum: forum
227
    } do
228
      author = author(forum)
229
230
      conn
231
      |> put_forge_api_token("forum-tips-recipient-payer")
232
      |> post(~p"/api/v3/forum/posts/#{author.post.id}/tips", %{
233
        amount_sats: 900,
234
        idempotency_key: Ecto.UUID.generate()
235
      })
236
      |> json_response(201)
237
238
      listed =
239
        conn
240
        |> put_forge_api_token("forum-tips-author")
241
        |> get(~p"/api/v3/forum/tips/received")
242
243
      export = json_response(listed, 200)
244
      assert export["custody"] == "self"
245
      assert export["received_sats"] == 900
246
      assert export["destination_fingerprint"] == author.destination.fingerprint
247
      refute export["destination"]
248
      assert [settlement] = export["settlements"]
249
      assert is_binary(settlement["payment_hash"])
250
    end
251
  end
252
253
  describe "public projections" do
254
    test "public pages show bounded totals and never a destination", %{conn: conn, forum: forum} do
255
      author = author(forum)
256
257
      conn
258
      |> put_forge_api_token("forum-tips-public")
259
      |> post(~p"/api/v3/forum/posts/#{author.post.id}/tips", %{
260
        amount_sats: 1_500,
261
        idempotency_key: Ecto.UUID.generate()
262
      })
263
      |> json_response(201)
264
265
      thread = get(conn, ~p"/api/v3/forum/topics/#{author.topic.id}")
266
      body = response(thread, 200)
267
268
      assert %{"topic" => topic, "posts" => [post]} = Jason.decode!(body)
269
      assert topic["tip_sats"] == 1_500
270
      assert post["tip_sats"] == 1_500
271
      assert post["tip_count"] == 1
272
      refute body =~ "lno1qsgauthor"
273
      refute body =~ "destination"
274
    end
275
  end
276
end
test/openagents_web/live/forum_tips_live_test.exs added +131

@@ -0,0 +1,131 @@

1
defmodule OpenAgentsWeb.ForumTipsLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.Forum
7
  alias OpenAgents.Forum.{TipPaymentServiceStub, Tips}
8
  alias OpenAgents.Repo
9
10
  setup %{conn: conn} do
11
    TipPaymentServiceStub.settle()
12
13
    {:ok, forum} =
14
      %Forum.Forum{}
15
      |> Forum.Forum.changeset(%{slug: "general", title: "General"})
16
      |> Repo.insert()
17
18
    {:ok, conn: conn, forum: forum}
19
  end
20
21
  defp sign_in(conn, key) do
22
    user = github_user(key)
23
    {Plug.Test.init_test_session(conn, %{"user_id" => user.id}), user}
24
  end
25
26
  defp author(forum, key) do
27
    {_conn, user} = sign_in(build_conn(), key)
28
    actor_ref = "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20"
29
30
    {:ok, link} = Forum.start_actor_link(user, actor_ref)
31
    {:ok, _linked} = Forum.approve_actor_link(link)
32
33
    {:ok, destination} =
34
      Tips.register_destination(%{
35
        user_id: user.id,
36
        kind: "bolt12",
37
        destination: "lno1qsgliveauthor",
38
        label: "Phone wallet"
39
      })
40
41
    {:ok, topic} =
42
      Forum.create_topic(forum, %{
43
        title: "Hello world",
44
        slug: "hello-world",
45
        body_text: "First post body",
46
        idempotency_key: Ecto.UUID.generate(),
47
        actor_ref: actor_ref,
48
        actor_display_name: "Orrery",
49
        actor_slug: "orrery"
50
      })
51
52
    [post] = Forum.list_posts(topic)
53
54
    %{user: user, destination: destination, topic: topic, post: post}
55
  end
56
57
  test "a signed-in reader saves a destination and sees only its fingerprint", %{conn: conn} do
58
    {conn, user} = sign_in(conn, "forum-tips-live-owner")
59
60
    {:ok, view, _html} = live(conn, ~p"/forum/tips")
61
62
    html =
63
      view
64
      |> form("#tip-destination-form", %{
65
        destination: %{kind: "bolt12", destination: "lno1qsgowner", label: "Phone wallet"}
66
      })
67
      |> render_submit()
68
69
    destination = Tips.active_destination(user.id)
70
71
    assert html =~ destination.fingerprint
72
    refute html =~ "lno1qsgowner"
73
  end
74
75
  test "a reader opts out of tips and keeps the destination", %{conn: conn} do
76
    {conn, user} = sign_in(conn, "forum-tips-live-optout")
77
78
    {:ok, _destination} =
79
      Tips.register_destination(%{user_id: user.id, kind: "lnurl", destination: "lnurl1dp68g"})
80
81
    {:ok, view, _html} = live(conn, ~p"/forum/tips")
82
83
    render_click(view, "toggle_accepting")
84
85
    assert Tips.active_destination(user.id).accepting_tips == false
86
  end
87
88
  test "tipping a post from the thread settles and shows the new total", %{
89
    conn: conn,
90
    forum: forum
91
  } do
92
    author = author(forum, "forum-tips-live-author")
93
    {conn, _payer} = sign_in(conn, "forum-tips-live-payer")
94
95
    {:ok, view, _html} = live(conn, ~p"/forum/t/#{author.topic.id}")
96
97
    html = render_click(view, "tip", %{"id" => author.post.id, "amount" => "1000"})
98
99
    assert html =~ "Sent 1000 sats"
100
101
    render_click(view, "tip", %{"id" => author.post.id, "amount" => "1000"})
102
103
    assert Repo.reload!(author.post).tip_sats_counted == 2_000
104
    assert render(view) =~ "2000 sats"
105
  end
106
107
  test "a thread never renders a payment destination", %{conn: conn, forum: forum} do
108
    author = author(forum, "forum-tips-live-privacy")
109
    {conn, _payer} = sign_in(conn, "forum-tips-live-privacy-payer")
110
111
    {:ok, view, _html} = live(conn, ~p"/forum/t/#{author.topic.id}")
112
113
    html = render_click(view, "tip", %{"id" => author.post.id, "amount" => "1000"})
114
115
    refute html =~ "lno1qsgliveauthor"
116
    refute html =~ author.destination.destination
117
  end
118
119
  test "a payment outage tells the payer nothing was sent", %{conn: conn, forum: forum} do
120
    author = author(forum, "forum-tips-live-outage")
121
    {conn, _payer} = sign_in(conn, "forum-tips-live-outage-payer")
122
    TipPaymentServiceStub.unavailable()
123
124
    {:ok, view, _html} = live(conn, ~p"/forum/t/#{author.topic.id}")
125
126
    html = render_click(view, "tip", %{"id" => author.post.id, "amount" => "1000"})
127
128
    assert html =~ "payment service is unavailable"
129
    assert Repo.reload!(author.post).tip_sats_total == 0
130
  end
131
end
test/support/forum/tip_payment_service.ex added +84

@@ -0,0 +1,84 @@

1
defmodule OpenAgents.Forum.TipPaymentServiceStub do
2
  @moduledoc """
3
  A payment service double for tests.
4
5
  Each test scripts the outcome it wants with `settle/0`, `fail/1`, or
6
  `unavailable/0`, and the stub records every request it received so a test can
7
  prove a retry never asked for a second payment.
8
9
  The script belongs to the test process. A LiveView or a spawned task reaches
10
  the same script through `$callers`, the way the Ecto sandbox finds its owner,
11
  so a tip paid from a LiveView follows the outcome its test asked for.
12
  """
13
14
  @behaviour OpenAgents.Forum.Tips.PaymentService
15
16
  @table __MODULE__
17
18
  @doc "Creates the script table, owned by the process that calls it."
19
  def install do
20
    case :ets.whereis(@table) do
21
      :undefined -> :ets.new(@table, [:named_table, :public, :set])
22
      reference -> reference
23
    end
24
  end
25
26
  def settle(fee_sats \\ 0), do: put(:outcome, {:settle, fee_sats})
27
28
  def fail(failure_code), do: put(:outcome, {:fail, failure_code})
29
30
  def unavailable, do: put(:outcome, :unavailable)
31
32
  @doc "Every request the stub received, oldest first."
33
  def requests, do: Enum.reverse(get(:requests, []))
34
35
  @impl true
36
  def pay(request) do
37
    put(:requests, [request | get(:requests, [])])
38
39
    case get(:outcome, :unavailable) do
40
      {:settle, fee_sats} ->
41
        {:ok,
42
         %{
43
           payment_hash:
44
             Base.encode16(:crypto.hash(:sha256, request.idempotency_key), case: :lower),
45
           fee_sats: fee_sats,
46
           settled_at: DateTime.utc_now()
47
         }}
48
49
      {:fail, failure_code} ->
50
        {:error, {:payment_failed, failure_code}}
51
52
      :unavailable ->
53
        {:error, :payment_service_unavailable}
54
    end
55
  end
56
57
  defp put(key, value) do
58
    install()
59
    :ets.insert(@table, {{owner(), key}, value})
60
    :ok
61
  end
62
63
  defp get(key, default) do
64
    install()
65
66
    case :ets.lookup(@table, {owner(), key}) do
67
      [{_key, value}] -> value
68
      [] -> default
69
    end
70
  end
71
72
  # The test process owns the script. A process it started, such as a LiveView,
73
  # inherits it through `$callers`.
74
  defp owner do
75
    [self() | Process.get(:"$callers", [])]
76
    |> Enum.find(&scripted?/1)
77
    |> case do
78
      nil -> self()
79
      pid -> pid
80
    end
81
  end
82
83
  defp scripted?(pid), do: :ets.member(@table, {pid, :outcome})
84
end
test/test_helper.exs modified +5

@@ -26,3 +26,8 @@ if cluster_stage? do

26 26
end
27 27
28 28
Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, :manual)
29
30
# The forum tip payment double keeps each test's scripted outcome here. The
31
# suite process owns the table so a finished test cannot take it down while a
32
# concurrent test is still reading its own script.
33
OpenAgents.Forum.TipPaymentServiceStub.install()

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