Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 54 additions & 24 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,16 @@ type Currency struct {
}

type Route struct {
Provider string `json:"provider"`
Engine string `json:"engine"`
AmountTo float64 `json:"amount_to"`
AmountFrom float64 `json:"amount_from"`
KYC string `json:"kyc"`
LogPolicy string `json:"log_policy"`
ETA int `json:"eta"`
Fixed bool `json:"fixed"`
Spread float64 `json:"spread,omitempty"`
HoudiniQuote any `json:"_houdiniQuote,omitempty"`
Provider string `json:"provider"`
Engine string `json:"engine"`
AmountTo float64 `json:"amount_to"`
AmountFrom float64 `json:"amount_from"`
KYC string `json:"kyc"`
LogPolicy string `json:"log_policy"`
ETA int `json:"eta"`
Fixed bool `json:"fixed"`
Spread float64 `json:"spread,omitempty"`
HoudiniQuote any `json:"_houdiniQuote,omitempty"`
// Bridge / Ghost-only metadata. Populated by /v2/exchange/bridge/estimate.
BridgeLabel string `json:"bridgeLabel,omitempty"`
BridgeBadge string `json:"bridgeBadge,omitempty"`
Expand All @@ -86,20 +86,21 @@ type Estimate struct {
}

type CreateReq struct {
Provider string `json:"provider"`
Engine string `json:"engine,omitempty"`
FromCurrency string `json:"from_currency"`
ToCurrency string `json:"to_currency"`
FromNetwork string `json:"from_network,omitempty"`
ToNetwork string `json:"to_network,omitempty"`
AmountFrom float64 `json:"amount_from,omitempty"`
AmountTo float64 `json:"amount_to,omitempty"`
AddressTo string `json:"address_to"`
FixedRate bool `json:"fixed_rate,omitempty"`
HoudiniQuote any `json:"_houdiniQuote,omitempty"`
AddressMemo string `json:"address_memo,omitempty"`
RefundAddress string `json:"refund_address,omitempty"`
Source string `json:"source,omitempty"`
TradeID string `json:"trade_id,omitempty"`
Provider string `json:"provider"`
Engine string `json:"engine,omitempty"`
FromCurrency string `json:"from_currency"`
ToCurrency string `json:"to_currency"`
FromNetwork string `json:"from_network,omitempty"`
ToNetwork string `json:"to_network,omitempty"`
AmountFrom float64 `json:"amount_from,omitempty"`
AmountTo float64 `json:"amount_to,omitempty"`
AddressTo string `json:"address_to"`
FixedRate bool `json:"fixed_rate,omitempty"`
HoudiniQuote any `json:"_houdiniQuote,omitempty"`
AddressMemo string `json:"address_memo,omitempty"`
RefundAddress string `json:"refund_address,omitempty"`
Source string `json:"source,omitempty"`
}

type Trade struct {
Expand Down Expand Up @@ -172,12 +173,15 @@ func (c *Client) Estimate(ctx context.Context, from, fromNet, to, toNet string,
q.Set("from", from)
q.Set("to", to)
if fromNet != "" {
q.Set("from_net", fromNet)
q.Set("network_from", fromNet)
}
if toNet != "" {
q.Set("to_net", toNet)
q.Set("network_to", toNet)
}
q.Set("amount", strconv.FormatFloat(amount, 'f', -1, 64))
q.Set("type", "from")
var out Estimate
if err := c.do(ctx, http.MethodGet, "/v2/exchange/estimate?"+q.Encode(), nil, &out); err != nil {
return nil, err
Expand All @@ -189,6 +193,9 @@ func (c *Client) Create(ctx context.Context, req CreateReq) (*Trade, error) {
if req.Source == "" {
req.Source = "cli"
}
if err := validateCreateReq(req); err != nil {
return nil, err
}
var out Trade
if err := c.do(ctx, http.MethodPost, "/v2/exchange/create", req, &out); err != nil {
return nil, err
Expand Down Expand Up @@ -228,6 +235,9 @@ func (c *Client) CreateBridge(ctx context.Context, req CreateReq) (*Trade, error
if req.Source == "" {
req.Source = "cli"
}
if err := validateCreateReq(req); err != nil {
return nil, err
}
// Bridge create may return either a Trade object or [Trade, Trade...]
// depending on whether the route is multi-leg. Unmarshal into json.RawMessage
// and disambiguate.
Expand Down Expand Up @@ -270,6 +280,26 @@ func (c *Client) Status(ctx context.Context, id string) (*Trade, error) {
return &out, nil
}

func validateCreateReq(req CreateReq) error {
missing := []string{}
if strings.TrimSpace(req.Provider) == "" {
missing = append(missing, "provider")
}
if strings.TrimSpace(req.FromCurrency) == "" {
missing = append(missing, "from_currency")
}
if strings.TrimSpace(req.ToCurrency) == "" {
missing = append(missing, "to_currency")
}
if strings.TrimSpace(req.AddressTo) == "" {
missing = append(missing, "address_to")
}
if len(missing) > 0 {
return fmt.Errorf("missing required create params: %s", strings.Join(missing, ", "))
}
return nil
}

func truncate(s string, n int) string {
if len(s) <= n {
return s
Expand Down
75 changes: 74 additions & 1 deletion internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ func TestEstimateParse(t *testing.T) {
if r.URL.Query().Get("from") != "btc" || r.URL.Query().Get("to") != "usdt" {
t.Fatalf("missing query: %s", r.URL.RawQuery)
}
if got := r.URL.Query().Get("from_net"); got != "Mainnet" {
t.Fatalf("expected website-style from_net=Mainnet, got %q in %s", got, r.URL.RawQuery)
}
if got := r.URL.Query().Get("to_net"); got != "TRC20" {
t.Fatalf("expected website-style to_net=TRC20, got %q in %s", got, r.URL.RawQuery)
}
if got := r.URL.Query().Get("type"); got != "from" {
t.Fatalf("expected type=from, got %q in %s", got, r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"q-1","rate":80000,"amount_from":0.01,"amount_to":800,
Expand All @@ -49,7 +58,7 @@ func TestEstimateParse(t *testing.T) {
"routes":[{"provider":"Houdini_ChangeNow","engine":"houdini","amount_to":800,"amount_from":0.01,"kyc":"B","log_policy":"B","eta":30,"fixed":false}]
}`))
}))
q, err := c.Estimate(context.Background(), "btc", "", "usdt", "TRC20", 0.01)
q, err := c.Estimate(context.Background(), "btc", "Mainnet", "usdt", "TRC20", 0.01)
if err != nil {
t.Fatalf("Estimate err: %v", err)
}
Expand Down Expand Up @@ -78,6 +87,70 @@ func TestCreateInjectsSource(t *testing.T) {
}
}

func TestCreatePayloadIncludesQuoteRouteContext(t *testing.T) {
var got map[string]any
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/exchange/create" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"id":"abc","trade_id":"abc","status":"WAITING","engine":"pegasusswap","provider":"Pegasusswap","fromTicker":"USDC","fromNetwork":"Base","fromAmount":10,"toTicker":"QUBIC","toNetwork":"Mainnet","toAmount":23000000,"depositAddress":"0xabc","address_user":"QUBIC..."}`))
}))

_, err := c.Create(context.Background(), CreateReq{
TradeID: "pegasusswap-quote-1",
Provider: "Pegasusswap",
Engine: "pegasusswap",
FromCurrency: "USDC",
FromNetwork: "Base",
ToCurrency: "QUBIC",
ToNetwork: "Mainnet",
AmountFrom: 10,
AmountTo: 23000000,
AddressTo: "QUBIC_DESTINATION",
})
if err != nil {
t.Fatalf("Create err: %v", err)
}

want := map[string]any{
"trade_id": "pegasusswap-quote-1",
"provider": "Pegasusswap",
"engine": "pegasusswap",
"from_currency": "USDC",
"from_network": "Base",
"to_currency": "QUBIC",
"to_network": "Mainnet",
"amount_from": float64(10),
"amount_to": float64(23000000),
"address_to": "QUBIC_DESTINATION",
"source": "cli",
}
for k, v := range want {
if got[k] != v {
t.Fatalf("payload[%s] = %#v, want %#v; full payload=%#v", k, got[k], v, got)
}
}
}

func TestCreateRejectsMissingRequiredPayloadBeforePost(t *testing.T) {
called := false
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusInternalServerError)
}))

_, err := c.Create(context.Background(), CreateReq{Provider: "Pegasusswap"})
if err == nil {
t.Fatal("expected validation error")
}
if called {
t.Fatal("Create posted to API despite missing required fields")
}
}

func TestCreatePropagatesError(t *testing.T) {
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(429)
Expand Down
10 changes: 9 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,25 +213,34 @@ func (m Model) cmdCreate() tea.Cmd {

provider := m.quote.Provider
engine := m.quote.Engine
amountTo := m.quote.AmountTo
fixed := false
var hq any
if r := m.picks.get(m.routePick); r != nil {
provider = r.Provider
engine = r.Engine
amountTo = r.AmountTo
fixed = r.Fixed
hq = r.HoudiniQuote
} else if len(m.quote.Routes) > 0 {
provider = m.quote.Routes[0].Provider
engine = m.quote.Routes[0].Engine
amountTo = m.quote.Routes[0].AmountTo
fixed = m.quote.Routes[0].Fixed
hq = m.quote.Routes[0].HoudiniQuote
}
req := api.CreateReq{
TradeID: m.quote.ID,
Provider: provider,
Engine: engine,
FromCurrency: from,
ToCurrency: to,
FromNetwork: fromNet,
ToNetwork: toNet,
AmountFrom: amt,
AmountTo: amountTo,
AddressTo: addr,
FixedRate: fixed,
AddressMemo: memo,
HoudiniQuote: hq,
}
Expand Down Expand Up @@ -675,7 +684,6 @@ func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
m.assignAsset(strings.ToUpper(txt))
m.setPickerScratch("")
return m, nil
case "backspace":
s := m.pickerScratch()
Expand Down
39 changes: 30 additions & 9 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package tui

import "testing"
import (
"testing"

tea "github.com/charmbracelet/bubbletea"
)

func TestSplitTickerNet(t *testing.T) {
cases := []struct {
in string
ticker, net string
in string
ticker, net string
}{
{"btc", "BTC", ""},
{"USDT-TRC20", "USDT", "TRC20"},
Expand All @@ -23,12 +27,12 @@ func TestSplitTickerNet(t *testing.T) {

func TestIsTerminal(t *testing.T) {
cases := map[string]bool{
"WAITING": false,
"finished": true,
"FINISHED": true,
"FAILED": true,
"EXPIRED": true,
"REFUNDED": true,
"WAITING": false,
"finished": true,
"FINISHED": true,
"FAILED": true,
"EXPIRED": true,
"REFUNDED": true,
"EXCHANGING": false,
}
for k, want := range cases {
Expand All @@ -37,3 +41,20 @@ func TestIsTerminal(t *testing.T) {
}
}
}

func TestFreeTextDestinationPersistsAfterPickerEnter(t *testing.T) {
m := New(Config{})
m.tab = tabSwap
m.state = stPickTo
m.to = "QUBIC-MAINNET"

updated, _ := m.updatePicker(tea.KeyMsg{Type: tea.KeyEnter})
got := updated.(Model)

if got.state != stAmount {
t.Fatalf("state = %v, want %v", got.state, stAmount)
}
if got.to != "QUBIC-MAINNET" {
t.Fatalf("to = %q, want QUBIC-MAINNET", got.to)
}
}