Skip to content

Instantly share code, notes, and snippets.

@mazz
Last active June 4, 2025 15:59
Show Gist options
  • Save mazz/28e1438d195f9977d32188abb4f18616 to your computer and use it in GitHub Desktop.
Save mazz/28e1438d195f9977d32188abb4f18616 to your computer and use it in GitHub Desktop.
defmodule LumensAppWebApi.WalletController do
use LumensAppWeb, :controller
alias StellarBase.StrKey
alias Stellar.KeyPair
require Logger
def show_qr(conn, %{"network" => network}) when network in ["pubnet", "testnet"] do
session_id = UUID.uuid6()
Logger.debug("Generating QR code for network: #{network}, session_id: #{session_id}")
case WalletConnect.generate_qr_code(network, session_id) do
{:ok, qr_buffer} ->
qr_base64 = Base.encode64(qr_buffer)
Logger.debug("QR code generated for session_id: #{session_id}")
render(conn, :show_qr, qr_base64: qr_base64, network: network, session_id: session_id, error: nil)
{:error, reason} ->
Logger.error("Failed to generate QR code: #{reason}, session_id: #{session_id}")
render(conn, :show_qr, qr_base64: nil, network: network, session_id: session_id, error: reason)
end
end
def show_qr(conn, _params) do
Logger.error("Invalid network provided")
render(conn, :show_qr, qr_base64: nil, network: nil, session_id: nil, error: "Invalid network. Use 'pubnet' or 'testnet'")
end
def wallet_connect_qr(conn, %{"network" => network, "user_id" => user_id}) when network in ["pubnet", "testnet"] do
case LumensApp.Accounts.get_user!(user_id) do
nil ->
Logger.error("User not found for user_id: #{user_id}")
conn
|> put_status(:not_found)
|> json(%{error: "User not found"})
user ->
Logger.debug("Checking user auth status for network: #{network}, user: #{user_id}")
case check_user_auth_status(conn, user) do
{true, _error_map, _status} ->
Logger.debug("Generating WalletConnect QR code for network: #{network}, user_id: #{user_id}")
{:ok, session_id} = WalletManager.create_session(network, user_id)
case WalletConnect.generate_qr_code(network, user_id, session_id) do
{:ok, qr_buffer} ->
qr_base64 = Base.encode64(qr_buffer)
response = %{
qr_base64: qr_base64,
session_id: session_id,
network: network,
error: nil
}
Logger.debug("Broadcasting QR code for user_id: #{user_id}, session_id: #{session_id}")
LumensAppWeb.Endpoint.broadcast("wallet:#{user_id}", "qr_code", response)
send_resp(conn, :no_content, "")
{:error, reason} ->
Logger.error("Failed to generate WalletConnect QR code: #{reason}")
response = %{
qr_base64: nil,
session_id: session_id,
network: network,
error: reason
}
LumensAppWeb.Endpoint.broadcast("wallet:#{user_id}", "qr_code", response)
send_resp(conn, :no_content, "")
end
{false, error_map, status} ->
conn
|> put_status(status)
|> json(error_map)
end
end
end
def create_custodial_wallet(conn, %{"user_id" => user_id}) do
case LumensApp.Accounts.get_user!(user_id) do
nil ->
Logger.error("User not found for user_id: #{user_id}")
conn
|> put_status(:not_found)
|> json(%{error: "User not found"})
user ->
Logger.debug("Checking user auth status for custodial wallet creation, user: #{user_id}")
case check_user_auth_status(conn, user) do
{true, _error_map, _status} ->
Logger.debug("Creating custodial testnet wallet for user_id: #{user_id}")
case LumensApp.Wallets.create_custodial_wallet("testnet", user_id) do
{:ok, wallet} ->
Logger.info("Custodial wallet created for user_id: #{user_id}, wallet_id: #{wallet.id}")
json(conn, %{
wallet_id: wallet.id,
public_key: wallet.public_key,
network: wallet.network,
user_id: wallet.user_id
})
{:error, reason} ->
Logger.error("Failed to create custodial wallet for user_id: #{user_id}: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to create wallet: #{inspect(reason)}"})
end
{false, error_map, status} ->
conn
|> put_status(status)
|> json(error_map)
end
end
end
def hydrate_wallet(conn, %{"user_id" => user_id, "wallet_id" => wallet_id}) do
case LumensApp.Accounts.get_user!(user_id) do
nil ->
Logger.error("User not found for user_id: #{user_id}")
conn
|> put_status(:not_found)
|> json(%{error: "User not found"})
user ->
Logger.debug("Checking user auth status for wallet hydration, user: #{user_id}")
case check_user_auth_status(conn, user) do
{true, _error_map, _status} ->
case validate_public_key(wallet_id) do
{:ok, public_key} ->
case LumensApp.Repo.get_by(LumensApp.Wallets.Wallet, public_key: public_key, user_id: user_id, network: "testnet") do
nil ->
Logger.error("Wallet not found for public_key: #{public_key}, user_id: #{user_id}")
conn
|> put_status(:not_found)
|> json(%{error: "Wallet not found"})
wallet ->
Logger.debug("Hydrating wallet with public_key #{public_key} for user_id: #{user_id}")
case hydrate_test_wallet(wallet) do
{:ok, _} ->
Logger.info("Wallet hydrated with 10000 USDC for wallet_id: #{wallet.id}")
broadcast_wallet_hydration(wallet, :success, "Wallet hydrated with 10000 USDC")
send_resp(conn, :no_content, "")
{:error, reason} ->
Logger.error("Failed to hydrate wallet #{wallet.id}: #{inspect(reason)}")
broadcast_wallet_hydration(wallet, :error, "Failed to hydrate wallet: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to hydrate wallet: #{inspect(reason)}"})
end
end
{:error, reason} ->
Logger.error("Invalid wallet_id: #{wallet_id}, reason: #{reason}")
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid wallet_id: #{reason}"})
end
{false, error_map, status} ->
conn
|> put_status(status)
|> json(error_map)
end
end
end
defp validate_public_key(wallet_id) do
if is_binary(wallet_id) and String.starts_with?(wallet_id, "G") and String.length(wallet_id) == 56 do
case StellarBase.StrKey.decode(wallet_id, :ed25519_public_key) do
{:ok, _decoded} -> {:ok, wallet_id}
{:error, reason} -> {:error, "Invalid Stellar public key: #{inspect(reason)}"}
end
else
{:error, "Wallet ID must be a Stellar public key starting with 'G' and 56 characters long"}
end
end
defp hydrate_test_wallet(wallet) do
alias Stellar.Network
alias Stellar.TxBuild.{Account, Asset, ChangeTrust}
require Logger
network_passphrase = Network.testnet_passphrase()
usdc_issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
# Fetch assets from Horizon
case Stellar.Horizon.Assets.all(Stellar.Horizon.Server.testnet(), asset_issuer: usdc_issuer) do
{:ok, %Stellar.Horizon.Collection{records: assets}} ->
horizon_usdc_asset = Enum.find(assets, fn asset -> asset.asset_code == "USDC" end)
if horizon_usdc_asset do
# Validate and normalize asset code
asset_code = String.trim(horizon_usdc_asset.asset_code)
unless asset_code =~ ~r/^[A-Za-z0-9]{1,12}$/ do
Logger.error("Invalid USDC asset code: #{asset_code}")
{:error, {:invalid_asset, "Invalid USDC asset code format"}}
end
# Validate issuer public key
case StellarBase.StrKey.decode(usdc_issuer, :ed25519_public_key) do
{:ok, _decoded_issuer} ->
# Create TxBuild asset
usdc_asset = Asset.new(code: asset_code, issuer: usdc_issuer)
Logger.debug("USDC asset created: #{inspect(usdc_asset, pretty: true)}")
try do
# Validate seed (decrypted by cloak_ecto)
unless is_binary(wallet.seed) and String.starts_with?(wallet.seed, "S") and String.length(wallet.seed) == 56 do
Logger.error("Invalid secret seed format for wallet #{wallet.id}")
throw({:invalid_seed, "Secret seed is not a valid Stellar seed"})
end
# Validate USDC issuer account existence
unless issuer_exists?(usdc_issuer) do
Logger.error("USDC issuer account #{usdc_issuer} does not exist on testnet")
throw({:invalid_asset, "USDC issuer account does not exist"})
end
# Use KeyPair for consistent key handling
{public_key_str, secret_seed} = KeyPair.from_secret_seed(wallet.seed)
Logger.debug("wallet.seed: #{inspect(wallet.seed)}")
Logger.debug("public_key_str: #{inspect(public_key_str)}")
Logger.debug("secret_seed: #{inspect(secret_seed)}")
# Verify public key matches
unless public_key_str == wallet.public_key do
Logger.error("Derived public key #{public_key_str} does not match wallet public_key #{wallet.public_key}")
throw({:public_key_mismatch, "Derived public key does not match wallet"})
end
# Ensure account is funded
case ensure_account_funded(public_key_str) do
:ok ->
# Get sequence number with retry
case fetch_sequence_number_with_retry(public_key_str, 3, 5000) do
{:ok, seq} ->
Logger.debug("Fetched sequence number: #{seq}")
source_account = Account.new(public_key_str, sequence: seq)
Logger.debug("source_account: #{inspect(source_account, pretty: true)}")
# Check if trustline exists
unless trustline_exists?(public_key_str, horizon_usdc_asset) do
Logger.debug("asset_code: #{inspect(asset_code)}")
Logger.debug("usdc_issuer: #{inspect(usdc_issuer)}")
# Build trustline operation with explicit limit
change_trust_asset = {asset_code, usdc_issuer}
Logger.debug("change_trust_asset: #{inspect(change_trust_asset)}")
trustline_op = ChangeTrust.new(asset: change_trust_asset, limit: "10000")
Logger.debug("Trustline operation: #{inspect(trustline_op, pretty: true)}")
trustline_tx =
Stellar.TxBuild.new(source_account, network_passphrase: network_passphrase)
|> Stellar.TxBuild.add_operation(trustline_op)
Logger.debug("Unsigned transaction: #{inspect(trustline_tx, pretty: true)}")
# Sign transaction
signed_tx = Stellar.TxBuild.sign(trustline_tx, secret_seed)
Logger.debug("Signed transaction: #{inspect(signed_tx, pretty: true)}")
# Build envelope
case Stellar.TxBuild.envelope(signed_tx) do
{:ok, envelope_xdr} ->
Logger.debug("Envelope xdr: #{inspect(envelope_xdr)}")
case submit_transaction(envelope_xdr) do
{:ok, _} ->
Logger.info("Trustline added for USDC on wallet #{wallet.id}")
{:error, reason} ->
Logger.error("Failed to submit trustline transaction: #{inspect(reason)}")
throw({:trustline_failed, reason})
end
{:error, reason} ->
Logger.error("Failed to build trustline envelope: #{inspect(reason)}, operation: #{inspect(trustline_op, pretty: true)}")
throw({:trustline_failed, reason})
end
end
# Fund with USDC
case fund_with_usdc(public_key_str) do
{:ok, _} ->
Logger.info("Successfully funded wallet #{wallet.id} with 10000 USDC")
{:ok, :funded}
{:error, reason} ->
Logger.error("Failed to fund with USDC: #{inspect(reason)}")
throw({:funding_failed, reason})
end
{:error, reason} ->
Logger.error("Failed to fetch sequence number: #{inspect(reason)}")
throw({:sequence_failed, reason})
end
{:error, reason} ->
Logger.error("Failed to ensure account funding: #{inspect(reason)}")
throw({:funding_failed, reason})
end
catch
{:trustline_failed, reason} -> {:error, {:trustline_failed, reason}}
{:funding_failed, reason} -> {:error, {:funding_failed, reason}}
{:sequence_failed, reason} -> {:error, {:sequence_failed, reason}}
{:public_key_mismatch, reason} -> {:error, {:public_key_mismatch, reason}}
{:invalid_seed, reason} -> {:error, {:invalid_seed, reason}}
{:invalid_asset, reason} -> {:error, {:invalid_asset, reason}}
end
{:error, reason} ->
Logger.error("Invalid USDC issuer public key: #{usdc_issuer}, reason: #{inspect(reason)}")
{:error, {:invalid_asset, "Invalid USDC issuer public key"}}
end
else
Logger.error("USDC asset not found for issuer #{usdc_issuer}")
{:error, {:invalid_asset, "USDC asset not found for issuer"}}
end
{:error, reason} ->
Logger.error("Failed to fetch assets for issuer #{usdc_issuer}: #{inspect(reason)}")
{:error, {:invalid_asset, "Failed to fetch assets"}}
end
end
defp fetch_sequence_number_with_retry(public_key, retries, delay) do
server = Stellar.Horizon.Server.testnet()
case Stellar.Horizon.Accounts.retrieve(server, public_key) do
{:ok, %Stellar.Horizon.Account{sequence: sequence}} ->
{:ok, sequence}
{:error, reason} ->
if retries > 0 do
Logger.debug("Retrying sequence number fetch, retries left: #{retries}, reason: #{inspect(reason)}")
:timer.sleep(delay)
fetch_sequence_number_with_retry(public_key, retries - 1, delay)
else
Logger.error("Failed to fetch sequence number for #{public_key}: #{inspect(reason)}")
{:error, reason}
end
end
end
defp issuer_exists?(issuer_public_key) do
server = Stellar.Horizon.Server.testnet()
case Stellar.Horizon.Accounts.retrieve(server, issuer_public_key) do
{:ok, _account} -> true
{:error, _reason} -> false
end
end
defp ensure_account_funded(public_key) do
server = Stellar.Horizon.Server.testnet()
case Stellar.Horizon.Accounts.retrieve(server, public_key) do
{:ok, _account} ->
:ok
{:error, %{status: 404}} ->
Logger.info("Account #{public_key} not funded, attempting to fund via Friendbot")
case fund_with_friendbot(public_key) do
{:ok, _} -> :ok
{:error, reason} -> {:error, {:friendbot_failed, reason}}
end
{:error, reason} ->
Logger.error("Failed to check account status for #{public_key}: #{inspect(reason)}")
{:error, reason}
end
end
defp fund_with_friendbot(public_key) do
case HTTPoison.get("https://friendbot.stellar.org/?addr=#{public_key}") do
{:ok, %HTTPoison.Response{status_code: 200}} ->
Logger.info("Successfully funded account #{public_key} with Friendbot")
{:ok, :funded}
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
Logger.error("Friendbot funding failed: #{code}, #{body}")
{:error, {:friendbot_failed, body}}
{:error, reason} ->
Logger.error("Friendbot request error: #{inspect(reason)}")
{:error, {:friendbot_failed, reason}}
end
end
defp trustline_exists?(public_key, horizon_usdc_asset) do
server = Stellar.Horizon.Server.testnet()
case Stellar.Horizon.Accounts.retrieve(server, public_key) do
{:ok, %Stellar.Horizon.Account{balances: balances}} ->
Enum.any?(balances, fn balance ->
balance.asset_code == horizon_usdc_asset.asset_code && balance.asset_issuer == horizon_usdc_asset.asset_issuer
end)
{:error, reason} ->
Logger.error("Failed to fetch account balances for #{public_key}: #{inspect(reason)}")
false
end
end
defp fund_with_usdc(public_key) do
case HTTPoison.post(
"https://faucet.circle.com/v1/faucet/stellar",
Jason.encode!(%{"address": public_key, "amount": 10000, "asset": "USDC"}),
[{"Content-Type", "application/json"}]
) do
{:ok, %HTTPoison.Response{status_code: 200}} ->
{:ok, :funded}
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
Logger.error("Faucet request failed: #{code}, #{body}")
{:error, {:faucet_failed, body}}
{:error, reason} ->
{:error, {:faucet_failed, reason}}
end
end
defp submit_transaction(envelope_xdr) do
case Stellar.Horizon.Transactions.create(envelope_xdr, network: :testnet) do
{:ok, response} ->
{:ok, response}
{:error, reason} ->
{:error, {:submission_failed, reason}}
end
end
defp broadcast_wallet_hydration(wallet, status, message) do
payload = %{
wallet_id: wallet.id,
public_key: wallet.public_key,
network: wallet.network,
user_id: wallet.user_id,
status: status,
message: message
}
LumensAppWeb.Endpoint.broadcast("wallet:#{wallet.user_id}", "wallet_hydrated", payload)
end
defp check_user_auth_status(_conn, user) do
cond do
is_nil(user.confirmed_at) ->
error_map = %{
errors: [
%{
status: "403",
title: "Forbidden",
detail: gettext("The user is not confirmed.")
}
]
}
{false, error_map, 403}
user.is_suspended ->
error_map = %{
errors: [
%{
status: "403",
title: "Forbidden",
detail: gettext("The user is suspended.")
}
]
}
{false, error_map, 403}
user.is_deleted ->
error_map = %{
errors: [
%{
status: "403",
title: "Forbidden",
detail: gettext("The user is deleted.")
}
]
}
{false, error_map, 403}
true ->
{true, %{}, 200}
end
end
end
logging:
[debug] Hydrating wallet with public_key GCSU2BSNO64ZBSDEJR6OXRWIGH7P5E5MCJB5XMBNB477P3TTRU7ZN4SN for user_id: 1f0414d7-48e4-6776-ab90-361e354e2fed
[debug] USDC asset created: %Stellar.TxBuild.Asset{
code: "USDC",
issuer: %Stellar.TxBuild.AccountID{
account_id: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
},
type: :alpha_num4
}
[debug] wallet.seed: "SCCJTDGE5VUDGG3C66IH76V6BTJMKMY6W6E37WPJD4HTPB3AYGVTHVYP"
[debug] public_key_str: "GCSU2BSNO64ZBSDEJR6OXRWIGH7P5E5MCJB5XMBNB477P3TTRU7ZN4SN"
[debug] secret_seed: "SCCJTDGE5VUDGG3C66IH76V6BTJMKMY6W6E37WPJD4HTPB3AYGVTHVYP"
[debug] Fetched sequence number: 5699305637675008
[debug] source_account: %Stellar.TxBuild.Account{
address: "GCSU2BSNO64ZBSDEJR6OXRWIGH7P5E5MCJB5XMBNB477P3TTRU7ZN4SN",
account_id: "GCSU2BSNO64ZBSDEJR6OXRWIGH7P5E5MCJB5XMBNB477P3TTRU7ZN4SN",
muxed_id: nil,
type: :ed25519_public_key
}
[debug] asset_code: "USDC"
[debug] usdc_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
[debug] change_trust_asset: {"USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"}
[debug] Trustline operation: %Stellar.TxBuild.ChangeTrust{
asset: %Stellar.TxBuild.Asset{
code: "USDC",
issuer: %Stellar.TxBuild.AccountID{
account_id: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
},
type: :alpha_num4
},
amount: %Stellar.TxBuild.Amount{
amount: 922337203685.4775,
raw: 9223372036854775807
},
source_account: %Stellar.TxBuild.OptionalAccount{account_id: nil}
}
[debug] Unsigned transaction: {:ok,
%Stellar.TxBuild{
tx: %Stellar.TxBuild.Transaction{
source_account: %Stellar.TxBuild.Account{
address: "GCSU2BSNO64ZBSDEJR6OXRWIGH7P5E5MCJB5XMBNB477P3TTRU7ZN4SN",
account_id: "GCSU2BSNO64ZBSDEJR6OXRWIGH7P5E5MCJB5XMBNB477P3TTRU7ZN4SN",
muxed_id: nil,
type: :ed25519_public_key
},
sequence_number: %Stellar.TxBuild.SequenceNumber{sequence_number: 0},
base_fee: %Stellar.TxBuild.BaseFee{fee: 100, multiplier: 1},
memo: %Stellar.TxBuild.Memo{type: :MEMO_NONE, value: nil},
preconditions: %Stellar.TxBuild.Preconditions{
type: :none,
preconditions: nil
},
operations: %Stellar.TxBuild.Operations{
operations: [
%Stellar.TxBuild.Operation{
body: %Stellar.TxBuild.ChangeTrust{
asset: %Stellar.TxBuild.Asset{
code: "USDC",
issuer: %Stellar.TxBuild.AccountID{
account_id: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
},
type: :alpha_num4
},
amount: %Stellar.TxBuild.Amount{
amount: 922337203685.4775,
raw: 9223372036854775807
},
source_account: %Stellar.TxBuild.OptionalAccount{account_id: nil}
},
source_account: %Stellar.TxBuild.OptionalAccount{account_id: nil}
}
],
count: 1
},
ext: %StellarBase.XDR.TransactionExt{
value: %StellarBase.XDR.Void{value: nil},
type: 0
}
},
signatures: [],
tx_envelope: nil,
network_passphrase: "Test SDF Network ; September 2015"
}}
[debug] Signed transaction: {:error, :invalid_signature}
[error] Failed to build trustline envelope: :invalid_signature, operation: %Stellar.TxBuild.ChangeTrust{
asset: %Stellar.TxBuild.Asset{
code: "USDC",
issuer: %Stellar.TxBuild.AccountID{
account_id: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
},
type: :alpha_num4
},
amount: %Stellar.TxBuild.Amount{
amount: 922337203685.4775,
raw: 9223372036854775807
},
source_account: %Stellar.TxBuild.OptionalAccount{account_id: nil}
}
[error] Failed to hydrate wallet 1f0414dc-a6ad-6d12-b166-fcf347eb4379: {:trustline_failed, :invalid_signature}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment