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, WalletSettings, WalletStatus, Webhook } @min_amount Decimal.new("0.000001") # Contract name -> flow name for /permits/prepare. @contract_to_flow %{ "router" => "direct", "escrow" => "escrow", "tab" => "tab", "stream" => "stream", "bounty" => "bounty", "deposit" => "deposit", "relayer" => "direct", } 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_SIGNER_KEY 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 (via /permits/prepare) ───────────────────────── @doc """ Sign a USDC permit via the server's `/permits/prepare` endpoint. The server computes the EIP-712 hash, manages nonces, and resolves contract addresses. The SDK only signs the returned hash. ## Parameters - `flow` - payment flow: `"direct"`, `"escrow"`, `"tab"`, `"stream"`, `"bounty"`, `"deposit"` - `amount` - amount in USDC (number or string, e.g. `5.0` or `"5.00"`) Returns `%PermitSignature{}`. """ def sign_permit(%__MODULE__{} = w, flow, amount) when is_binary(flow) do data = do_http_post(w, "/permits/prepare", %{ flow: flow, amount: to_string(amount), owner: w.address }) hash_hex = Map.fetch!(data, "hash") hash_bytes = hash_hex |> String.trim_leading("0x") |> Base.decode16!(case: :mixed) sig_hex = call_sign_hash(w.signer, hash_bytes) 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: data["value"] |> to_string() |> String.to_integer(), deadline: data["deadline"] |> to_string() |> String.to_integer(), v: v, r: r, s: s } 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 """ Reclaim an expired or unclaimed bounty (poster-only). """ def reclaim_bounty(%__MODULE__{} = w, bounty_id) do with {:ok, data} <- do_call(w, :post, "/bounties/#{bounty_id}/reclaim", %{}) 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 "List all registered webhooks for this wallet." def list_webhooks(%__MODULE__{} = w) do case do_call(w, :get, "/webhooks", nil) do {:ok, data} when is_list(data) -> {:ok, Enum.map(data, &Webhook.from_map/1)} {:ok, _} -> {:ok, []} err -> err end end @doc "Delete a webhook by ID." def delete_webhook(%__MODULE__{} = w, webhook_id) do do_call(w, :delete, "/webhooks/#{webhook_id}", nil) end @doc """ Update a webhook's URL, events, or active status. ## Options - `:url` - new webhook URL - `:events` - new list of event types - `:active` - boolean to enable/disable the webhook """ def update_webhook(%__MODULE__{} = w, webhook_id, opts \\ []) do body = %{} body = if url = Keyword.get(opts, :url), do: Map.put(body, :url, url), else: body body = if events = Keyword.get(opts, :events), do: Map.put(body, :events, events), else: body body = if Keyword.has_key?(opts, :active), do: Map.put(body, :active, Keyword.get(opts, :active)), else: body with {:ok, data} <- do_call(w, :patch, "/webhooks/#{webhook_id}", body) do {:ok, Webhook.from_map(data)} end end # ─── Tab aliases / missing methods ───────────────────────────────── @doc "Canonical name for `create_tab`." def open_tab(%__MODULE__{} = w, provider, limit_amount, per_unit, opts \\ []) do create_tab(w, provider, limit_amount, per_unit, opts) end @doc "Settle and close a tab (alias for close_tab with defaults)." def settle_tab(%__MODULE__{} = w, tab_id) do close_tab(w, tab_id) end @doc "Charge the given amount from an open tab (off-chain, signed). Deprecated: use charge_tab." def debit_tab(%__MODULE__{} = w, tab_id, amount, memo \\ "") do body = %{tab_id: tab_id, amount: amount, memo: memo} do_call(w, :post, "/tabs/#{tab_id}/debit", body) end # ��── Stream aliases / missing methods ────────────────────────────── @doc "Canonical name for `create_stream`." def open_stream(%__MODULE__{} = w, payee, rate_per_second, max_total, opts \\ []) do create_stream(w, payee, rate_per_second, max_total, opts) end @doc "Claim all vested stream payments (callable by recipient)." def withdraw_stream(%__MODULE__{} = w, stream_id) do with {:ok, data} <- do_call(w, :post, "/streams/#{stream_id}/withdraw", %{}) do {:ok, data} end end # ─── Bounty aliases ──────────────────────────────────────────────── @doc "Canonical name for `create_bounty`." def post_bounty(%__MODULE__{} = w, amount, task_description, deadline, opts \\ []) do create_bounty(w, amount, task_description, deadline, opts) 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 # ─── Wallet Settings ────────────────────────────────────────────────── @doc """ Update wallet display settings. ## Options - `:display_name` - new display name for the wallet """ def update_wallet_settings(%__MODULE__{} = w, opts \\ []) do body = %{} body = if name = Keyword.get(opts, :display_name), do: Map.put(body, :display_name, name), else: body with {:ok, data} <- do_call(w, :patch, "/wallet/settings", body) do {:ok, WalletSettings.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 @doc """ Forfeit a deposit (depositor-side). Provider keeps the funds. """ def forfeit_deposit(%__MODULE__{} = w, deposit_id) do with {:ok, data} <- do_call(w, :post, "/deposits/#{deposit_id}/forfeit", %{}) 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) :patch -> Http.patch(t, path, body) :delete -> Http.delete(t, path) 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 via /permits/prepare for the given contract type and amount. # Maps contract name to flow, then calls sign_permit/3. # Returns nil on failure so callers degrade gracefully. defp auto_permit(%__MODULE__{} = w, contract, amount) do flow = @contract_to_flow[contract] unless flow do require Logger Logger.warning("[remitmd] auto-permit: unknown contract for permit: #{contract}") nil else sign_permit(w, flow, amount) end rescue e -> require Logger Logger.warning("[remitmd] auto-permit failed for #{contract} (amount=#{amount}): #{Exception.message(e)}") nil 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 defp call_sign_hash(%{__struct__: mod} = signer, hash) do mod.sign_hash(signer, hash) end end