Skip to content

Commit aa5af69

Browse files
feat: add non-custodial-link and tx dumpers
1 parent c91c7d8 commit aa5af69

File tree

6 files changed

+299
-1
lines changed

6 files changed

+299
-1
lines changed

internal/api/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ func New(o APIOpts) *API {
6868

6969
g.POST(fmt.Sprintf("/callback/%s", o.CallbackSecret), api.callbackHandler)
7070
g.POST("/trigger-onramp", api.onrampHandler)
71+
g.POST("/link", api.createLinkHandler)
72+
g.GET("/link/:phoneNumber", api.getLinksHandler)
73+
g.GET("/transactions/:phoneNumber", api.getTransactionsByPhoneHandler)
74+
g.GET("/transactions-recent", api.getRecentTransactionsHandler)
7175
})
7276

7377
api.server = &http.Server{

internal/api/link_handler.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
7+
"github.com/jackc/pgx/v5"
8+
"github.com/kamikazechaser/common/httputil"
9+
"github.com/uptrace/bunrouter"
10+
)
11+
12+
type LinkRequest struct {
13+
PublicKey string `json:"publicKey" validate:"required,eth_addr_checksum"`
14+
PhoneNumber string `json:"phoneNumber" validate:"required"`
15+
}
16+
17+
func (a *API) createLinkHandler(w http.ResponseWriter, req bunrouter.Request) error {
18+
var linkReq LinkRequest
19+
if err := httputil.BindJSON(w, req.Request, &linkReq); err != nil {
20+
a.logg.Error("failed to bind link request", "error", err)
21+
return httputil.JSON(w, http.StatusBadRequest, ErrResponse{
22+
Ok: false,
23+
Description: "Invalid request body",
24+
})
25+
}
26+
27+
if err := a.validator.Validate(linkReq); err != nil {
28+
a.logg.Error("validation failed", "error", err)
29+
return httputil.JSON(w, http.StatusBadRequest, ErrResponse{
30+
Ok: false,
31+
Description: err.Error(),
32+
})
33+
}
34+
35+
tx, err := a.store.Pool().Begin(req.Context())
36+
if err != nil {
37+
return handlePostgresError(w, err)
38+
}
39+
defer tx.Rollback(req.Context())
40+
41+
// Check if this exact combination already exists (prevent duplicate links)
42+
existingLink, err := a.store.GetNonCustodialLinkByPublicKey(req.Context(), tx, linkReq.PublicKey)
43+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
44+
a.logg.Error("failed to check existing link by public key", "error", err)
45+
return httputil.JSON(w, http.StatusInternalServerError, ErrResponse{
46+
Ok: false,
47+
Description: "Internal server error",
48+
})
49+
}
50+
51+
// If this public key is already linked, check if it's to the same phone number
52+
if existingLink != nil && existingLink.Active {
53+
if existingLink.PhoneNumber == linkReq.PhoneNumber {
54+
// Already linked to the same phone number - this is idempotent
55+
a.logg.Info("link already exists with same details", "publicKey", linkReq.PublicKey, "phoneNumber", linkReq.PhoneNumber)
56+
return httputil.JSON(w, http.StatusOK, OKResponse{
57+
Ok: true,
58+
Description: "Link already exists",
59+
Result: map[string]any{
60+
"publicKey": linkReq.PublicKey,
61+
"phoneNumber": linkReq.PhoneNumber,
62+
},
63+
})
64+
} else {
65+
// Public key is linked to a different phone number
66+
a.logg.Warn("public key already linked to different phone", "publicKey", linkReq.PublicKey, "existingPhone", existingLink.PhoneNumber, "requestedPhone", linkReq.PhoneNumber)
67+
return httputil.JSON(w, http.StatusConflict, ErrResponse{
68+
Ok: false,
69+
Description: "Public key already linked to a different phone number",
70+
})
71+
}
72+
}
73+
74+
// Note: We allow multiple public keys to be linked to the same phone number
75+
// This is by design to support users with multiple addresses
76+
77+
err = a.store.InsertNonCustodialLink(req.Context(), tx, linkReq.PublicKey, linkReq.PhoneNumber)
78+
if err != nil {
79+
a.logg.Error("failed to insert non-custodial link", "error", err)
80+
return handlePostgresError(w, err)
81+
}
82+
83+
if err := tx.Commit(req.Context()); err != nil {
84+
return handlePostgresError(w, err)
85+
}
86+
87+
a.logg.Info("non-custodial link created", "publicKey", linkReq.PublicKey, "phoneNumber", linkReq.PhoneNumber)
88+
89+
return httputil.JSON(w, http.StatusCreated, OKResponse{
90+
Ok: true,
91+
Description: "Non-custodial link created successfully",
92+
Result: map[string]any{
93+
"publicKey": linkReq.PublicKey,
94+
"phoneNumber": linkReq.PhoneNumber,
95+
},
96+
})
97+
}
98+
99+
func (a *API) getLinksHandler(w http.ResponseWriter, req bunrouter.Request) error {
100+
phoneNumber := req.Param("phoneNumber")
101+
if phoneNumber == "" {
102+
return httputil.JSON(w, http.StatusBadRequest, ErrResponse{
103+
Ok: false,
104+
Description: "Phone number is required",
105+
})
106+
}
107+
108+
tx, err := a.store.Pool().Begin(req.Context())
109+
if err != nil {
110+
return handlePostgresError(w, err)
111+
}
112+
defer tx.Rollback(req.Context())
113+
114+
links, err := a.store.GetNonCustodialLinksByPhone(req.Context(), tx, phoneNumber)
115+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
116+
a.logg.Error("failed to get links by phone", "error", err, "phoneNumber", phoneNumber)
117+
return httputil.JSON(w, http.StatusInternalServerError, ErrResponse{
118+
Ok: false,
119+
Description: "Internal server error",
120+
})
121+
}
122+
123+
if err := tx.Commit(req.Context()); err != nil {
124+
return handlePostgresError(w, err)
125+
}
126+
127+
a.logg.Info("links retrieved", "phoneNumber", phoneNumber, "count", len(links))
128+
129+
return httputil.JSON(w, http.StatusOK, OKResponse{
130+
Ok: true,
131+
Description: "Links retrieved successfully",
132+
Result: map[string]any{
133+
"phoneNumber": phoneNumber,
134+
"links": links,
135+
"count": len(links),
136+
},
137+
})
138+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
7+
"github.com/jackc/pgx/v5"
8+
"github.com/kamikazechaser/common/httputil"
9+
"github.com/uptrace/bunrouter"
10+
)
11+
12+
func (a *API) getTransactionsByPhoneHandler(w http.ResponseWriter, req bunrouter.Request) error {
13+
phoneNumber := req.Param("phoneNumber")
14+
if phoneNumber == "" {
15+
return httputil.JSON(w, http.StatusBadRequest, ErrResponse{
16+
Ok: false,
17+
Description: "Phone number is required",
18+
})
19+
}
20+
21+
tx, err := a.store.Pool().Begin(req.Context())
22+
if err != nil {
23+
return handlePostgresError(w, err)
24+
}
25+
defer tx.Rollback(req.Context())
26+
27+
onramps, err := a.store.GetOnrampsByPhone(req.Context(), tx, phoneNumber)
28+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
29+
a.logg.Error("failed to get onramps by phone", "error", err, "phoneNumber", phoneNumber)
30+
return httputil.JSON(w, http.StatusInternalServerError, ErrResponse{
31+
Ok: false,
32+
Description: "Internal server error",
33+
})
34+
}
35+
36+
offramps, err := a.store.GetOfframpsByPhone(req.Context(), tx, phoneNumber)
37+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
38+
a.logg.Error("failed to get offramps by phone", "error", err, "phoneNumber", phoneNumber)
39+
return httputil.JSON(w, http.StatusInternalServerError, ErrResponse{
40+
Ok: false,
41+
Description: "Internal server error",
42+
})
43+
}
44+
45+
if err := tx.Commit(req.Context()); err != nil {
46+
return handlePostgresError(w, err)
47+
}
48+
49+
a.logg.Info("transactions retrieved", "phoneNumber", phoneNumber, "onrampCount", len(onramps), "offrampCount", len(offramps))
50+
51+
result := map[string]any{
52+
"phoneNumber": phoneNumber,
53+
"onramps": onramps,
54+
"offramps": offramps,
55+
"totalCount": len(onramps) + len(offramps),
56+
}
57+
58+
if len(onramps) == 0 && len(offramps) == 0 {
59+
a.logg.Debug("no transactions found for phone number", "phoneNumber", phoneNumber)
60+
}
61+
62+
return httputil.JSON(w, http.StatusOK, OKResponse{
63+
Ok: true,
64+
Description: "Transactions retrieved successfully",
65+
Result: result,
66+
})
67+
}
68+
69+
func (a *API) getRecentTransactionsHandler(w http.ResponseWriter, req bunrouter.Request) error {
70+
tx, err := a.store.Pool().Begin(req.Context())
71+
if err != nil {
72+
return handlePostgresError(w, err)
73+
}
74+
defer tx.Rollback(req.Context())
75+
76+
onramps, err := a.store.GetRecentOnramps(req.Context(), tx)
77+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
78+
a.logg.Error("failed to get recent onramps", "error", err)
79+
return httputil.JSON(w, http.StatusInternalServerError, ErrResponse{
80+
Ok: false,
81+
Description: "Internal server error",
82+
})
83+
}
84+
85+
offramps, err := a.store.GetRecentOfframps(req.Context(), tx)
86+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
87+
a.logg.Error("failed to get recent offramps", "error", err)
88+
return httputil.JSON(w, http.StatusInternalServerError, ErrResponse{
89+
Ok: false,
90+
Description: "Internal server error",
91+
})
92+
}
93+
94+
if err := tx.Commit(req.Context()); err != nil {
95+
return handlePostgresError(w, err)
96+
}
97+
98+
a.logg.Info("recent transactions retrieved", "onrampCount", len(onramps), "offrampCount", len(offramps))
99+
100+
result := map[string]any{
101+
"onramps": onramps,
102+
"offramps": offramps,
103+
"totalCount": len(onramps) + len(offramps),
104+
"period": "last 3 days",
105+
}
106+
107+
return httputil.JSON(w, http.StatusOK, OKResponse{
108+
Ok: true,
109+
Description: "Recent transactions retrieved successfully",
110+
Result: result,
111+
})
112+
}

internal/store/pg.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ type (
1919
InsertNonCustodialLink string `query:"insert-non-custodial-link"`
2020
GetNonCustodialLinkByPubKey string `query:"get-non-custodial-link-by-public-key"`
2121
GetNonCustodialLinkByPhone string `query:"get-non-custodial-link-by-phone"`
22+
GetNonCustodialLinksByPhone string `query:"get-non-custodial-links-by-phone"`
2223
DeactivateNonCustodialLink string `query:"deactivate-non-custodial-link"`
2324

2425
InsertOfframp string `query:"insert-offramp"`
2526
GetOfframpByPretiumID string `query:"get-offramp-by-pretium-id"`
2627
GetOfframpByTxHash string `query:"get-offramp-by-tx-hash"`
2728
GetOfframpByPhone string `query:"get-offramp-by-phone"`
2829
GetStaleOfframps string `query:"get-stale-offramps"`
30+
GetRecentOfframps string `query:"get-recent-offramps"`
2931
UpdateOfframpStatus string `query:"update-offramp-status"`
3032
UpdateOfframpMpesaConfirm string `query:"update-offramp-mpesa-confirmation"`
3133

@@ -34,6 +36,7 @@ type (
3436
GetOnrampByTxHash string `query:"get-onramp-by-tx-hash"`
3537
GetOnrampByPhone string `query:"get-onramp-by-phone"`
3638
GetStaleOnramps string `query:"get-stale-onramps"`
39+
GetRecentOnramps string `query:"get-recent-onramps"`
3740
UpdateOnrampStatus string `query:"update-onramp-status"`
3841
UpdateOnrampMpesaConfirm string `query:"update-onramp-mpesa-confirmation"`
3942
}
@@ -105,6 +108,14 @@ func (s *Pg) GetNonCustodialLinkByPhone(ctx context.Context, tx pgx.Tx, phoneNum
105108
return &link, nil
106109
}
107110

111+
func (s *Pg) GetNonCustodialLinksByPhone(ctx context.Context, tx pgx.Tx, phoneNumber string) ([]NonCustodialLink, error) {
112+
var links []NonCustodialLink
113+
if err := pgxscan.Select(ctx, tx, &links, s.queries.GetNonCustodialLinksByPhone, phoneNumber); err != nil {
114+
return nil, err
115+
}
116+
return links, nil
117+
}
118+
108119
func (s *Pg) DeactivateNonCustodialLink(ctx context.Context, tx pgx.Tx, phoneNumber string) error {
109120
_, err := tx.Exec(ctx, s.queries.DeactivateNonCustodialLink, phoneNumber)
110121
return err
@@ -147,6 +158,14 @@ func (s *Pg) GetStaleOfframps(ctx context.Context, tx pgx.Tx) ([]Offramp, error)
147158
return offramps, nil
148159
}
149160

161+
func (s *Pg) GetRecentOfframps(ctx context.Context, tx pgx.Tx) ([]Offramp, error) {
162+
var offramps []Offramp
163+
if err := pgxscan.Select(ctx, tx, &offramps, s.queries.GetRecentOfframps); err != nil {
164+
return nil, err
165+
}
166+
return offramps, nil
167+
}
168+
150169
func (s *Pg) UpdateOfframpStatus(ctx context.Context, tx pgx.Tx, pretiumStatus, pretiumID string) error {
151170
_, err := tx.Exec(ctx, s.queries.UpdateOfframpStatus, pretiumStatus, pretiumID)
152171
return err
@@ -194,6 +213,14 @@ func (s *Pg) GetStaleOnramps(ctx context.Context, tx pgx.Tx) ([]Onramp, error) {
194213
return onramps, nil
195214
}
196215

216+
func (s *Pg) GetRecentOnramps(ctx context.Context, tx pgx.Tx) ([]Onramp, error) {
217+
var onramps []Onramp
218+
if err := pgxscan.Select(ctx, tx, &onramps, s.queries.GetRecentOnramps); err != nil {
219+
return nil, err
220+
}
221+
return onramps, nil
222+
}
223+
197224
func (s *Pg) UpdateOnrampStatus(ctx context.Context, tx pgx.Tx, pretiumStatus, pretiumID string) error {
198225
_, err := tx.Exec(ctx, s.queries.UpdateOnrampStatus, pretiumStatus, pretiumID)
199226
return err

internal/store/store.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ type Store interface {
1313
InsertNonCustodialLink(ctx context.Context, tx pgx.Tx, publicKey, phoneNumber string) error
1414
GetNonCustodialLinkByPublicKey(ctx context.Context, tx pgx.Tx, publicKey string) (*NonCustodialLink, error)
1515
GetNonCustodialLinkByPhone(ctx context.Context, tx pgx.Tx, phoneNumber string) (*NonCustodialLink, error)
16+
GetNonCustodialLinksByPhone(ctx context.Context, tx pgx.Tx, phoneNumber string) ([]NonCustodialLink, error)
1617
DeactivateNonCustodialLink(ctx context.Context, tx pgx.Tx, phoneNumber string) error
1718

1819
InsertOfframp(ctx context.Context, tx pgx.Tx, pretiumID, phoneNumber, amountUSD, amountKES, txHash, tokenAddress string) error
1920
GetOfframpByPretiumID(ctx context.Context, tx pgx.Tx, pretiumID string) (*Offramp, error)
2021
GetOfframpByTxHash(ctx context.Context, tx pgx.Tx, txHash string) (*Offramp, error)
2122
GetOfframpsByPhone(ctx context.Context, tx pgx.Tx, phoneNumber string) ([]Offramp, error)
2223
GetStaleOfframps(ctx context.Context, tx pgx.Tx) ([]Offramp, error)
24+
GetRecentOfframps(ctx context.Context, tx pgx.Tx) ([]Offramp, error)
2325
UpdateOfframpStatus(ctx context.Context, tx pgx.Tx, pretiumStatus, pretiumID string) error
2426
UpdateOfframpMpesaConfirmation(ctx context.Context, tx pgx.Tx, mpesaConfirmation, pretiumStatus, pretiumID string) error
2527

@@ -28,6 +30,7 @@ type Store interface {
2830
GetOnrampByTxHash(ctx context.Context, tx pgx.Tx, txHash string) (*Onramp, error)
2931
GetOnrampsByPhone(ctx context.Context, tx pgx.Tx, phoneNumber string) ([]Onramp, error)
3032
GetStaleOnramps(ctx context.Context, tx pgx.Tx) ([]Onramp, error)
33+
GetRecentOnramps(ctx context.Context, tx pgx.Tx) ([]Onramp, error)
3134
UpdateOnrampStatus(ctx context.Context, tx pgx.Tx, pretiumStatus, pretiumID string) error
3235
UpdateOnrampMpesaConfirmation(ctx context.Context, tx pgx.Tx, mpesaConfirmation, pretiumStatus, pretiumID string) error
3336
}

queries.sql

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ SELECT * FROM non_custodial_link WHERE public_key = $1 AND active = true;
1616
-- $1: phone_number
1717
SELECT * FROM non_custodial_link WHERE phone_number = $1 AND active = true;
1818

19+
--name: get-non-custodial-links-by-phone
20+
-- $1: phone_number
21+
SELECT * FROM non_custodial_link WHERE phone_number = $1 AND active = true ORDER BY created_at DESC;
22+
1923
--name: deactivate-non-custodial-link
2024
-- $1: phone_number
2125
UPDATE non_custodial_link SET active = false WHERE phone_number = $1;
@@ -69,6 +73,11 @@ WHERE mpesa_confirmation IS NULL
6973
ORDER BY created_at ASC
7074
LIMIT 100;
7175

76+
--name: get-recent-offramps
77+
SELECT * FROM offramp
78+
WHERE created_at >= NOW() - INTERVAL '3 days'
79+
ORDER BY created_at DESC;
80+
7281
--name: insert-onramp
7382
-- $1: pretium_id
7483
-- $2: phone_number
@@ -113,4 +122,9 @@ SELECT * FROM onramp
113122
WHERE mpesa_confirmation IS NULL
114123
AND created_at < NOW() - INTERVAL '1 minute'
115124
ORDER BY created_at ASC
116-
LIMIT 100;
125+
LIMIT 100;
126+
127+
--name: get-recent-onramps
128+
SELECT * FROM onramp
129+
WHERE created_at >= NOW() - INTERVAL '3 days'
130+
ORDER BY created_at DESC;

0 commit comments

Comments
 (0)