Compare commits

...

11 Commits

Author SHA1 Message Date
7bf71cbfff Merge pull request 'allow-stables-direct-to-mpesa' (#118) from allow-stables-direct-to-mpesa into master
Reviewed-on: #118
2026-02-26 18:17:42 +01:00
295ca6e53e
use the token transfer API for pool deposit
Some checks failed
release / docker (push) Has been cancelled
2026-02-26 14:24:04 +03:00
de32deab80
set the flag_low_swap_amount for amounts below the min withdrawal
Some checks failed
release / docker (push) Has been cancelled
2026-02-25 15:51:48 +03:00
1a9dd64dd6
ensure that any stable coin can be sent to Mpesa without a swap
Some checks failed
release / docker (push) Has been cancelled
2026-02-25 14:40:16 +03:00
0d92872d90
ensure that any stable coin can do a direct transfer to Mpesa 2026-02-25 13:35:48 +03:00
77f0585b56
add the USDC token address as a stable voucher 2026-02-25 13:34:33 +03:00
38f0058d0a Merge pull request 'debt-menu' (#115) from debt-menu into master
Reviewed-on: #115
2026-02-25 09:44:57 +01:00
c16c39f289
update the translations for the swahili menus 2026-02-25 11:37:36 +03:00
185ff0dc45
have a single view for the pay_debt node 2026-02-25 11:35:58 +03:00
45ccefe1fe
properly format the comments 2026-02-25 10:16:29 +03:00
eea51ea40d
reset appropriate error flags on success
Some checks failed
release / docker (push) Has been cancelled
2026-02-23 17:46:52 +03:00
14 changed files with 76 additions and 33 deletions

View File

@ -39,7 +39,7 @@ DEFAULT_MPESA_ASSET=cUSD
MPESA_BEARER_TOKEN=eyJeSIsInRcCI6IkpXVCJ.yJwdWJsaWNLZXkiOiIwrrrrrr MPESA_BEARER_TOKEN=eyJeSIsInRcCI6IkpXVCJ.yJwdWJsaWNLZXkiOiIwrrrrrr
MPESA_ONRAMP_BASE=https://pretium.v1.grassecon.net MPESA_ONRAMP_BASE=https://pretium.v1.grassecon.net
# Known stable voucher addresses (USDm, USD₮) # Known stable voucher addresses (USDm, USD₮, USDC)
STABLE_VOUCHER_ADDRESSES=0x765DE816845861e75A25fCA122bb6898B8B1282a,0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e STABLE_VOUCHER_ADDRESSES=0x765DE816845861e75A25fCA122bb6898B8B1282a,0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e,0xcebA9300f2b948710d2653dD7B07f33A8B32118C
DEFAULT_STABLE_VOUCHER_ADDRESS=0x765DE816845861e75A25fCA122bb6898B8B1282a DEFAULT_STABLE_VOUCHER_ADDRESS=0x765DE816845861e75A25fCA122bb6898B8B1282a
DEFAULT_STABLE_VOUCHER_DECIMALS=18 DEFAULT_STABLE_VOUCHER_DECIMALS=18

View File

@ -160,7 +160,7 @@ func (h *MenuHandlers) CalculateCreditAndDebt(ctx context.Context, sym string, i
scaledCredit := "0" scaledCredit := "0"
// 1. Find first stable voucher in POOL (for swap target) // 1. Find first stable voucher in POOL (for swap target)
var firstPoolStable *dataserviceapi.TokenHoldings var firstPoolStable *dataserviceapi.TokenHoldings
for i := range swappableVouchers { for i := range swappableVouchers {
if isStableVoucher(swappableVouchers[i].TokenAddress) { if isStableVoucher(swappableVouchers[i].TokenAddress) {
@ -169,7 +169,7 @@ func (h *MenuHandlers) CalculateCreditAndDebt(ctx context.Context, sym string, i
} }
} }
// 2. If pool has a stable, get swap quote // 2. If pool has a stable, get swap quote
if firstPoolStable != nil { if firstPoolStable != nil {
finalAmountStr, err := store.ParseAndScaleAmount( finalAmountStr, err := store.ParseAndScaleAmount(
string(activeBal), string(activeBal),
@ -194,7 +194,7 @@ func (h *MenuHandlers) CalculateCreditAndDebt(ctx context.Context, sym string, i
scaledCredit = store.AddDecimalStrings(scaledCredit, finalQuote) scaledCredit = store.AddDecimalStrings(scaledCredit, finalQuote)
} }
// 3. Add ALL wallet stable balances (from FetchVouchers) // 3. Add ALL wallet stable balances (from FetchVouchers)
for _, v := range allVouchers { for _, v := range allVouchers {
if isStableVoucher(v.TokenAddress) { if isStableVoucher(v.TokenAddress) {
scaled := store.ScaleDownBalance(v.Balance, v.TokenDecimals) scaled := store.ScaleDownBalance(v.Balance, v.TokenDecimals)

View File

@ -100,11 +100,13 @@ func (h *MenuHandlers) GetMpesaMaxLimit(ctx context.Context, sym string, input [
} }
// Fetch min withdrawal amount from config/env // Fetch min withdrawal amount from config/env
minksh := fmt.Sprintf("%f", config.MinMpesaWithdrawAmount()) minWithdraw := config.MinMpesaWithdrawAmount() // float64 (20)
minKshFormatted, _ := store.TruncateDecimalString(minksh, 0) minKshFormatted, _ := store.TruncateDecimalString(fmt.Sprintf("%f", minWithdraw), 0)
// If SAT is the same as RAT, return early with KSH format // If SAT is the same as RAT (default USDm),
if string(metadata.TokenAddress) == string(recipientActiveAddress) { // or if the voucher is a stable coin
// return early with KSH format
if string(metadata.TokenAddress) == string(recipientActiveAddress) || isStableVoucher(metadata.TokenAddress) {
txType = "normal" txType = "normal"
// Save the transaction type // Save the transaction type
if err := userStore.WriteEntry(ctx, sessionId, storedb.DATA_SEND_TRANSACTION_TYPE, []byte(txType)); err != nil { if err := userStore.WriteEntry(ctx, sessionId, storedb.DATA_SEND_TRANSACTION_TYPE, []byte(txType)); err != nil {
@ -113,9 +115,16 @@ func (h *MenuHandlers) GetMpesaMaxLimit(ctx context.Context, sym string, input [
} }
activeFloat, _ := strconv.ParseFloat(string(metadata.Balance), 64) activeFloat, _ := strconv.ParseFloat(string(metadata.Balance), 64)
ksh := fmt.Sprintf("%f", activeFloat*rates.Buy) kshValue := activeFloat * rates.Buy
maxKshFormatted, _ := store.TruncateDecimalString(ksh, 0) maxKshFormatted, _ := store.TruncateDecimalString(fmt.Sprintf("%f", kshValue), 0)
// Ensure that the max is greater than the min
if kshValue < minWithdraw {
res.FlagSet = append(res.FlagSet, flag_low_swap_amount)
res.Content = l.Get("%s Ksh", maxKshFormatted)
return res, nil
}
res.Content = l.Get( res.Content = l.Get(
"Enter the amount of Mpesa to withdraw: (Min: Ksh %s, Max %s Ksh)\n", "Enter the amount of Mpesa to withdraw: (Min: Ksh %s, Max %s Ksh)\n",
@ -123,6 +132,8 @@ func (h *MenuHandlers) GetMpesaMaxLimit(ctx context.Context, sym string, input [
maxKshFormatted, maxKshFormatted,
) )
res.FlagReset = append(res.FlagReset, flag_low_swap_amount, flag_api_call_error, flag_incorrect_voucher, flag_incorrect_pool)
return res, nil return res, nil
} }
@ -155,6 +166,8 @@ func (h *MenuHandlers) GetMpesaMaxLimit(ctx context.Context, sym string, input [
return res, nil return res, nil
} }
res.FlagReset = append(res.FlagReset, flag_api_call_error)
// Fallback if below minimum // Fallback if below minimum
maxFloat, _ := strconv.ParseFloat(maxRAT, 64) maxFloat, _ := strconv.ParseFloat(maxRAT, 64)
if maxFloat < 0.1 { if maxFloat < 0.1 {
@ -164,6 +177,8 @@ func (h *MenuHandlers) GetMpesaMaxLimit(ctx context.Context, sym string, input [
return res, nil return res, nil
} }
res.FlagReset = append(res.FlagReset, flag_low_swap_amount)
// Save max RAT amount to be used in validating the user's input // Save max RAT amount to be used in validating the user's input
err = userStore.WriteEntry(ctx, sessionId, storedb.DATA_ACTIVE_SWAP_MAX_AMOUNT, []byte(maxRAT)) err = userStore.WriteEntry(ctx, sessionId, storedb.DATA_ACTIVE_SWAP_MAX_AMOUNT, []byte(maxRAT))
if err != nil { if err != nil {
@ -529,7 +544,7 @@ func (h *MenuHandlers) SendMpesaMinLimit(ctx context.Context, sym string, input
kshFormatted, _ := store.TruncateDecimalString(ksh, 0) kshFormatted, _ := store.TruncateDecimalString(ksh, 0)
res.Content = l.Get( res.Content = l.Get(
"Enter the amount of credit to receive: (Minimum %s Ksh)\n", "Enter the amount of credit to deposit: (Minimum %s Ksh)\n",
kshFormatted, kshFormatted,
) )
@ -601,7 +616,7 @@ func (h *MenuHandlers) SendMpesaPreview(ctx context.Context, sym string, input [
defaultAsset := config.DefaultMpesaAsset() defaultAsset := config.DefaultMpesaAsset()
res.Content = l.Get( res.Content = l.Get(
"You will get a prompt for your M-Pesa PIN shortly to send %s ksh and receive ~ %s %s", "You will get a prompt for your Mpesa PIN shortly to send %s ksh and receive ~ %s %s",
inputStr, estimateFormatted, defaultAsset, inputStr, estimateFormatted, defaultAsset,
) )

View File

@ -238,7 +238,7 @@ func (h *MenuHandlers) ConfirmDebtRemoval(ctx context.Context, sym string, input
} }
res.Content = l.Get( res.Content = l.Get(
"Please confirm that you will use %s %s to remove your debt of %s %s\n", "Please confirm that you will use %s %s to remove your debt of %s %s\nEnter your PIN:",
inputStr, payDebtVoucher.TokenSymbol, qouteStr, string(activeSym), inputStr, payDebtVoucher.TokenSymbol, qouteStr, string(activeSym),
) )

View File

@ -206,8 +206,8 @@ func (h *MenuHandlers) InitiatePoolDeposit(ctx context.Context, sym string, inpu
return res, err return res, err
} }
// Call pool deposit API // Call token transfer API and send the token to the pool address
r, err := h.accountService.PoolDeposit(ctx, finalAmountStr, string(publicKey), string(activePoolAddress), poolDepositVoucher.TokenAddress) r, err := h.accountService.TokenTransfer(ctx, finalAmountStr, string(publicKey), string(activePoolAddress), poolDepositVoucher.TokenAddress)
if err != nil { if err != nil {
flag_api_call_error, _ := h.flagManager.GetFlag("flag_api_call_error") flag_api_call_error, _ := h.flagManager.GetFlag("flag_api_call_error")
res.FlagSet = append(res.FlagSet, flag_api_call_error) res.FlagSet = append(res.FlagSet, flag_api_call_error)

View File

@ -175,6 +175,10 @@ func (h *MenuHandlers) SwapMaxLimit(ctx context.Context, sym string, input []byt
return res, nil return res, nil
} }
code := codeFromCtx(ctx)
l := gotext.NewLocale(translationDir, code)
l.AddDomain("default")
userStore := h.userdataStore userStore := h.userdataStore
metadata, err := store.GetSwapToVoucherData(ctx, userStore, sessionId, inputStr) metadata, err := store.GetSwapToVoucherData(ctx, userStore, sessionId, inputStr)
if err != nil { if err != nil {
@ -235,7 +239,7 @@ func (h *MenuHandlers) SwapMaxLimit(ctx context.Context, sym string, input []byt
return res, err return res, err
} }
res.Content = fmt.Sprintf( res.Content = l.Get(
"Maximum: %s %s\n\nEnter amount of %s to swap for %s:", "Maximum: %s %s\n\nEnter amount of %s to swap for %s:",
maxStr, swapData.ActiveSwapFromSym, swapData.ActiveSwapFromSym, swapData.ActiveSwapToSym, maxStr, swapData.ActiveSwapFromSym, swapData.ActiveSwapFromSym, swapData.ActiveSwapToSym,
) )
@ -323,8 +327,8 @@ func (h *MenuHandlers) SwapPreview(ctx context.Context, sym string, input []byte
// Format to 2 decimal places // Format to 2 decimal places
qouteStr, _ := store.TruncateDecimalString(string(quoteAmountStr), 2) qouteStr, _ := store.TruncateDecimalString(string(quoteAmountStr), 2)
res.Content = fmt.Sprintf( res.Content = l.Get(
"You will swap:\n%s %s for %s %s:", "You will swap %s %s for %s %s:",
inputStr, swapData.ActiveSwapFromSym, qouteStr, swapData.ActiveSwapToSym, inputStr, swapData.ActiveSwapFromSym, qouteStr, swapData.ActiveSwapToSym,
) )

View File

@ -380,6 +380,19 @@ func (h *MenuHandlers) MaxAmount(ctx context.Context, sym string, input []byte)
return res, err return res, err
} }
// Case for M-Pesa
// if the recipient is Mpesa (address), check if the sender's voucher is a stable coin
recipientAddress, err := userStore.ReadEntry(ctx, sessionId, storedb.DATA_RECIPIENT)
if err != nil {
logg.ErrorCtxf(ctx, "Failed to read recipient's address", "error", err)
return res, err
}
if string(recipientAddress) == config.DefaultMpesaAddress() && isStableVoucher(string(activeAddress)) {
res.FlagReset = append(res.FlagReset, flag_swap_transaction)
res.Content = l.Get("Maximum amount: %s %s\nEnter amount:", formattedBalance, string(activeSym))
return res, nil
}
if string(swapToVoucher.TokenAddress) == string(activeAddress) { if string(swapToVoucher.TokenAddress) == string(activeAddress) {
// recipient has active token same as selected token → normal transaction // recipient has active token same as selected token → normal transaction
transactionType = []byte("normal") transactionType = []byte("normal")

View File

@ -1,2 +1 @@
{{.confirm_debt_removal}} {{.confirm_debt_removal}}
Enter your PIN:

View File

@ -59,7 +59,7 @@ msgid "Enter the amount of M-Pesa to get: (Max %s Ksh)\n"
msgstr "Weka kiasi cha M-Pesa cha kupata: (Kikomo %s Ksh)\n" msgstr "Weka kiasi cha M-Pesa cha kupata: (Kikomo %s Ksh)\n"
msgid "You are sending %s %s in order to receive ~ %s ksh" msgid "You are sending %s %s in order to receive ~ %s ksh"
msgstr "Unatuma ~ %s %s ili upoke %s ksh" msgstr "Unatuma ~ %s %s ili upokee %s ksh"
msgid "Your request has been sent. Please await confirmation" msgid "Your request has been sent. Please await confirmation"
msgstr "Ombi lako limetumwa. Tafadhali subiri" msgstr "Ombi lako limetumwa. Tafadhali subiri"
@ -67,8 +67,8 @@ msgstr "Ombi lako limetumwa. Tafadhali subiri"
msgid "Enter the amount of M-Pesa to send: (Minimum %s Ksh)\n" msgid "Enter the amount of M-Pesa to send: (Minimum %s Ksh)\n"
msgstr "Weka kiasi cha M-Pesa cha kutuma: (Kima cha chini %s Ksh)\n" msgstr "Weka kiasi cha M-Pesa cha kutuma: (Kima cha chini %s Ksh)\n"
msgid "You will get a prompt for your M-Pesa PIN shortly to send %s ksh and receive ~ %s cUSD" msgid "You will get a prompt for your Mpesa PIN shortly to send %s ksh and receive ~ %s %s"
msgstr "Utapokea kidokezo cha PIN yako ya M-Pesa hivi karibuni kutuma %s ksh na kupokea ~ %s cUSD" msgstr "Utapokea kidokezo cha PIN yako ya Mpesa hivi karibuni kutuma %s ksh na kupokea ~ %s %s"
msgid "Your request has been sent. Thank you for using Sarafu" msgid "Your request has been sent. Thank you for using Sarafu"
msgstr "Ombi lako limetumwa. Asante kwa kutumia huduma ya Sarafu" msgstr "Ombi lako limetumwa. Asante kwa kutumia huduma ya Sarafu"
@ -76,7 +76,7 @@ msgstr "Ombi lako limetumwa. Asante kwa kutumia huduma ya Sarafu"
msgid "You can remove a max of %s %s from '%s' pool\nEnter amount of %s:(Max: %s)" msgid "You can remove a max of %s %s from '%s' pool\nEnter amount of %s:(Max: %s)"
msgstr "Unaweza kuondoa kiwango cha juu cha %s %s kutoka kwenye '%s'\n\nWeka kiwango cha %s:(Kikomo: %s)" msgstr "Unaweza kuondoa kiwango cha juu cha %s %s kutoka kwenye '%s'\n\nWeka kiwango cha %s:(Kikomo: %s)"
msgid "Please confirm that you will use %s %s to remove your debt of %s %s\n" msgid "Please confirm that you will use %s %s to remove your debt of %s %s\nEnter your PIN:"
msgstr "Tafadhali thibitisha kwamba utatumia %s %s kulipa deni lako la %s %s.\nWeka PIN yako:" msgstr "Tafadhali thibitisha kwamba utatumia %s %s kulipa deni lako la %s %s.\nWeka PIN yako:"
msgid "Your active voucher %s is already set" msgid "Your active voucher %s is already set"
@ -96,3 +96,18 @@ msgstr %s atapokea %s %s kutoka kwa %s"
msgid "You need another voucher to proceed. Only found %s." msgid "You need another voucher to proceed. Only found %s."
msgstr "Unahitaji kua na sarafu nyingine. Tumepata tu %s." msgstr "Unahitaji kua na sarafu nyingine. Tumepata tu %s."
msgid "Maximum: %s %s\n\nEnter amount of %s to swap for %s:"
msgstr "Kikimo: %s %s\n\nWeka kiasi cha %s kitakacho badilishwa kua %s:"
msgid "You will swap %s %s for %s %s:"
msgstr "Utabadilisha %s %s kua %s %s:"
msgid "Your request has been sent. You will receive an SMS when your debt of %s %s has been removed from %s."
msgstr "Ombi lako limetumwa. Utapokea ujumbe wakati deni lako la %s %s litatolewa kwa %s."
msgid "Enter the amount of Mpesa to withdraw: (Min: Ksh %s, Max %s Ksh)\n"
msgstr "Weka kiasi cha Mpesa utakacho toa: (Min: Ksh %s, Max %s Ksh)\n"
msgid "Enter the amount of credit to deposit: (Minimum %s Ksh)\n"
msgstr "Weka kiasi utakacho weka (Kima cha chini: %s Ksh)\n"

View File

@ -1 +0,0 @@
{{.calculate_max_pay_debt}}

View File

@ -1,3 +1,2 @@
{{.send_mpesa_preview}} {{.send_mpesa_preview}}
Enter your PIN to confirm:
Please enter your account PIN to confirm:

View File

@ -1,3 +1,2 @@
{{.send_mpesa_preview}} {{.send_mpesa_preview}}
Weka PIN yako kudhibitisha:
Tafadhali weka PIN ya akaunti yako kudhibitisha:

View File

@ -12,7 +12,7 @@ INCMP > 88
INCMP < 98 INCMP < 98
INCMP _ 0 INCMP _ 0
INCMP quit 99 INCMP quit 99
LOAD swap_max_limit 64 LOAD swap_max_limit 138
RELOAD swap_max_limit RELOAD swap_max_limit
CATCH api_failure flag_api_call_error 1 CATCH api_failure flag_api_call_error 1
CATCH . flag_incorrect_voucher 1 CATCH . flag_incorrect_voucher 1