project-tidyup - menuhandler and tests #90
							
								
								
									
										2
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
									
									
									
									
								
							| @ -5,7 +5,7 @@ go 1.23.4 | |||||||
| require ( | require ( | ||||||
| 	git.defalsify.org/vise.git v0.3.2-0.20250528124150-03bf7bfc1b66 | 	git.defalsify.org/vise.git v0.3.2-0.20250528124150-03bf7bfc1b66 | ||||||
| 	git.grassecon.net/grassrootseconomics/common v0.9.0-beta.1.0.20250417111317-2953f4c2f32e | 	git.grassecon.net/grassrootseconomics/common v0.9.0-beta.1.0.20250417111317-2953f4c2f32e | ||||||
| 	git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624090744-339ba854c997 | 	git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250630214912-814bef2b209a | ||||||
| 	git.grassecon.net/grassrootseconomics/visedriver v0.9.0-beta.2.0.20250408094335-e2d1f65bb306 | 	git.grassecon.net/grassrootseconomics/visedriver v0.9.0-beta.2.0.20250408094335-e2d1f65bb306 | ||||||
| 	git.grassecon.net/grassrootseconomics/visedriver-africastalking v0.0.0-20250129070628-5a539172c694 | 	git.grassecon.net/grassrootseconomics/visedriver-africastalking v0.0.0-20250129070628-5a539172c694 | ||||||
| 	github.com/alecthomas/assert/v2 v2.2.2 | 	github.com/alecthomas/assert/v2 v2.2.2 | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								go.sum
									
									
									
									
									
								
							| @ -12,6 +12,14 @@ git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624074830- | |||||||
| git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624074830-5aa032400c12/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624074830-5aa032400c12/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | ||||||
| git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624090744-339ba854c997 h1:8bCKyYoV4YiVBvCZlRclc3aQlBYpWhgtM35mvniDFD8= | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624090744-339ba854c997 h1:8bCKyYoV4YiVBvCZlRclc3aQlBYpWhgtM35mvniDFD8= | ||||||
| git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624090744-339ba854c997/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250624090744-339ba854c997/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250626065419-57ee409f9629 h1:ew3vCFrLS/7/8uULTTPCbsHzFntQ6X68SScnBEy3pl0= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250626065419-57ee409f9629/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250630213135-50ee455e7069 h1:re+hdr5NAC6JqhyvjMCkgX17fFi0u3Mawc6RBnBJW8I= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250630213135-50ee455e7069/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250630213606-12940bb5f284 h1:2zMU9jPd6xEO6oY9oxr84sdT9G3d09eyAkjVBAz9eco= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250630213606-12940bb5f284/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250630214912-814bef2b209a h1:KuhJ/WY4RCGmrXUA680ciaponM4vM5zBOJfnCpUo2fc= | ||||||
|  | git.grassecon.net/grassrootseconomics/sarafu-api v0.9.0-beta.1.0.20250630214912-814bef2b209a/go.mod h1:y/vsN8UO0wSxZk3gv0y5df4RPKMJI6TIxjVcVCPF8T8= | ||||||
| git.grassecon.net/grassrootseconomics/visedriver v0.9.0-beta.2.0.20250408094335-e2d1f65bb306 h1:Jo+yWysWw/N5BJQtAyEMN8ePVvAyPHv+JG4lQti+1N4= | git.grassecon.net/grassrootseconomics/visedriver v0.9.0-beta.2.0.20250408094335-e2d1f65bb306 h1:Jo+yWysWw/N5BJQtAyEMN8ePVvAyPHv+JG4lQti+1N4= | ||||||
| git.grassecon.net/grassrootseconomics/visedriver v0.9.0-beta.2.0.20250408094335-e2d1f65bb306/go.mod h1:FdLwYtzsjOIcDiW4uDgDYnB4Wdzq12uJUe0QHSSPbSo= | git.grassecon.net/grassrootseconomics/visedriver v0.9.0-beta.2.0.20250408094335-e2d1f65bb306/go.mod h1:FdLwYtzsjOIcDiW4uDgDYnB4Wdzq12uJUe0QHSSPbSo= | ||||||
| git.grassecon.net/grassrootseconomics/visedriver-africastalking v0.0.0-20250129070628-5a539172c694 h1:DjJlBSz0S13acft5XZDWk7ZYnzElym0xLMYEVgyNJ+E= | git.grassecon.net/grassrootseconomics/visedriver-africastalking v0.0.0-20250129070628-5a539172c694 h1:DjJlBSz0S13acft5XZDWk7ZYnzElym0xLMYEVgyNJ+E= | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ package application | |||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"context" | 	"context" | ||||||
| 	"errors" |  | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"path" | 	"path" | ||||||
| 	"strconv" | 	"strconv" | ||||||
| @ -475,17 +474,14 @@ func loadUserContent(ctx context.Context, activeSym string, balance string, alia | |||||||
| 	l := gotext.NewLocale(translationDir, code) | 	l := gotext.NewLocale(translationDir, code) | ||||||
| 	l.AddDomain("default") | 	l.AddDomain("default") | ||||||
| 
 | 
 | ||||||
| 	balFloat, err := strconv.ParseFloat(balance, 64) | 	// Format the balance to 2 decimal places or default to 0.00
 | ||||||
|  | 	formattedAmount, err := store.TruncateDecimalString(balance, 2) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		//Only exclude ErrSyntax error to avoid returning an error if the active bal is not available yet
 | 		formattedAmount = "0.00" | ||||||
| 		if !errors.Is(err, strconv.ErrSyntax) { |  | ||||||
| 			logg.ErrorCtxf(ctx, "failed to parse activeBal as float", "value", balance, "error", err) |  | ||||||
| 			return "", err |  | ||||||
| 	} | 	} | ||||||
| 		balFloat = 0.00 | 
 | ||||||
| 	} | 	// format the final output
 | ||||||
| 	// Format to 2 decimal places
 | 	balStr := fmt.Sprintf("%s %s", formattedAmount, activeSym) | ||||||
| 	balStr := fmt.Sprintf("%.2f %s", balFloat, activeSym) |  | ||||||
| 	if alias != "" { | 	if alias != "" { | ||||||
| 		content = l.Get("%s balance: %s\n", alias, balStr) | 		content = l.Get("%s balance: %s\n", alias, balStr) | ||||||
| 	} else { | 	} else { | ||||||
| @ -562,6 +558,676 @@ func (h *MenuHandlers) FetchCommunityBalance(ctx context.Context, sym string, in | |||||||
| 	return res, nil | 	return res, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // ValidateRecipient validates that the given input is valid.
 | ||||||
|  | //
 | ||||||
|  | // TODO: split up functino
 | ||||||
|  | func (h *MenuHandlers) ValidateRecipient(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	var AliasAddressResult string | ||||||
|  | 	var AliasAddress *models.AliasAddress | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 	flag_invalid_recipient, _ := h.flagManager.GetFlag("flag_invalid_recipient") | ||||||
|  | 	flag_invalid_recipient_with_invite, _ := h.flagManager.GetFlag("flag_invalid_recipient_with_invite") | ||||||
|  | 	flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") | ||||||
|  | 
 | ||||||
|  | 	// remove white spaces
 | ||||||
|  | 	recipient := strings.ReplaceAll(string(input), " ", "") | ||||||
|  | 
 | ||||||
|  | 	if recipient != "0" { | ||||||
|  | 		recipientType, err := identity.CheckRecipient(recipient) | ||||||
|  | 		if err != nil { | ||||||
|  | 			// Invalid recipient format (not a phone number, address, or valid alias format)
 | ||||||
|  | 			res.FlagSet = append(res.FlagSet, flag_invalid_recipient) | ||||||
|  | 			res.Content = recipient | ||||||
|  | 
 | ||||||
|  | 			return res, nil | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// save the recipient as the temporaryRecipient
 | ||||||
|  | 		err = store.WriteEntry(ctx, sessionId, storedb.DATA_TEMPORARY_VALUE, []byte(recipient)) | ||||||
|  | 		if err != nil { | ||||||
|  | 			logg.ErrorCtxf(ctx, "failed to write temporaryRecipient entry with", "key", storedb.DATA_TEMPORARY_VALUE, "value", recipient, "error", err) | ||||||
|  | 			return res, err | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		switch recipientType { | ||||||
|  | 		case "phone number": | ||||||
|  | 			// format the phone number
 | ||||||
|  | 			formattedNumber, err := phone.FormatPhoneNumber(recipient) | ||||||
|  | 			if err != nil { | ||||||
|  | 				logg.ErrorCtxf(ctx, "Failed to format the phone number: %s", recipient, "error", err) | ||||||
|  | 				return res, err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Check if the phone number is registered
 | ||||||
|  | 			publicKey, err := store.ReadEntry(ctx, formattedNumber, storedb.DATA_PUBLIC_KEY) | ||||||
|  | 			if err != nil { | ||||||
|  | 				if db.IsNotFound(err) { | ||||||
|  | 					logg.InfoCtxf(ctx, "Unregistered phone number: %s", recipient) | ||||||
|  | 					res.FlagSet = append(res.FlagSet, flag_invalid_recipient_with_invite) | ||||||
|  | 					res.Content = recipient | ||||||
|  | 					return res, nil | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", storedb.DATA_PUBLIC_KEY, "error", err) | ||||||
|  | 				return res, err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Save the publicKey as the recipient
 | ||||||
|  | 			err = store.WriteEntry(ctx, sessionId, storedb.DATA_RECIPIENT, publicKey) | ||||||
|  | 			if err != nil { | ||||||
|  | 				logg.ErrorCtxf(ctx, "failed to write recipient entry with", "key", storedb.DATA_RECIPIENT, "value", string(publicKey), "error", err) | ||||||
|  | 				return res, err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 		case "address": | ||||||
|  | 			// checksum the address
 | ||||||
|  | 			address := ethutils.ChecksumAddress(recipient) | ||||||
|  | 
 | ||||||
|  | 			// Save the valid Ethereum address as the recipient
 | ||||||
|  | 			err = store.WriteEntry(ctx, sessionId, storedb.DATA_RECIPIENT, []byte(address)) | ||||||
|  | 			if err != nil { | ||||||
|  | 				logg.ErrorCtxf(ctx, "failed to write recipient entry with", "key", storedb.DATA_RECIPIENT, "value", recipient, "error", err) | ||||||
|  | 				return res, err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 		case "alias": | ||||||
|  | 			if strings.Contains(recipient, ".") { | ||||||
|  | 				AliasAddress, err = h.accountService.CheckAliasAddress(ctx, recipient) | ||||||
|  | 				if err == nil { | ||||||
|  | 					AliasAddressResult = AliasAddress.Address | ||||||
|  | 				} else { | ||||||
|  | 					logg.ErrorCtxf(ctx, "failed to resolve alias", "alias", recipient, "error_alias_check", err) | ||||||
|  | 				} | ||||||
|  | 			} else { | ||||||
|  | 				//Perform a search for each search domain,break on first match
 | ||||||
|  | 				for _, domain := range config.SearchDomains() { | ||||||
|  | 					fqdn := fmt.Sprintf("%s.%s", recipient, domain) | ||||||
|  | 					logg.InfoCtxf(ctx, "Resolving with fqdn alias", "alias", fqdn) | ||||||
|  | 					AliasAddress, err = h.accountService.CheckAliasAddress(ctx, fqdn) | ||||||
|  | 					if err == nil { | ||||||
|  | 						res.FlagReset = append(res.FlagReset, flag_api_error) | ||||||
|  | 						AliasAddressResult = AliasAddress.Address | ||||||
|  | 						continue | ||||||
|  | 					} else { | ||||||
|  | 						res.FlagSet = append(res.FlagSet, flag_api_error) | ||||||
|  | 						logg.ErrorCtxf(ctx, "failed to resolve alias", "alias", recipient, "error_alias_check", err) | ||||||
|  | 						return res, nil | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			if AliasAddressResult == "" { | ||||||
|  | 				res.Content = recipient | ||||||
|  | 				res.FlagSet = append(res.FlagSet, flag_invalid_recipient) | ||||||
|  | 				return res, nil | ||||||
|  | 			} else { | ||||||
|  | 				err = store.WriteEntry(ctx, sessionId, storedb.DATA_RECIPIENT, []byte(AliasAddressResult)) | ||||||
|  | 				if err != nil { | ||||||
|  | 					logg.ErrorCtxf(ctx, "failed to write recipient entry with", "key", storedb.DATA_RECIPIENT, "value", AliasAddressResult, "error", err) | ||||||
|  | 					return res, err | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // TransactionReset resets the previous transaction data (Recipient and Amount)
 | ||||||
|  | // as well as the invalid flags.
 | ||||||
|  | func (h *MenuHandlers) TransactionReset(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	var err error | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	flag_invalid_recipient, _ := h.flagManager.GetFlag("flag_invalid_recipient") | ||||||
|  | 	flag_invalid_recipient_with_invite, _ := h.flagManager.GetFlag("flag_invalid_recipient_with_invite") | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 	err = store.WriteEntry(ctx, sessionId, storedb.DATA_AMOUNT, []byte("")) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err = store.WriteEntry(ctx, sessionId, storedb.DATA_RECIPIENT, []byte("")) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.FlagReset = append(res.FlagReset, flag_invalid_recipient, flag_invalid_recipient_with_invite) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // InviteValidRecipient sends an invitation to the valid phone number.
 | ||||||
|  | func (h *MenuHandlers) InviteValidRecipient(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 	smsservice := h.smsService | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	code := codeFromCtx(ctx) | ||||||
|  | 	l := gotext.NewLocale(translationDir, code) | ||||||
|  | 	l.AddDomain("default") | ||||||
|  | 
 | ||||||
|  | 	recipient, err := store.ReadEntry(ctx, sessionId, storedb.DATA_TEMPORARY_VALUE) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "Failed to read invalid recipient info", "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if !phone.IsValidPhoneNumber(string(recipient)) { | ||||||
|  | 		logg.InfoCtxf(ctx, "corrupted recipient", "key", storedb.DATA_TEMPORARY_VALUE, "recipient", recipient) | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	_, err = smsservice.Accountservice.SendUpsellSMS(ctx, sessionId, string(recipient)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		res.Content = l.Get("Your invite request for %s to Sarafu Network failed. Please try again later.", string(recipient)) | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 	res.Content = l.Get("Your invitation to %s to join Sarafu Network has been sent.", string(recipient)) | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ResetTransactionAmount resets the transaction amount and invalid flag.
 | ||||||
|  | func (h *MenuHandlers) ResetTransactionAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	var err error | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	flag_invalid_amount, _ := h.flagManager.GetFlag("flag_invalid_amount") | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 	err = store.WriteEntry(ctx, sessionId, storedb.DATA_AMOUNT, []byte("")) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.FlagReset = append(res.FlagReset, flag_invalid_amount) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // MaxAmount gets the current balance from the API and sets it as
 | ||||||
|  | // the result content.
 | ||||||
|  | func (h *MenuHandlers) MaxAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	var err error | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 
 | ||||||
|  | 	activeBal, err := store.ReadEntry(ctx, sessionId, storedb.DATA_ACTIVE_BAL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read activeBal entry with", "key", storedb.DATA_ACTIVE_BAL, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.Content = string(activeBal) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ValidateAmount ensures that the given input is a valid amount and that
 | ||||||
|  | // it is not more than the current balance.
 | ||||||
|  | func (h *MenuHandlers) ValidateAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 	flag_invalid_amount, _ := h.flagManager.GetFlag("flag_invalid_amount") | ||||||
|  | 	userStore := h.userdataStore | ||||||
|  | 
 | ||||||
|  | 	var balanceValue float64 | ||||||
|  | 
 | ||||||
|  | 	// retrieve the active balance
 | ||||||
|  | 	activeBal, err := userStore.ReadEntry(ctx, sessionId, storedb.DATA_ACTIVE_BAL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read activeBal entry with", "key", storedb.DATA_ACTIVE_BAL, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 	balanceValue, err = strconv.ParseFloat(string(activeBal), 64) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "Failed to convert the activeBal to a float", "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Extract numeric part from the input amount
 | ||||||
|  | 	amountStr := strings.TrimSpace(string(input)) | ||||||
|  | 	inputAmount, err := strconv.ParseFloat(amountStr, 64) | ||||||
|  | 	if err != nil { | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_invalid_amount) | ||||||
|  | 		res.Content = amountStr | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if inputAmount > balanceValue { | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_invalid_amount) | ||||||
|  | 		res.Content = amountStr | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Format the amount to 2 decimal places before saving (truncated)
 | ||||||
|  | 	formattedAmount, err := store.TruncateDecimalString(amountStr, 2) | ||||||
|  | 	if err != nil { | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_invalid_amount) | ||||||
|  | 		res.Content = amountStr | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err = userStore.WriteEntry(ctx, sessionId, storedb.DATA_AMOUNT, []byte(formattedAmount)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to write amount entry with", "key", storedb.DATA_AMOUNT, "value", formattedAmount, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.Content = formattedAmount | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetRecipient returns the transaction recipient phone number from the gdbm.
 | ||||||
|  | func (h *MenuHandlers) GetRecipient(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 	recipient, _ := store.ReadEntry(ctx, sessionId, storedb.DATA_TEMPORARY_VALUE) | ||||||
|  | 	if len(recipient) == 0 { | ||||||
|  | 		logg.ErrorCtxf(ctx, "recipient is empty", "key", storedb.DATA_TEMPORARY_VALUE) | ||||||
|  | 		return res, fmt.Errorf("Data error encountered") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.Content = string(recipient) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // RetrieveBlockedNumber gets the current number during the pin reset for other's is in progress.
 | ||||||
|  | func (h *MenuHandlers) RetrieveBlockedNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 	blockedNumber, _ := store.ReadEntry(ctx, sessionId, storedb.DATA_BLOCKED_NUMBER) | ||||||
|  | 
 | ||||||
|  | 	res.Content = string(blockedNumber) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetSender returns the sessionId (phoneNumber).
 | ||||||
|  | func (h *MenuHandlers) GetSender(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.Content = sessionId | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetAmount retrieves the amount from teh Gdbm Db.
 | ||||||
|  | func (h *MenuHandlers) GetAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 
 | ||||||
|  | 	// retrieve the active symbol
 | ||||||
|  | 	activeSym, err := store.ReadEntry(ctx, sessionId, storedb.DATA_ACTIVE_SYM) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read activeSym entry with", "key", storedb.DATA_ACTIVE_SYM, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	amount, _ := store.ReadEntry(ctx, sessionId, storedb.DATA_AMOUNT) | ||||||
|  | 
 | ||||||
|  | 	res.Content = fmt.Sprintf("%s %s", string(amount), string(activeSym)) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // InitiateTransaction calls the TokenTransfer and returns a confirmation based on the result.
 | ||||||
|  | func (h *MenuHandlers) InitiateTransaction(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	var err error | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	flag_account_authorized, _ := h.flagManager.GetFlag("flag_account_authorized") | ||||||
|  | 
 | ||||||
|  | 	code := codeFromCtx(ctx) | ||||||
|  | 	l := gotext.NewLocale(translationDir, code) | ||||||
|  | 	l.AddDomain("default") | ||||||
|  | 
 | ||||||
|  | 	data, err := store.ReadTransactionData(ctx, h.userdataStore, sessionId) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	finalAmountStr, err := store.ParseAndScaleAmount(data.Amount, data.ActiveDecimal) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Call TokenTransfer
 | ||||||
|  | 	r, err := h.accountService.TokenTransfer(ctx, finalAmountStr, data.PublicKey, data.Recipient, data.ActiveAddress) | ||||||
|  | 	if err != nil { | ||||||
|  | 		flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_api_error) | ||||||
|  | 		res.Content = l.Get("Your request failed. Please try again later.") | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed on TokenTransfer", "error", err) | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	trackingId := r.TrackingId | ||||||
|  | 	logg.InfoCtxf(ctx, "TokenTransfer", "trackingId", trackingId) | ||||||
|  | 
 | ||||||
|  | 	res.Content = l.Get( | ||||||
|  | 		"Your request has been sent. %s will receive %s %s from %s.", | ||||||
|  | 		data.TemporaryValue, | ||||||
|  | 		data.Amount, | ||||||
|  | 		data.ActiveSym, | ||||||
|  | 		sessionId, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	res.FlagReset = append(res.FlagReset, flag_account_authorized) | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ManageVouchers retrieves the token holdings from the API using the "PublicKey" and
 | ||||||
|  | // 1. sets the first as the default voucher if no active voucher is set.
 | ||||||
|  | // 2. Stores list of vouchers
 | ||||||
|  | // 3. updates the balance of the active voucher
 | ||||||
|  | func (h *MenuHandlers) ManageVouchers(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	userStore := h.userdataStore | ||||||
|  | 	logdb := h.logDb | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	flag_no_active_voucher, _ := h.flagManager.GetFlag("flag_no_active_voucher") | ||||||
|  | 	flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") | ||||||
|  | 
 | ||||||
|  | 	publicKey, err := userStore.ReadEntry(ctx, sessionId, storedb.DATA_PUBLIC_KEY) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read publicKey entry", "key", storedb.DATA_PUBLIC_KEY, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Fetch vouchers from API
 | ||||||
|  | 	vouchersResp, err := h.accountService.FetchVouchers(ctx, string(publicKey)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_api_error) | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 	res.FlagReset = append(res.FlagReset, flag_api_error) | ||||||
|  | 
 | ||||||
|  | 	if len(vouchersResp) == 0 { | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_no_active_voucher) | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.FlagReset = append(res.FlagReset, flag_no_active_voucher) | ||||||
|  | 
 | ||||||
|  | 	// Check if user has an active voucher with proper error handling
 | ||||||
|  | 	activeSym, err := userStore.ReadEntry(ctx, sessionId, storedb.DATA_ACTIVE_SYM) | ||||||
|  | 	if err != nil { | ||||||
|  | 		if db.IsNotFound(err) { | ||||||
|  | 			// No active voucher, set the first one as default
 | ||||||
|  | 			firstVoucher := vouchersResp[0] | ||||||
|  | 			defaultSym := firstVoucher.TokenSymbol | ||||||
|  | 			defaultBal := firstVoucher.Balance | ||||||
|  | 			defaultDec := firstVoucher.TokenDecimals | ||||||
|  | 			defaultAddr := firstVoucher.TokenAddress | ||||||
|  | 
 | ||||||
|  | 			// Scale down the balance
 | ||||||
|  | 			scaledBalance := store.ScaleDownBalance(defaultBal, defaultDec) | ||||||
|  | 
 | ||||||
|  | 			firstVoucherMap := map[storedb.DataTyp]string{ | ||||||
|  | 				storedb.DATA_ACTIVE_SYM:     defaultSym, | ||||||
|  | 				storedb.DATA_ACTIVE_BAL:     scaledBalance, | ||||||
|  | 				storedb.DATA_ACTIVE_DECIMAL: defaultDec, | ||||||
|  | 				storedb.DATA_ACTIVE_ADDRESS: defaultAddr, | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			for key, value := range firstVoucherMap { | ||||||
|  | 				if err := userStore.WriteEntry(ctx, sessionId, key, []byte(value)); err != nil { | ||||||
|  | 					logg.ErrorCtxf(ctx, "Failed to write active voucher data", "key", key, "error", err) | ||||||
|  | 					return res, err | ||||||
|  | 				} | ||||||
|  | 				err = logdb.WriteLogEntry(ctx, sessionId, key, []byte(value)) | ||||||
|  | 				if err != nil { | ||||||
|  | 					logg.DebugCtxf(ctx, "Failed to write voucher db log entry", "key", key, "value", value) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			logg.InfoCtxf(ctx, "Default voucher set", "symbol", defaultSym, "balance", defaultBal, "decimals", defaultDec, "address", defaultAddr) | ||||||
|  | 		} else { | ||||||
|  | 			logg.ErrorCtxf(ctx, "failed to read activeSym entry with", "key", storedb.DATA_ACTIVE_SYM, "error", err) | ||||||
|  | 			return res, err | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		// Update active voucher balance
 | ||||||
|  | 		activeSymStr := string(activeSym) | ||||||
|  | 
 | ||||||
|  | 		// Find the matching voucher data
 | ||||||
|  | 		var activeData *dataserviceapi.TokenHoldings | ||||||
|  | 		for _, voucher := range vouchersResp { | ||||||
|  | 			if voucher.TokenSymbol == activeSymStr { | ||||||
|  | 				activeData = &voucher | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if activeData == nil { | ||||||
|  | 			logg.ErrorCtxf(ctx, "activeSym not found in vouchers, setting the first voucher as the default", "activeSym", activeSymStr) | ||||||
|  | 			firstVoucher := vouchersResp[0] | ||||||
|  | 			activeData = &firstVoucher | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Scale down the balance
 | ||||||
|  | 		scaledBalance := store.ScaleDownBalance(activeData.Balance, activeData.TokenDecimals) | ||||||
|  | 
 | ||||||
|  | 		// Update the balance field with the scaled value
 | ||||||
|  | 		activeData.Balance = scaledBalance | ||||||
|  | 
 | ||||||
|  | 		// Pass the matching voucher data to UpdateVoucherData
 | ||||||
|  | 		if err := store.UpdateVoucherData(ctx, h.userdataStore, sessionId, activeData); err != nil { | ||||||
|  | 			logg.ErrorCtxf(ctx, "failed on UpdateVoucherData", "error", err) | ||||||
|  | 			return res, err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Store all voucher data
 | ||||||
|  | 	data := store.ProcessVouchers(vouchersResp) | ||||||
|  | 	dataMap := map[storedb.DataTyp]string{ | ||||||
|  | 		storedb.DATA_VOUCHER_SYMBOLS:   data.Symbols, | ||||||
|  | 		storedb.DATA_VOUCHER_BALANCES:  data.Balances, | ||||||
|  | 		storedb.DATA_VOUCHER_DECIMALS:  data.Decimals, | ||||||
|  | 		storedb.DATA_VOUCHER_ADDRESSES: data.Addresses, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Write data entries
 | ||||||
|  | 	for key, value := range dataMap { | ||||||
|  | 		if err := userStore.WriteEntry(ctx, sessionId, key, []byte(value)); err != nil { | ||||||
|  | 			logg.ErrorCtxf(ctx, "Failed to write data entry for sessionId: %s", sessionId, "key", key, "error", err) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetVoucherList fetches the list of vouchers and formats them.
 | ||||||
|  | func (h *MenuHandlers) GetVoucherList(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	userStore := h.userdataStore | ||||||
|  | 
 | ||||||
|  | 	// Read vouchers from the store
 | ||||||
|  | 	voucherData, err := userStore.ReadEntry(ctx, sessionId, storedb.DATA_VOUCHER_SYMBOLS) | ||||||
|  | 	logg.InfoCtxf(ctx, "reading voucherData in GetVoucherList", "sessionId", sessionId, "key", storedb.DATA_VOUCHER_SYMBOLS, "voucherData", voucherData) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read voucherData entires with", "key", storedb.DATA_VOUCHER_SYMBOLS, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	voucherBalances, err := userStore.ReadEntry(ctx, sessionId, storedb.DATA_VOUCHER_BALANCES) | ||||||
|  | 	logg.InfoCtxf(ctx, "reading voucherBalances in GetVoucherList", "sessionId", sessionId, "key", storedb.DATA_VOUCHER_BALANCES, "voucherBalances", voucherBalances) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read voucherData entires with", "key", storedb.DATA_VOUCHER_BALANCES, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	formattedVoucherList := store.FormatVoucherList(ctx, string(voucherData), string(voucherBalances)) | ||||||
|  | 	finalOutput := strings.Join(formattedVoucherList, "\n") | ||||||
|  | 
 | ||||||
|  | 	logg.InfoCtxf(ctx, "final output for GetVoucherList", "sessionId", sessionId, "finalOutput", finalOutput) | ||||||
|  | 
 | ||||||
|  | 	res.Content = finalOutput | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ViewVoucher retrieves the token holding and balance from the subprefixDB
 | ||||||
|  | // and displays it to the user for them to select it.
 | ||||||
|  | func (h *MenuHandlers) ViewVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	code := codeFromCtx(ctx) | ||||||
|  | 	l := gotext.NewLocale(translationDir, code) | ||||||
|  | 	l.AddDomain("default") | ||||||
|  | 
 | ||||||
|  | 	flag_incorrect_voucher, _ := h.flagManager.GetFlag("flag_incorrect_voucher") | ||||||
|  | 
 | ||||||
|  | 	inputStr := string(input) | ||||||
|  | 
 | ||||||
|  | 	metadata, err := store.GetVoucherData(ctx, h.userdataStore, sessionId, inputStr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return res, fmt.Errorf("failed to retrieve voucher data: %v", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if metadata == nil { | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_incorrect_voucher) | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := store.StoreTemporaryVoucher(ctx, h.userdataStore, sessionId, metadata); err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed on StoreTemporaryVoucher", "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.FlagReset = append(res.FlagReset, flag_incorrect_voucher) | ||||||
|  | 	res.Content = l.Get("Symbol: %s\nBalance: %s", metadata.TokenSymbol, metadata.Balance) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetVoucher retrieves the temp voucher data and sets it as the active data.
 | ||||||
|  | func (h *MenuHandlers) SetVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 
 | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Get temporary data
 | ||||||
|  | 	tempData, err := store.GetTemporaryVoucherData(ctx, h.userdataStore, sessionId) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed on GetTemporaryVoucherData", "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Set as active and clear temporary data
 | ||||||
|  | 	if err := store.UpdateVoucherData(ctx, h.userdataStore, sessionId, tempData); err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed on UpdateVoucherData", "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	res.Content = tempData.TokenSymbol | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // GetVoucherDetails retrieves the voucher details.
 | ||||||
|  | func (h *MenuHandlers) GetVoucherDetails(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
|  | 	var res resource.Result | ||||||
|  | 	store := h.userdataStore | ||||||
|  | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
|  | 	if !ok { | ||||||
|  | 		return res, fmt.Errorf("missing session") | ||||||
|  | 	} | ||||||
|  | 	flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") | ||||||
|  | 
 | ||||||
|  | 	// get the active address
 | ||||||
|  | 	activeAddress, err := store.ReadEntry(ctx, sessionId, storedb.DATA_ACTIVE_ADDRESS) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read activeAddress entry with", "key", storedb.DATA_ACTIVE_ADDRESS, "error", err) | ||||||
|  | 		return res, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// use the voucher contract address to get the data from the API
 | ||||||
|  | 	voucherData, err := h.accountService.VoucherData(ctx, string(activeAddress)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		res.FlagSet = append(res.FlagSet, flag_api_error) | ||||||
|  | 		return res, nil | ||||||
|  | 	} | ||||||
|  | 	res.FlagReset = append(res.FlagReset, flag_api_error) | ||||||
|  | 
 | ||||||
|  | 	res.Content = fmt.Sprintf( | ||||||
|  | 		"Name: %s\nSymbol: %s\nCommodity: %s\nLocation: %s", voucherData.TokenName, voucherData.TokenSymbol, voucherData.TokenCommodity, voucherData.TokenLocation, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	return res, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // GetDefaultPool returns the current user's Pool. If none is set, it returns the default config pool.
 | // GetDefaultPool returns the current user's Pool. If none is set, it returns the default config pool.
 | ||||||
| func (h *MenuHandlers) GetDefaultPool(ctx context.Context, sym string, input []byte) (resource.Result, error) { | func (h *MenuHandlers) GetDefaultPool(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
| 	var res resource.Result | 	var res resource.Result | ||||||
| @ -699,55 +1365,10 @@ func (h *MenuHandlers) persistLanguageCode(ctx context.Context, code string) err | |||||||
| 	return h.persistInitialLanguageCode(ctx, sessionId, code) | 	return h.persistInitialLanguageCode(ctx, sessionId, code) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // constructAccountAlias retrieves and alias based on the first and family name
 |  | ||||||
| // and writes the result in DATA_ACCOUNT_ALIAS
 |  | ||||||
| func (h *MenuHandlers) constructAccountAlias(ctx context.Context) error { |  | ||||||
| 	var alias string |  | ||||||
| 	store := h.userdataStore |  | ||||||
| 	sessionId, ok := ctx.Value("SessionId").(string) |  | ||||||
| 	if !ok { |  | ||||||
| 		return fmt.Errorf("missing session") |  | ||||||
| 	} |  | ||||||
| 	firstName, err := store.ReadEntry(ctx, sessionId, storedb.DATA_FIRST_NAME) |  | ||||||
| 	if err != nil { |  | ||||||
| 		if db.IsNotFound(err) { |  | ||||||
| 			return nil |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	familyName, err := store.ReadEntry(ctx, sessionId, storedb.DATA_FAMILY_NAME) |  | ||||||
| 	if err != nil { |  | ||||||
| 		if db.IsNotFound(err) { |  | ||||||
| 			return nil |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	pubKey, err := store.ReadEntry(ctx, sessionId, storedb.DATA_PUBLIC_KEY) |  | ||||||
| 	if err != nil { |  | ||||||
| 		if db.IsNotFound(err) { |  | ||||||
| 			return nil |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	aliasInput := fmt.Sprintf("%s%s", firstName, familyName) |  | ||||||
| 	aliasResult, err := h.accountService.RequestAlias(ctx, string(pubKey), aliasInput) |  | ||||||
| 	if err != nil { |  | ||||||
| 		logg.ErrorCtxf(ctx, "failed to retrieve alias", "alias", aliasInput, "error_alias_request", err) |  | ||||||
| 		return fmt.Errorf("Failed to retrieve alias: %s", err.Error()) |  | ||||||
| 	} |  | ||||||
| 	alias = aliasResult.Alias |  | ||||||
| 	//Store the alias
 |  | ||||||
| 	err = store.WriteEntry(ctx, sessionId, storedb.DATA_ACCOUNT_ALIAS, []byte(alias)) |  | ||||||
| 	if err != nil { |  | ||||||
| 		logg.ErrorCtxf(ctx, "failed to write account alias", "key", storedb.DATA_ACCOUNT_ALIAS, "value", alias, "error", err) |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // RequestCustomAlias requests an ENS based alias name based on a user's input,then saves it as temporary value
 | // RequestCustomAlias requests an ENS based alias name based on a user's input,then saves it as temporary value
 | ||||||
| func (h *MenuHandlers) RequestCustomAlias(ctx context.Context, sym string, input []byte) (resource.Result, error) { | func (h *MenuHandlers) RequestCustomAlias(ctx context.Context, sym string, input []byte) (resource.Result, error) { | ||||||
| 	var res resource.Result | 	var res resource.Result | ||||||
|  | 	var alias string | ||||||
| 	sessionId, ok := ctx.Value("SessionId").(string) | 	sessionId, ok := ctx.Value("SessionId").(string) | ||||||
| 	if !ok { | 	if !ok { | ||||||
| 		return res, fmt.Errorf("missing session") | 		return res, fmt.Errorf("missing session") | ||||||
| @ -772,28 +1393,45 @@ func (h *MenuHandlers) RequestCustomAlias(ctx context.Context, sym string, input | |||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return res, err | 			return res, err | ||||||
| 		} | 		} | ||||||
| 		pubKey, err := store.ReadEntry(ctx, sessionId, storedb.DATA_PUBLIC_KEY) | 		publicKey, err := store.ReadEntry(ctx, sessionId, storedb.DATA_PUBLIC_KEY) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			if db.IsNotFound(err) { | 			if db.IsNotFound(err) { | ||||||
| 				return res, nil | 				return res, nil | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		sanitizedInput := sanitizeAliasHint(string(input)) | 		sanitizedInput := sanitizeAliasHint(string(input)) | ||||||
| 		aliasResult, err := h.accountService.RequestAlias(ctx, string(pubKey), sanitizedInput) | 		// Check if an alias already exists
 | ||||||
|  | 		existingAlias, err := store.ReadEntry(ctx, sessionId, storedb.DATA_SUGGESTED_ALIAS) | ||||||
|  | 		if err == nil && len(existingAlias) > 0 { | ||||||
|  | 			logg.InfoCtxf(ctx, "Current alias", "alias", string(existingAlias)) | ||||||
|  | 			// Update existing alias
 | ||||||
|  | 			aliasResult, err := h.accountService.UpdateAlias(ctx, sanitizedInput, string(publicKey)) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				res.FlagSet = append(res.FlagSet, flag_api_error) | 				res.FlagSet = append(res.FlagSet, flag_api_error) | ||||||
| 			logg.ErrorCtxf(ctx, "failed to retrieve alias", "alias", string(aliasHint), "error_alias_request", err) | 				logg.ErrorCtxf(ctx, "failed to update alias", "alias", sanitizedInput, "error", err) | ||||||
|  | 				return res, nil | ||||||
|  | 			} | ||||||
|  | 			alias = aliasResult.Alias | ||||||
|  | 			logg.InfoCtxf(ctx, "Updated alias", "alias", alias) | ||||||
|  | 		} else { | ||||||
|  | 			logg.InfoCtxf(ctx, "Registering a new alias", "err", err) | ||||||
|  | 			// Register a new alias
 | ||||||
|  | 			aliasResult, err := h.accountService.RequestAlias(ctx, string(publicKey), sanitizedInput) | ||||||
|  | 			if err != nil { | ||||||
|  | 				res.FlagSet = append(res.FlagSet, flag_api_error) | ||||||
|  | 				logg.ErrorCtxf(ctx, "failed to retrieve alias", "alias", sanitizedInput, "error_alias_request", err) | ||||||
| 				return res, nil | 				return res, nil | ||||||
| 			} | 			} | ||||||
| 			res.FlagReset = append(res.FlagReset, flag_api_error) | 			res.FlagReset = append(res.FlagReset, flag_api_error) | ||||||
| 
 | 
 | ||||||
| 		alias := aliasResult.Alias | 			alias = aliasResult.Alias | ||||||
| 		logg.InfoCtxf(ctx, "Suggested alias ", "alias", alias) | 			logg.InfoCtxf(ctx, "Suggested alias", "alias", alias) | ||||||
| 
 | 		} | ||||||
| 		//Store the returned alias,wait for user to confirm it as new account alias
 | 		//Store the returned alias,wait for user to confirm it as new account alias
 | ||||||
|  | 		logg.InfoCtxf(ctx, "Final suggested alias", "alias", alias) | ||||||
| 		err = store.WriteEntry(ctx, sessionId, storedb.DATA_SUGGESTED_ALIAS, []byte(alias)) | 		err = store.WriteEntry(ctx, sessionId, storedb.DATA_SUGGESTED_ALIAS, []byte(alias)) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			logg.ErrorCtxf(ctx, "failed to write account alias", "key", storedb.DATA_TEMPORARY_VALUE, "value", alias, "error", err) | 			logg.ErrorCtxf(ctx, "failed to write suggested alias", "key", storedb.DATA_SUGGESTED_ALIAS, "value", alias, "error", err) | ||||||
| 			return res, err | 			return res, err | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @ -821,7 +1459,8 @@ func (h *MenuHandlers) GetSuggestedAlias(ctx context.Context, sym string, input | |||||||
| 		return res, fmt.Errorf("missing session") | 		return res, fmt.Errorf("missing session") | ||||||
| 	} | 	} | ||||||
| 	suggestedAlias, err := store.ReadEntry(ctx, sessionId, storedb.DATA_SUGGESTED_ALIAS) | 	suggestedAlias, err := store.ReadEntry(ctx, sessionId, storedb.DATA_SUGGESTED_ALIAS) | ||||||
| 	if err != nil { | 	if err != nil && len(suggestedAlias) <= 0 { | ||||||
|  | 		logg.ErrorCtxf(ctx, "failed to read suggested alias", "key", storedb.DATA_SUGGESTED_ALIAS, "error", err) | ||||||
| 		return res, nil | 		return res, nil | ||||||
| 	} | 	} | ||||||
| 	res.Content = string(suggestedAlias) | 	res.Content = string(suggestedAlias) | ||||||
|  | |||||||
| @ -1840,20 +1840,42 @@ func TestCheckBalance(t *testing.T) { | |||||||
| 		name           string | 		name           string | ||||||
| 		sessionId      string | 		sessionId      string | ||||||
| 		publicKey      string | 		publicKey      string | ||||||
|  | 		alias          string | ||||||
| 		activeSym      string | 		activeSym      string | ||||||
| 		activeBal      string | 		activeBal      string | ||||||
| 		expectedResult resource.Result | 		expectedResult resource.Result | ||||||
| 		expectError    bool | 		expectError    bool | ||||||
| 	}{ | 	}{ | ||||||
|  | 		{ | ||||||
|  | 			name:           "User with no active sym", | ||||||
|  | 			sessionId:      "session123", | ||||||
|  | 			publicKey:      "0X98765432109", | ||||||
|  | 			alias:          "", | ||||||
|  | 			activeSym:      "", | ||||||
|  | 			activeBal:      "", | ||||||
|  | 			expectedResult: resource.Result{Content: "Balance: 0.00 \n"}, | ||||||
|  | 			expectError:    false, | ||||||
|  | 		}, | ||||||
| 		{ | 		{ | ||||||
| 			name:           "User with active sym", | 			name:           "User with active sym", | ||||||
| 			sessionId:      "session123", | 			sessionId:      "session123", | ||||||
| 			publicKey:      "0X98765432109", | 			publicKey:      "0X98765432109", | ||||||
|  | 			alias:          "", | ||||||
| 			activeSym:      "ETH", | 			activeSym:      "ETH", | ||||||
| 			activeBal:      "1.5", | 			activeBal:      "1.5", | ||||||
| 			expectedResult: resource.Result{Content: "Balance: 1.50 ETH\n"}, | 			expectedResult: resource.Result{Content: "Balance: 1.50 ETH\n"}, | ||||||
| 			expectError:    false, | 			expectError:    false, | ||||||
| 		}, | 		}, | ||||||
|  | 		{ | ||||||
|  | 			name:           "User with active sym and alias", | ||||||
|  | 			sessionId:      "session123", | ||||||
|  | 			publicKey:      "0X98765432109", | ||||||
|  | 			alias:          "user72", | ||||||
|  | 			activeSym:      "SRF", | ||||||
|  | 			activeBal:      "10.967", | ||||||
|  | 			expectedResult: resource.Result{Content: "user72 balance: 10.96 SRF\n"}, | ||||||
|  | 			expectError:    false, | ||||||
|  | 		}, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, tt := range tests { | 	for _, tt := range tests { | ||||||
| @ -1866,14 +1888,26 @@ func TestCheckBalance(t *testing.T) { | |||||||
| 				accountService: mockAccountService, | 				accountService: mockAccountService, | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
|  | 			if tt.alias != "" { | ||||||
|  | 				err := store.WriteEntry(ctx, tt.sessionId, storedb.DATA_ACCOUNT_ALIAS, []byte(tt.alias)) | ||||||
|  | 				if err != nil { | ||||||
|  | 					t.Fatal(err) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			if tt.activeSym != "" { | ||||||
| 				err := store.WriteEntry(ctx, tt.sessionId, storedb.DATA_ACTIVE_SYM, []byte(tt.activeSym)) | 				err := store.WriteEntry(ctx, tt.sessionId, storedb.DATA_ACTIVE_SYM, []byte(tt.activeSym)) | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 					t.Fatal(err) | 					t.Fatal(err) | ||||||
| 				} | 				} | ||||||
| 			err = store.WriteEntry(ctx, tt.sessionId, storedb.DATA_ACTIVE_BAL, []byte(tt.activeBal)) | 			} | ||||||
|  | 
 | ||||||
|  | 			if tt.activeBal != "" { | ||||||
|  | 				err := store.WriteEntry(ctx, tt.sessionId, storedb.DATA_ACTIVE_BAL, []byte(tt.activeBal)) | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 					t.Fatal(err) | 					t.Fatal(err) | ||||||
| 				} | 				} | ||||||
|  | 			} | ||||||
| 
 | 
 | ||||||
| 			res, err := h.CheckBalance(ctx, "check_balance", []byte("")) | 			res, err := h.CheckBalance(ctx, "check_balance", []byte("")) | ||||||
| 
 | 
 | ||||||
| @ -2208,20 +2242,25 @@ func TestGetVoucherList(t *testing.T) { | |||||||
| 		ReplaceSeparatorFunc: mockReplaceSeparator, | 		ReplaceSeparatorFunc: mockReplaceSeparator, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	mockSyms := []byte("1:SRF\n2:MILO") | 	mockSymbols := []byte("1:SRF\n2:MILO") | ||||||
|  | 	mockBalances := []byte("1:10.099999\n2:40.7") | ||||||
| 
 | 
 | ||||||
| 	// Put voucher sym data from the store
 | 	// Put voucher symnols and balances data to the store
 | ||||||
| 	err := store.WriteEntry(ctx, sessionId, storedb.DATA_VOUCHER_SYMBOLS, mockSyms) | 	err := store.WriteEntry(ctx, sessionId, storedb.DATA_VOUCHER_SYMBOLS, mockSymbols) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	err = store.WriteEntry(ctx, sessionId, storedb.DATA_VOUCHER_BALANCES, mockBalances) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		t.Fatal(err) | 		t.Fatal(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	expectedSyms := []byte("1: SRF\n2: MILO") | 	expectedList := []byte("1: SRF 10.09\n2: MILO 40.70") | ||||||
| 
 | 
 | ||||||
| 	res, err := h.GetVoucherList(ctx, "", []byte("")) | 	res, err := h.GetVoucherList(ctx, "", []byte("")) | ||||||
| 
 | 
 | ||||||
| 	assert.NoError(t, err) | 	assert.NoError(t, err) | ||||||
| 	assert.Equal(t, res.Content, string(expectedSyms)) | 	assert.Equal(t, res.Content, string(expectedList)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestViewVoucher(t *testing.T) { | func TestViewVoucher(t *testing.T) { | ||||||
| @ -3170,97 +3209,6 @@ func TestResetUnregisteredNumber(t *testing.T) { | |||||||
| 	assert.Equal(t, expectedResult, res) | 	assert.Equal(t, expectedResult, res) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestConstructAccountAlias(t *testing.T) { |  | ||||||
| 	ctx, store := InitializeTestStore(t) |  | ||||||
| 	sessionId := "session123" |  | ||||||
| 	mockAccountService := new(mocks.MockAccountService) |  | ||||||
| 
 |  | ||||||
| 	ctx = context.WithValue(ctx, "SessionId", sessionId) |  | ||||||
| 
 |  | ||||||
| 	h := &MenuHandlers{ |  | ||||||
| 		userdataStore:  store, |  | ||||||
| 		accountService: mockAccountService, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	tests := []struct { |  | ||||||
| 		name          string |  | ||||||
| 		firstName     string |  | ||||||
| 		familyName    string |  | ||||||
| 		publicKey     string |  | ||||||
| 		expectedAlias string |  | ||||||
| 		aliasResponse *models.RequestAliasResult |  | ||||||
| 		aliasError    error |  | ||||||
| 		expectedError error |  | ||||||
| 	}{ |  | ||||||
| 		{ |  | ||||||
| 			name:          "Valid alias construction", |  | ||||||
| 			firstName:     "John", |  | ||||||
| 			familyName:    "Doe", |  | ||||||
| 			publicKey:     "pubkey123", |  | ||||||
| 			expectedAlias: "JohnDoeAlias", |  | ||||||
| 			aliasResponse: &models.RequestAliasResult{Alias: "JohnDoeAlias"}, |  | ||||||
| 			aliasError:    nil, |  | ||||||
| 			expectedError: nil, |  | ||||||
| 		}, |  | ||||||
| 		{ |  | ||||||
| 			name:          "Account service fails to return alias", |  | ||||||
| 			firstName:     "Jane", |  | ||||||
| 			familyName:    "Smith", |  | ||||||
| 			publicKey:     "pubkey456", |  | ||||||
| 			expectedAlias: "", |  | ||||||
| 			aliasResponse: nil, |  | ||||||
| 			aliasError:    fmt.Errorf("service unavailable"), |  | ||||||
| 			expectedError: fmt.Errorf("Failed to retrieve alias: service unavailable"), |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, tt := range tests { |  | ||||||
| 		t.Run(tt.name, func(t *testing.T) { |  | ||||||
| 			if tt.firstName != "" { |  | ||||||
| 				err := store.WriteEntry(ctx, sessionId, storedb.DATA_FIRST_NAME, []byte(tt.firstName)) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if tt.familyName != "" { |  | ||||||
| 				err := store.WriteEntry(ctx, sessionId, storedb.DATA_FAMILY_NAME, []byte(tt.familyName)) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if tt.publicKey != "" { |  | ||||||
| 				err := store.WriteEntry(ctx, sessionId, storedb.DATA_PUBLIC_KEY, []byte(tt.publicKey)) |  | ||||||
| 				require.NoError(t, err) |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			aliasInput := fmt.Sprintf("%s%s", tt.firstName, tt.familyName) |  | ||||||
| 
 |  | ||||||
| 			// Mock service behavior
 |  | ||||||
| 			mockAccountService.On( |  | ||||||
| 				"RequestAlias", |  | ||||||
| 				tt.publicKey, |  | ||||||
| 				aliasInput, |  | ||||||
| 			).Return(tt.aliasResponse, tt.aliasError) |  | ||||||
| 
 |  | ||||||
| 			// Call the function under test
 |  | ||||||
| 			err := h.constructAccountAlias(ctx) |  | ||||||
| 
 |  | ||||||
| 			// Assertions
 |  | ||||||
| 			if tt.expectedError != nil { |  | ||||||
| 				assert.EqualError(t, err, tt.expectedError.Error()) |  | ||||||
| 			} else { |  | ||||||
| 				assert.NoError(t, err) |  | ||||||
| 				if tt.expectedAlias != "" { |  | ||||||
| 					storedAlias, err := store.ReadEntry(ctx, sessionId, storedb.DATA_ACCOUNT_ALIAS) |  | ||||||
| 					require.NoError(t, err) |  | ||||||
| 					assert.Equal(t, tt.expectedAlias, string(storedAlias)) |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			// Ensure mock expectations were met
 |  | ||||||
| 			mockAccountService.AssertExpectations(t) |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func TestInsertProfileItems(t *testing.T) { | func TestInsertProfileItems(t *testing.T) { | ||||||
| 	ctx, store := InitializeTestStore(t) | 	ctx, store := InitializeTestStore(t) | ||||||
| 	sessionId := "session123" | 	sessionId := "session123" | ||||||
|  | |||||||
| @ -182,3 +182,30 @@ func UpdateVoucherData(ctx context.Context, store DataStore, sessionId string, d | |||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // FormatVoucherList combines the voucher symbols with their balances (SRF 0.11)
 | ||||||
|  | func FormatVoucherList(ctx context.Context, symbolsData, balancesData string) []string { | ||||||
|  | 	symbols := strings.Split(symbolsData, "\n") | ||||||
|  | 	balances := strings.Split(balancesData, "\n") | ||||||
|  | 
 | ||||||
|  | 	var combined []string | ||||||
|  | 	for i := 0; i < len(symbols) && i < len(balances); i++ { | ||||||
|  | 		symbolParts := strings.SplitN(symbols[i], ":", 2) | ||||||
|  | 		balanceParts := strings.SplitN(balances[i], ":", 2) | ||||||
|  | 
 | ||||||
|  | 		if len(symbolParts) == 2 && len(balanceParts) == 2 { | ||||||
|  | 			index := strings.TrimSpace(symbolParts[0]) | ||||||
|  | 			symbol := strings.TrimSpace(symbolParts[1]) | ||||||
|  | 			rawBalance := strings.TrimSpace(balanceParts[1]) | ||||||
|  | 
 | ||||||
|  | 			formattedBalance, err := TruncateDecimalString(rawBalance, 2) | ||||||
|  | 			if err != nil { | ||||||
|  | 				logg.ErrorCtxf(ctx, "failed to format balance", "balance", rawBalance, "error", err) | ||||||
|  | 				formattedBalance = rawBalance | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			combined = append(combined, fmt.Sprintf("%s: %s %s", index, symbol, formattedBalance)) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return combined | ||||||
|  | } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user