Packages

remit.md SDK for Elixir - universal payment protocol for AI agents

Retired package: Renamed - DEPRECATED: Use pay-cli (cargo install pay-cli) or pay-sdk (pip install pay-sdk). See https://pay-skill.com/docs

Current section

Files

Jump to
remit lib remit_md wallet.ex
Raw

lib/remit_md/wallet.ex

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")
# 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 = <<chain_id(w)::unsigned-big-integer-size(256)>>
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 = <<total_charged::unsigned-big-integer-size(256)>>
count_enc = <<call_count::unsigned-big-integer-size(256)>>
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 """
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
# ─── 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
# ─���─ Intent Negotiation ────────────────────────────────────────────
@doc "Propose a payment intent for negotiation (agent-to-agent)."
def propose_intent(%__MODULE__{} = w, to, amount, type \\ "direct") do
body = %{to: to, amount: to_string(amount), type: type}
do_call(w, :post, "/intents", body)
end
@doc "Alias for propose_intent."
def express_intent(%__MODULE__{} = w, to, amount, type \\ "direct") do
propose_intent(w, to, amount, type)
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)
: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