tarakan/lib/tarakan/market/bounty.ex
Maxfield Luke af6077b9c3
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
2026-07-29 04:43:40 -04:00

144 lines
4.5 KiB
Elixir

defmodule Tarakan.Market.Bounty do
@moduledoc """
A contract (a "bounty" internally): a funded reward for security work on a public target.
Exactly one target reference must be set, matching `target_type`
(`repository_id`, `pattern_key`, or `canonical_finding_id`). The shape is
enforced at the changeset level (see `validate_target/1`); the database only
constrains the enum-like columns.
Funding is either fiat (Stripe Checkout escrow, `amount_cents`) or platform
credits (`credit_amount`, debited from the sponsor at creation). The
platform `take_rate` is snapshotted at creation so later config changes
never rewrite open offers.
"""
use Ecto.Schema
import Ecto.Changeset
alias Tarakan.Accounts.Account
alias Tarakan.Market.BountyClaim
alias Tarakan.Repositories.Repository
alias Tarakan.Scans.CanonicalFinding
@target_types ~w(repository infestation finding)
@fundings ~w(fiat credits)
@statuses ~w(pending_funding open claimed fulfilled payout_pending paid cancelled expired)
schema "bounties" do
field :public_id, Ecto.UUID, autogenerate: true
field :target_type, :string
field :pattern_key, :string
field :title, :string
field :description, :string
field :funding, :string
field :amount_cents, :integer
field :credit_amount, :integer
field :take_rate, :decimal
field :status, :string, default: "pending_funding"
field :stripe_checkout_session_id, :string
field :expires_at, :utc_datetime_usec
belongs_to :sponsor_account, Account
belongs_to :repository, Repository
belongs_to :canonical_finding, CanonicalFinding
belongs_to :winner_account, Account
has_many :claims, BountyClaim
timestamps(type: :utc_datetime_usec)
end
def target_types, do: @target_types
def fundings, do: @fundings
def statuses, do: @statuses
# Only Tarakan.Market builds the attrs map for this changeset - public form
# params never reach it directly - so programmatic fields (target refs,
# take_rate, status) are safe to cast, and they must be cast so the
# validations below can see them.
@doc false
def changeset(bounty, attrs) do
bounty
|> cast(attrs, [
:target_type,
:repository_id,
:pattern_key,
:canonical_finding_id,
:title,
:description,
:funding,
:amount_cents,
:credit_amount,
:take_rate,
:status,
:expires_at
])
|> validate_required([:target_type, :title, :description, :funding, :take_rate])
|> validate_inclusion(:target_type, @target_types)
|> validate_inclusion(:funding, @fundings)
|> validate_inclusion(:status, @statuses)
|> validate_length(:title, max: 200)
|> validate_length(:description, max: 5_000)
|> validate_amount()
|> validate_target()
|> unique_constraint(:public_id)
|> foreign_key_constraint(:repository_id)
|> foreign_key_constraint(:canonical_finding_id)
|> check_constraint(:target_type, name: :bounties_target_type_must_be_valid)
|> check_constraint(:funding, name: :bounties_funding_must_be_valid)
|> check_constraint(:status, name: :bounties_status_must_be_valid)
end
defp validate_amount(changeset) do
case get_field(changeset, :funding) do
"fiat" ->
changeset
|> validate_required([:amount_cents], message: "must be at least $1.00")
|> validate_number(:amount_cents,
greater_than_or_equal_to: 100,
message: "must be at least $1.00"
)
"credits" ->
changeset
|> validate_required([:credit_amount])
|> validate_number(:credit_amount, greater_than: 0)
_other ->
changeset
end
end
# Exactly one target reference, and it must match target_type. The database
# cannot express the type/ref pairing, so this stays changeset-only.
defp validate_target(changeset) do
type = get_field(changeset, :target_type)
refs = [
repository_id: get_field(changeset, :repository_id),
pattern_key: present(get_field(changeset, :pattern_key)),
canonical_finding_id: get_field(changeset, :canonical_finding_id)
]
set = for {key, value} <- refs, not is_nil(value), do: key
expected =
case type do
"repository" -> [:repository_id]
"infestation" -> [:pattern_key]
"finding" -> [:canonical_finding_id]
_other -> []
end
if set == expected do
changeset
else
add_error(changeset, :target_type, "must reference exactly one #{type} target")
end
end
defp present(nil), do: nil
defp present(""), do: nil
defp present(value), do: value
end