defmodule RemitMd.Wallet do @moduledoc """ Primary remit.md client. All payment operations are functions in this module. ## Quickstart # Testing with MockRemit (no network, no keys needed) {:ok, mock} = RemitMd.MockRemit.start_link() wallet = RemitMd.Wallet.new(mock: mock, address: "0xYourAgent") {:ok, tx} = RemitMd.Wallet.pay(wallet, "0xRecipient", "1.50") # Production (private key from env) wallet = RemitMd.Wallet.from_env() {:ok, tx} = RemitMd.Wallet.pay(wallet, "0xRecipient", "1.50") IO.puts("Sent! tx: \#{tx.tx_hash}") Private keys are held only in the `Signer` and never appear in `inspect/1` or log output. """ alias RemitMd.{CliSigner, Error, Http, MockRemit, MockSigner, PrivateKeySigner} alias RemitMd.Models.{ Balance, Bounty, BountySubmission, Budget, ContractAddresses, Deposit, Escrow, MintResponse, PermitSignature, Reputation, SpendingSummary, Stream, Tab, TabCharge, Transaction, TransactionList, WalletStatus, Webhook } @min_amount Decimal.new("0.000001") # Known USDC contract addresses per chain (EIP-2612 compatible). @usdc_addresses %{ "base-sepolia" => "0x2d846325766921935f37d5b4478196d3ef93707c", "base" => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "localhost" => "0x5FbDB2315678afecb367f032d93F642f64180aa3" } defstruct [:signer, :transport, :mock_pid, :address, :chain, :chain_key] @type t :: %__MODULE__{ signer: term(), transport: term(), mock_pid: pid() | nil, address: String.t(), chain: String.t() | nil, chain_key: String.t() | nil } # ─── Constructors ───────────────────────────────────────────────────────── @doc """ Create a wallet from a private key. ## Options - `:private_key` - 0x-prefixed 32-byte secp256k1 private key (required unless `:signer` given) - `:signer` - custom `RemitMd.Signer` implementation - `:chain` - `"base"` | `"base_sepolia"` (default: `"base"`) - `:api_url` - override API base URL - `:mock` - pid of a running `RemitMd.MockRemit` (disables real HTTP) - `:address` - address to use when in mock mode (optional, defaults to MockSigner address) """ def new(opts) when is_list(opts) do mock_pid = Keyword.get(opts, :mock) if mock_pid do signer = MockSigner.new(Keyword.get(opts, :address, MockSigner.new().address)) %__MODULE__{ signer: signer, transport: nil, mock_pid: mock_pid, address: signer.address, chain: "base", chain_key: "base-sepolia" } else signer = cond do key = Keyword.get(opts, :private_key) -> PrivateKeySigner.new(key) s = Keyword.get(opts, :signer) -> s true -> raise Error.new(Error.unauthorized(), "Provide :private_key or :signer") end transport = Http.new(opts |> Keyword.put(:signer, signer)) address = get_address(signer) raw_chain = opts |> Keyword.get(:chain, "base") # chain_key preserves the full name for USDC lookups (e.g. "base_sepolia" → "base-sepolia") chain_key = raw_chain |> String.replace("_", "-") # Send the full chain name (e.g. "base-sepolia") in API request bodies. # The server expects the full chain name, matching the TS SDK. chain = chain_key %__MODULE__{ signer: signer, transport: transport, mock_pid: nil, address: address, chain: chain, chain_key: chain_key } end end @doc """ Build a wallet from environment variables. Credential priority: 1. `remit` CLI available (binary on PATH, keystore exists, REMIT_KEY_PASSWORD set) 2. `REMITMD_KEY` (or deprecated `REMITMD_PRIVATE_KEY`) - hex-encoded private key Optional: `REMITMD_CHAIN`, `REMITMD_API_URL`, `REMITMD_ROUTER_ADDRESS` """ def from_env do chain = System.get_env("REMITMD_CHAIN", "base") api_url = System.get_env("REMITMD_API_URL") router_address = System.get_env("REMITMD_ROUTER_ADDRESS") # Build the signer: priority 1 = CLI signer, priority 2 = private key signer_opts = resolve_signer_from_env() opts = signer_opts ++ [chain: chain] opts = if api_url, do: Keyword.put(opts, :api_url, api_url), else: opts opts = if router_address, do: Keyword.put(opts, :router_address, router_address), else: opts new(opts) end defp resolve_signer_from_env do cond do # Priority 1: CLI signer (remit binary + keystore + password) CliSigner.available?() -> case CliSigner.new() do {:ok, signer} -> [signer: signer] {:error, reason} -> raise Error.new( Error.server_error(), "remit CLI is available but failed to initialize: #{reason}" ) end # Priority 2: Private key true -> key = System.get_env("REMITMD_KEY") || System.get_env("REMITMD_PRIVATE_KEY") if System.get_env("REMITMD_PRIVATE_KEY") && !System.get_env("REMITMD_KEY") do IO.warn("REMITMD_PRIVATE_KEY is deprecated, use REMITMD_KEY instead") end unless key do raise Error.new( Error.unauthorized(), "No signing credentials found. Install the remit CLI " <> "(macOS: brew install remit-md/tap/remit, " <> "Linux: curl -fsSL https://remit.md/install.sh | sh, " <> "Windows: winget install remit-md.remit) " <> "or set REMITMD_KEY." ) end [private_key: key] end end # ─── Balance & Analytics ────────────────────────────────────────────────── @doc """ Fetch wallet status including balance, tier info, and permit nonce. Returns `{:ok, %RemitMd.Models.WalletStatus{}}` or `{:error, %RemitMd.Error{}}`. """ def status(%__MODULE__{} = w) do with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_balance(pid, w.address) end, fn -> do_http_get(w, "/status/#{w.address}") end) do {:ok, WalletStatus.from_map(data)} end end @doc """ Fetch current USDC balance (convenience wrapper around status/1). Returns `{:ok, balance_string}` or `{:error, %RemitMd.Error{}}`. """ def balance(%__MODULE__{} = w) do with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_balance(pid, w.address) end, fn -> do_http_get(w, "/status/#{w.address}") end) do # Support both WalletStatus format and legacy Balance format balance_val = Map.get(data, "balance") || Map.get(data, "usdc") {:ok, Balance.from_map(%{"address" => w.address, "usdc" => balance_val})} end end @doc """ Fetch transaction history. Options: `:limit` (default 50), `:offset` (default 0). """ def history(%__MODULE__{} = w, opts \\ []) do limit = Keyword.get(opts, :limit, 50) offset = Keyword.get(opts, :offset, 0) with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_history(pid, w.address, opts) end, fn -> do_http_get(w, "/wallet/transactions?limit=#{limit}&offset=#{offset}") end) do {:ok, TransactionList.from_map(data)} end end @doc """ Fetch reputation score for an address (defaults to this wallet's address). """ def reputation(%__MODULE__{} = w, address \\ nil) do addr = address || w.address with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_reputation(pid, addr) end, fn -> do_http_get(w, "/reputation/#{addr}") end) do {:ok, Reputation.from_map(data)} end end @doc """ Fetch operator-set spending budget. """ def budget(%__MODULE__{} = w) do with {:ok, data} <- call_mock_or_http(w, fn _pid -> {:ok, %{}} # Budget not implemented in mock end, fn -> do_http_get(w, "/wallet/budget") end) do {:ok, Budget.from_map(data)} end end @doc """ Fetch spending summary analytics. Options: `:limit` - number of days to include (default 30). """ def spending(%__MODULE__{} = w, opts \\ []) do limit = Keyword.get(opts, :limit, 30) with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_spending(pid, w.address, opts) end, fn -> do_http_get(w, "/wallet/spending?days=#{limit}") end) do {:ok, SpendingSummary.from_map(data)} end end # ─── Payment Models ─────────────────────────────────────────────────────── @doc """ Send USDC directly to a recipient (pay-as-you-go). ## Parameters - `to` - 0x-prefixed recipient address - `amount_usdc` - amount as a string, e.g. `"1.50"` ## Options - `:description` - human-readable memo - `:metadata` - map of arbitrary key-value pairs ## Example {:ok, tx} = RemitMd.Wallet.pay(wallet, "0xRecipient", "1.50") """ def pay(%__MODULE__{} = w, to, amount_usdc, opts \\ []) do with :ok <- validate_address(to), :ok <- validate_amount(amount_usdc) do permit = resolve_permit(w, "router", amount_usdc, opts) nonce = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) body = %{ to: to, amount: amount_usdc, task: Keyword.get(opts, :description) || "", chain: w.chain, nonce: nonce, signature: "0x", metadata: Keyword.get(opts, :metadata) } body = if permit, do: Map.put(body, :permit, PermitSignature.to_map(permit)), else: body with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_pay(pid, w.address, to, amount_usdc, opts) end, fn -> do_http_post(w, "/payments/direct", body) end) do {:ok, Transaction.from_map(data)} end end end @doc """ Create an escrow payment with milestone-based release (two-step flow). Step 1: POST /invoices to create the invoice Step 2: POST /escrows with invoice_id + permit to fund the escrow ## Options - `:description` - task description (sent as `task`) - `:escrow_timeout` - seconds until escrow expires (default: 7 days) ## Example {:ok, esc} = RemitMd.Wallet.create_escrow(wallet, "0xContractor", "50.00", description: "Design and deploy") """ def create_escrow(%__MODULE__{} = w, to, amount_usdc, opts \\ []) do with :ok <- validate_address(to), :ok <- validate_amount(amount_usdc) do call_mock_or_http(w, fn pid -> MockRemit.do_create_escrow(pid, w.address, to, amount_usdc, opts) end, fn -> # Step 1: Create invoice invoice_id = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) nonce = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) memo = Keyword.get(opts, :description, "") escrow_timeout = Keyword.get(opts, :escrow_timeout, 7 * 86_400) invoice_body = %{ id: invoice_id, chain: w.chain, from_agent: String.downcase(w.address), to_agent: String.downcase(to), amount: amount_usdc, type: "escrow", task: memo, nonce: nonce, signature: "0x", escrow_timeout: escrow_timeout } do_http_post(w, "/invoices", invoice_body) # Step 2: Fund the escrow permit = resolve_permit(w, "escrow", amount_usdc, opts) escrow_body = %{invoice_id: invoice_id} escrow_body = if permit, do: Map.put(escrow_body, :permit, PermitSignature.to_map(permit)), else: escrow_body do_http_post(w, "/escrows", escrow_body) end) |> case do {:ok, data} -> {:ok, Escrow.from_map(data)} error -> error end end end @doc """ Release an escrow milestone, transferring its share to the recipient. """ def pay_milestone(%__MODULE__{} = w, escrow_id, milestone_id) do body = %{milestone_id: milestone_id} with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_pay_milestone(pid, w.address, escrow_id, milestone_id) end, fn -> do_http_post(w, "/escrows/#{escrow_id}/claim-start", body) end) do {:ok, Escrow.from_map(data)} end end @doc "Provider claims start on an escrow (begins the escrow timer)." def claim_start(%__MODULE__{} = w, escrow_id) do with {:ok, data} <- do_call(w, :post, "/escrows/#{escrow_id}/claim-start", %{}) do {:ok, Transaction.from_map(data)} end end @doc "Release an escrow, transferring funds to the provider." def release_escrow(%__MODULE__{} = w, escrow_id) do with {:ok, data} <- do_call(w, :post, "/escrows/#{escrow_id}/release", %{}) do {:ok, Transaction.from_map(data)} end end @doc "Release a specific milestone of an escrow." def release_milestone(%__MODULE__{} = w, invoice_id, milestone_index) do body = %{milestone_ids: [to_string(milestone_index)]} with {:ok, data} <- do_call(w, :post, "/escrows/#{invoice_id}/release", body) do {:ok, Transaction.from_map(data)} end end @doc """ Submit evidence for an escrow claim. ## Parameters - `invoice_id` - escrow invoice ID - `evidence_uri` - URI pointing to the evidence - `milestone_index` - milestone index (default: 0) """ def submit_evidence(%__MODULE__{} = w, invoice_id, evidence_uri, milestone_index \\ 0) do body = %{evidence_uri: evidence_uri, milestone_index: milestone_index} with {:ok, data} <- do_call(w, :post, "/escrows/#{invoice_id}/claim-start", body) do {:ok, Transaction.from_map(data)} end end @doc "Cancel an escrow and return funds to payer." def cancel_escrow(%__MODULE__{} = w, escrow_id) do with {:ok, data} <- call_mock_or_http(w, fn pid -> MockRemit.do_cancel_escrow(pid, w.address, escrow_id) end, fn -> do_http_post(w, "/escrows/#{escrow_id}/cancel", %{}) end) do {:ok, Transaction.from_map(data)} end end @doc """ Create a metered tab (batch payment channel). ## Parameters - `provider` - 0x-prefixed provider address - `limit_amount` - maximum tab limit (string USDC, e.g. `"10.00"`) - `per_unit` - cost per unit/call (string USDC, e.g. `"0.10"`) ## Options - `:expires_in` - seconds from now until tab expires (default: 1 day) - `:permit` - `%PermitSignature{}` for gasless approval ## Example {:ok, tab} = RemitMd.Wallet.create_tab(wallet, "0xProvider", "10.00", "0.10") """ def create_tab(%__MODULE__{} = w, provider, limit_amount, per_unit, opts \\ []) do with :ok <- validate_address(provider), :ok <- validate_amount(limit_amount) do permit = resolve_permit(w, "tab", limit_amount, opts) expires_in = Keyword.get(opts, :expires_in, 86_400) expiry = :os.system_time(:second) + expires_in body = %{ chain: w.chain, provider: provider, limit_amount: limit_amount, per_unit: per_unit, expiry: expiry } body = if permit, do: Map.put(body, :permit, PermitSignature.to_map(permit)), else: body with {:ok, data} <- do_call(w, :post, "/tabs", body) do {:ok, Tab.from_map(data)} end end end @doc """ Charge a tab with an EIP-712 TabCharge signature (provider-side). ## Parameters - `tab_id` - tab UUID - `amount` - charge amount (number) - `cumulative` - cumulative total charged so far (number) - `call_count` - total number of charges (integer) - `provider_sig` - EIP-712 TabCharge signature from `sign_tab_charge/4` """ def charge_tab(%__MODULE__{} = w, tab_id, amount, cumulative, call_count, provider_sig) do body = %{ amount: amount, cumulative: cumulative, call_count: call_count, provider_sig: provider_sig } with {:ok, data} <- do_call(w, :post, "/tabs/#{tab_id}/charge", body) do {:ok, TabCharge.from_map(data)} end end @doc """ Close a tab with final settlement. ## Options - `:final_amount` - final settlement amount (number, default 0) - `:provider_sig` - provider's EIP-712 signature (default "0x") """ def close_tab(%__MODULE__{} = w, tab_id, opts \\ []) do final_amount = Keyword.get(opts, :final_amount, 0) provider_sig = Keyword.get(opts, :provider_sig, "0x") body = %{final_amount: final_amount, provider_sig: provider_sig} with {:ok, data} <- do_call(w, :post, "/tabs/#{tab_id}/close", body) do {:ok, Transaction.from_map(data)} end end @doc """ Sign a TabCharge EIP-712 message (provider-side). ## Parameters - `tab_contract` - Tab contract address (verifyingContract for the domain) - `tab_id` - UUID of the tab (will be encoded as bytes32) - `total_charged` - cumulative charged amount in USDC base units (integer, uint96) - `call_count` - number of charges made (integer, uint32) Returns a 0x-prefixed hex signature. """ def sign_tab_charge(%__MODULE__{} = w, tab_contract, tab_id, total_charged, call_count) do keccak = &RemitMd.Keccak.hash/1 # Domain separator: RemitTab domain_type_hash = keccak.("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") name_hash = keccak.("RemitTab") version_hash = keccak.("1") chain_id_enc = <> contract_enc = address_to_bytes32(tab_contract) domain_separator = keccak.(domain_type_hash <> name_hash <> version_hash <> chain_id_enc <> contract_enc) # Struct hash: TabCharge(bytes32 tabId, uint96 totalCharged, uint32 callCount) tab_charge_type_hash = keccak.("TabCharge(bytes32 tabId,uint96 totalCharged,uint32 callCount)") # Encode tab_id as bytes32: ASCII chars padded to 32 bytes tab_id_bytes = String.slice(tab_id, 0, 32) padded = tab_id_bytes <> :binary.copy(<<0>>, 32 - byte_size(tab_id_bytes)) total_enc = <> count_enc = <> struct_hash = keccak.(tab_charge_type_hash <> padded <> total_enc <> count_enc) # Final: keccak256(0x1901 || domainSeparator || structHash) digest = keccak.(<<0x19, 0x01>> <> domain_separator <> struct_hash) call_sign(w.signer, digest) end # ─── EIP-2612 Permit ───────────────────────────────────────────────── @doc """ Sign an EIP-2612 permit for USDC approval. Domain: name="USD Coin", version="2", chainId, verifyingContract=USDC address Type: Permit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline) ## Parameters - `spender` - contract address that will be approved as spender - `value` - amount in USDC base units (6 decimals, integer) - `deadline` - permit deadline (Unix timestamp) ## Options - `:nonce` - current permit nonce for this wallet (default: 0) - `:usdc_address` - override the USDC contract address Returns `%PermitSignature{}`. """ def sign_usdc_permit(%__MODULE__{} = w, spender, value, deadline, opts \\ []) do keccak = &RemitMd.Keccak.hash/1 nonce = Keyword.get(opts, :nonce, 0) usdc_addr = Keyword.get(opts, :usdc_address) || @usdc_addresses[w.chain_key] || raise Error.new(Error.chain_unsupported(), "No USDC address for chain #{w.chain_key}") # Domain separator for USDC (EIP-2612) domain_type_hash = keccak.("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") name_hash = keccak.("USD Coin") version_hash = keccak.("2") chain_id_enc = <> contract_enc = address_to_bytes32(usdc_addr) domain_separator = keccak.(domain_type_hash <> name_hash <> version_hash <> chain_id_enc <> contract_enc) # Permit struct hash permit_type_hash = keccak.("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") owner_enc = address_to_bytes32(w.address) spender_enc = address_to_bytes32(spender) value_enc = <> nonce_enc = <> deadline_enc = <> struct_hash = keccak.(permit_type_hash <> owner_enc <> spender_enc <> value_enc <> nonce_enc <> deadline_enc) # Final: keccak256(0x1901 || domainSeparator || structHash) digest = keccak.(<<0x19, 0x01>> <> domain_separator <> struct_hash) sig_hex = call_sign(w.signer, digest) # Parse r, s, v from the 65-byte signature sig_str = String.trim_leading(sig_hex, "0x") r = "0x" <> String.slice(sig_str, 0, 64) s = "0x" <> String.slice(sig_str, 64, 64) v = String.slice(sig_str, 128, 2) |> String.to_integer(16) %PermitSignature{value: value, deadline: deadline, v: v, r: r, s: s} end @doc """ Convenience: sign an EIP-2612 permit for USDC approval. Auto-fetches the on-chain nonce and sets a default deadline (1 hour from now). ## Parameters - `spender` - contract address to approve (e.g. router, escrow) - `amount` - amount in USDC (string, e.g. `"5.00"`) ## Options - `:deadline` - optional Unix timestamp; defaults to 1 hour from now Returns `%PermitSignature{}`. """ def sign_permit(%__MODULE__{} = w, spender, amount, opts \\ []) do usdc_addr = @usdc_addresses[w.chain_key] || raise Error.new(Error.chain_unsupported(), "No USDC address for chain #{w.chain_key}") nonce = fetch_permit_nonce(w, usdc_addr) deadline = Keyword.get(opts, :deadline) || (:os.system_time(:second) + 3600) raw = amount |> to_string() |> Decimal.new() |> Decimal.mult(1_000_000) |> Decimal.round(0) |> Decimal.to_integer() sign_usdc_permit(w, spender, raw, deadline, nonce: nonce, usdc_address: usdc_addr) end @doc """ Start a payment stream (continuous USDC flow per second). ## Parameters - `payee` - 0x-prefixed recipient address - `rate_per_second` - USDC per second (string, e.g. `"0.01"`) - `max_total` - maximum total USDC for the stream (string, e.g. `"5.00"`) ## Options - `:permit` - `%PermitSignature{}` for gasless approval ## Example {:ok, stream} = RemitMd.Wallet.create_stream(wallet, "0xWorker", "0.01", "5.00") """ def create_stream(%__MODULE__{} = w, payee, rate_per_second, max_total, opts \\ []) do with :ok <- validate_address(payee) do permit = resolve_permit(w, "stream", max_total, opts) body = %{ chain: w.chain, payee: payee, rate_per_second: rate_per_second, max_total: max_total } body = if permit, do: Map.put(body, :permit, PermitSignature.to_map(permit)), else: body with {:ok, data} <- do_call(w, :post, "/streams", body) do {:ok, Stream.from_map(data)} end end end @doc "Close a running payment stream." def close_stream(%__MODULE__{} = w, stream_id) do with {:ok, data} <- do_call(w, :post, "/streams/#{stream_id}/close", %{}) do {:ok, Transaction.from_map(data)} end end @doc """ Create a bounty - any agent that completes the task earns the reward. ## Parameters - `amount` - reward amount (string USDC, e.g. `"5.00"`) - `task_description` - description of the task - `deadline` - unix timestamp when the bounty expires ## Options - `:max_attempts` - maximum number of submissions (default: 10) - `:permit` - `%PermitSignature{}` for gasless approval ## Example deadline = :os.system_time(:second) + 3600 {:ok, bounty} = RemitMd.Wallet.create_bounty(wallet, "10.00", "Summarize this 100-page PDF", deadline) """ def create_bounty(%__MODULE__{} = w, amount, task_description, deadline, opts \\ []) do with :ok <- validate_amount(amount) do permit = resolve_permit(w, "bounty", amount, opts) body = %{ chain: w.chain, amount: amount, task_description: task_description, deadline: deadline, max_attempts: Keyword.get(opts, :max_attempts, 10) } body = if permit, do: Map.put(body, :permit, PermitSignature.to_map(permit)), else: body with {:ok, data} <- do_call(w, :post, "/bounties", body) do {:ok, Bounty.from_map(data)} end end end @doc """ Submit evidence for a bounty. ## Parameters - `bounty_id` - bounty UUID - `evidence_hash` - 0x-prefixed keccak256 hash of the evidence Returns `{:ok, %BountySubmission{}}`. """ def submit_bounty(%__MODULE__{} = w, bounty_id, evidence_hash) do body = %{evidence_hash: evidence_hash} with {:ok, data} <- do_call(w, :post, "/bounties/#{bounty_id}/submit", body) do {:ok, BountySubmission.from_map(data)} end end @doc """ Award a bounty to a specific submission. ## Parameters - `bounty_id` - bounty UUID - `submission_id` - integer ID of the winning submission """ def award_bounty(%__MODULE__{} = w, bounty_id, submission_id) when is_integer(submission_id) do body = %{submission_id: submission_id} with {:ok, data} <- do_call(w, :post, "/bounties/#{bounty_id}/award", body) do {:ok, Bounty.from_map(data)} end end @doc """ List bounties with optional filters. ## Options - `:status` - filter by status (open, claimed, awarded, expired). Default: `"open"`. - `:poster` - filter by poster wallet address. - `:submitter` - filter by submitter wallet address. - `:limit` - max results (default 20, max 100). """ def list_bounties(%__MODULE__{} = w, opts \\ []) do status = Keyword.get(opts, :status, "open") poster = Keyword.get(opts, :poster) submitter = Keyword.get(opts, :submitter) limit = Keyword.get(opts, :limit, 20) params = ["limit=#{limit}"] params = if status, do: ["status=#{status}" | params], else: params params = if poster, do: ["poster=#{poster}" | params], else: params params = if submitter, do: ["submitter=#{submitter}" | params], else: params qs = Enum.join(Enum.reverse(params), "&") with {:ok, data} <- do_call(w, :get, "/bounties?#{qs}", nil) do items = if is_map(data) && Map.has_key?(data, "data"), do: data["data"], else: data bounties = Enum.map(items || [], &Bounty.from_map/1) {:ok, bounties} end end @doc """ Register a webhook endpoint to receive event notifications. ## Parameters - `url` - the HTTPS endpoint that will receive POST notifications - `events` - list of event types to subscribe to (e.g. `["payment.sent", "escrow.funded"]`) ## Options - `:chains` - list of chain names to filter by (e.g. `["base"]`). Omit for all chains. ## Example {:ok, webhook} = RemitMd.Wallet.register_webhook(wallet, "https://example.com/webhooks", ["payment.sent", "escrow.funded"]) """ def register_webhook(%__MODULE__{} = w, url, events, opts \\ []) do chains = Keyword.get(opts, :chains) || [w.chain] body = %{url: url, events: events, chains: chains} with {:ok, data} <- do_call(w, :post, "/webhooks", body) do {:ok, Webhook.from_map(data)} end end @doc """ Generate a one-time URL for the operator to fund this wallet. ## Options - `:messages` - list of maps with `:role` ("agent"/"system") and `:text` - `:agent_name` - agent display name shown on the funding page - `:permit` - `%PermitSignature{}` for gasless approval (auto-signed if omitted) Returns `{:ok, %RemitMd.Models.LinkResponse{}}` or `{:error, %RemitMd.Error{}}`. """ def create_fund_link(%__MODULE__{} = w, opts \\ []) do permit = resolve_permit(w, "relayer", "999999999.0", opts) body = build_link_body(opts) body = if permit, do: Map.put(body, :permit, PermitSignature.to_map(permit)), else: body with {:ok, data} <- do_call(w, :post, "/links/fund", body) do {:ok, RemitMd.Models.LinkResponse.from_map(data)} end end @doc """ Generate a one-time URL for the operator to withdraw funds. ## Options - `:messages` - list of maps with `:role` ("agent"/"system") and `:text` - `:agent_name` - agent display name shown on the withdraw page - `:permit` - `%PermitSignature{}` for gasless approval (auto-signed if omitted) Returns `{:ok, %RemitMd.Models.LinkResponse{}}` or `{:error, %RemitMd.Error{}}`. """ def create_withdraw_link(%__MODULE__{} = w, opts \\ []) do permit = resolve_permit(w, "relayer", "999999999.0", opts) body = build_link_body(opts) body = if permit, do: Map.put(body, :permit, PermitSignature.to_map(permit)), else: body with {:ok, data} <- do_call(w, :post, "/links/withdraw", body) do {:ok, RemitMd.Models.LinkResponse.from_map(data)} end end # ─── Deposits ───────────────────────────────────────────────────────── @doc """ Place a refundable deposit with a provider. ## Parameters - `provider` - 0x-prefixed provider address - `amount` - deposit amount (string USDC, e.g. `"5.00"`) ## Options - `:expires_in` - seconds from now until deposit expires (default: 1 hour) - `:permit` - `%PermitSignature{}` for gasless approval ## Example {:ok, dep} = RemitMd.Wallet.place_deposit(wallet, "0xProvider", "5.00") """ def place_deposit(%__MODULE__{} = w, provider, amount, opts \\ []) do with :ok <- validate_address(provider), :ok <- validate_amount(amount) do permit = resolve_permit(w, "deposit", amount, opts) expires_in = Keyword.get(opts, :expires_in, 3600) expiry = :os.system_time(:second) + expires_in body = %{ chain: w.chain, provider: provider, amount: amount, expiry: expiry } body = if permit, do: Map.put(body, :permit, PermitSignature.to_map(permit)), else: body with {:ok, data} <- do_call(w, :post, "/deposits", body) do {:ok, Deposit.from_map(data)} end end end @doc """ Return a deposit (provider-side). Full refund to depositor, no fee. """ def return_deposit(%__MODULE__{} = w, deposit_id) do with {:ok, data} <- do_call(w, :post, "/deposits/#{deposit_id}/return", %{}) do {:ok, Transaction.from_map(data)} end end # ─── Contracts ──────────────────────────────────────────────────────── @doc """ Fetch contract addresses for the current chain. Returns `{:ok, %RemitMd.Models.ContractAddresses{}}` or `{:error, %RemitMd.Error{}}`. """ def get_contracts(%__MODULE__{} = w) do with {:ok, data} <- do_call(w, :get, "/contracts", nil) do {:ok, ContractAddresses.from_map(data)} end end # ─── Mint (testnet only) ───────────────────────────────────────────── @doc """ Mint testnet USDC to this wallet (testnet only). Uses unauthenticated HTTP (no EIP-712 headers), matching the TS SDK. ## Example {:ok, resp} = RemitMd.Wallet.mint(wallet, "100.00") """ def mint(%__MODULE__{} = w, amount_usdc) do with :ok <- validate_amount(amount_usdc) do :inets.start() :ssl.start() body = Jason.encode!(%{wallet: w.address, amount: amount_usdc}) url = String.to_charlist(w.transport.base_url <> "/mint") headers = [{~c"content-type", ~c"application/json"}] case :httpc.request(:post, {url, headers, ~c"application/json", String.to_charlist(body)}, [{:timeout, 10_000}, {:connect_timeout, 5_000}], []) do {:ok, {{_ver, status, _reason}, _resp_headers, resp_body}} when status in 200..299 -> case Jason.decode(to_string(resp_body)) do {:ok, data} -> {:ok, MintResponse.from_map(data)} {:error, _} -> {:error, Error.new(Error.server_error(), "Invalid JSON from mint")} end {:ok, {{_ver, status, _reason}, _resp_headers, resp_body}} -> msg = case Jason.decode(to_string(resp_body)) do {:ok, %{"message" => m}} -> m _ -> "mint failed (#{status})" end {:error, Error.new(Error.server_error(), msg)} {:error, reason} -> {:error, Error.new(Error.network_error(), "Mint network error: #{inspect(reason)}")} end end end @doc false def inspect_address(%__MODULE__{address: addr}), do: addr # ─── Private ────────────────────────────────────────────────────────────── defp call_mock_or_http(%__MODULE__{mock_pid: nil}, _mock_fn, http_fn) do try do {:ok, http_fn.()} rescue e in RemitMd.Error -> {:error, e} end end defp call_mock_or_http(%__MODULE__{mock_pid: pid}, mock_fn, _http_fn) do case mock_fn.(pid) do {:ok, _} = ok -> ok {:error, %RemitMd.Error{} = e} -> {:error, e} {:error, :not_found} -> {:error, RemitMd.Error.new(RemitMd.Error.not_found(), "Resource not found")} {:error, :forbidden} -> {:error, RemitMd.Error.new(RemitMd.Error.forbidden(), "Not authorized for this resource")} {:error, reason} -> {:error, RemitMd.Error.new(RemitMd.Error.server_error(), inspect(reason))} end end defp do_call(%__MODULE__{mock_pid: nil, transport: t}, method, path, body) do try do result = case method do :get -> Http.get(t, path) :post -> Http.post(t, path, body) end {:ok, result} rescue e in RemitMd.Error -> {:error, e} end end defp do_call(%__MODULE__{mock_pid: _pid}, _method, _path, _body) do {:ok, %{}} # Unimplemented mock endpoint - return empty map end defp do_http_get(%__MODULE__{transport: t}, path) do Http.get(t, path) end defp do_http_post(%__MODULE__{transport: t}, path, body) do Http.post(t, path, body) end defp validate_address(addr) when is_binary(addr) do if Regex.match?(~r/\A0x[0-9a-fA-F]{40}\z/, addr) do :ok else {:error, Error.new(Error.invalid_address(), "Invalid address: expected 0x + 40 hex chars, got #{inspect(addr)}")} end end defp validate_address(_), do: {:error, Error.new(Error.invalid_address(), "Address must be a string")} defp validate_amount(amount) when is_binary(amount) do try do d = Decimal.new(amount) if Decimal.compare(d, @min_amount) == :lt do {:error, Error.new(Error.invalid_amount(), "Amount #{amount} is below minimum 0.000001 USDC")} else :ok end rescue _ -> {:error, Error.new(Error.invalid_amount(), "Amount must be a valid decimal string, got #{inspect(amount)}")} end end defp validate_amount(_), do: {:error, Error.new(Error.invalid_amount(), "Amount must be a string")} defp get_address(%RemitMd.PrivateKeySigner{} = s), do: s.address defp get_address(%{address: addr}), do: addr defp get_address(%_{} = s), do: Map.get(s, :address) # Resolve permit: use explicit from opts, or auto-sign. Returns PermitSignature or nil (mock mode). defp resolve_permit(%__MODULE__{mock_pid: pid}, _contract, _amount, _opts) when pid != nil, do: nil defp resolve_permit(%__MODULE__{} = w, contract, amount, opts) do case Keyword.get(opts, :permit) do %PermitSignature{} = p -> p _ -> auto_permit(w, contract, amount) end end # ─── Permit helpers ────────────────────────────────────────────── # Auto-sign a permit for the given contract type and amount. # Used by payment methods when no explicit permit is provided. defp auto_permit(%__MODULE__{} = w, contract, amount) do {:ok, contracts} = get_contracts(w) spender = Map.get(contracts, String.to_existing_atom(contract)) unless spender do require Logger Logger.warning("[remitmd] auto-permit: no #{contract} contract address available") nil else sign_permit(w, spender, amount) end rescue e -> require Logger Logger.warning("[remitmd] auto-permit failed for #{contract} (amount=#{amount}): #{Exception.message(e)}") nil end # Fetch the EIP-2612 permit nonce. Mock mode returns 0 directly. defp fetch_permit_nonce(%__MODULE__{mock_pid: pid}, _usdc_address) when pid != nil, do: 0 defp fetch_permit_nonce(%__MODULE__{} = w, _usdc_address) do data = do_http_get(w, "/status/#{w.address}") nonce = case data do %{"permit_nonce" => n} when not is_nil(n) -> n _ -> nil end if nonce != nil do if is_integer(nonce), do: nonce, else: nonce |> to_string() |> String.to_integer() else raise Error.new(Error.server_error(), "permit_nonce not available from /status API for #{w.address}") end end # Build a body map for create_fund_link / create_withdraw_link from keyword opts. defp build_link_body(opts) do body = %{} body = case Keyword.get(opts, :messages) do nil -> body msgs -> Map.put(body, :messages, msgs) end case Keyword.get(opts, :agent_name) do nil -> body name -> Map.put(body, :agent_name, name) end end # Return the numeric chain ID for EIP-712 signing. defp chain_id(%__MODULE__{transport: %Http{chain_id: id}}), do: id defp chain_id(%__MODULE__{chain: "base"}), do: 8453 defp chain_id(%__MODULE__{chain: "base-sepolia"}), do: 84532 defp chain_id(%__MODULE__{chain: "base_sepolia"}), do: 84532 defp chain_id(%__MODULE__{chain: chain}) do raise Error.new(Error.chain_unsupported(), "Unknown chain: #{inspect(chain)}") end defp address_to_bytes32(nil) do raise Error.new(Error.invalid_address(), "address_to_bytes32 received nil") end defp address_to_bytes32("") do raise Error.new(Error.invalid_address(), "address_to_bytes32 received empty string") end defp address_to_bytes32(address) do hex = String.trim_leading(address, "0x") if String.length(hex) == 40 do addr_bytes = Base.decode16!(hex, case: :mixed) :binary.copy(<<0>>, 12) <> addr_bytes else raise Error.new(Error.invalid_address(), "address_to_bytes32: expected 40 hex chars, got #{String.length(hex)} in #{inspect(address)}") end end defp call_sign(%{__struct__: mod} = signer, digest) do mod.sign(signer, digest) end end