separated quotation and payments

This commit is contained in:
Stephan D
2026-02-10 18:29:47 +01:00
parent 6745bc0f6f
commit 296cc7b86a
163 changed files with 13516 additions and 191 deletions

View File

@@ -97,6 +97,12 @@ api:
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
payment_quotation:
address: dev-payments-quotation:50064
address_env: PAYMENTS_QUOTE_ADDRESS
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
app:

View File

@@ -97,6 +97,12 @@ api:
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
payment_quotation:
address: sendico_payments_quotation:50064
address_env: PAYMENTS_QUOTE_ADDRESS
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
app:

View File

@@ -8,6 +8,8 @@ replace github.com/tech/sendico/ledger => ../ledger
replace github.com/tech/sendico/payments/orchestrator => ../payments/orchestrator
replace github.com/tech/sendico/payments/storage => ../payments/storage
replace github.com/tech/sendico/gateway/tron => ../gateway/tron
require (

View File

@@ -11,6 +11,7 @@ type Config struct {
ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"`
Ledger *LedgerConfig `yaml:"ledger"`
PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"`
PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"`
}
type ChainGatewayConfig struct {

View File

@@ -144,6 +144,8 @@ func CreateAPI(logger mlogger.Logger, config *api.Config, db db.Factory, router
}
p.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
p.resolveServiceAddressesFromDiscovery()
p.logger.Info("Installing microservices...")
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))

View File

@@ -0,0 +1,466 @@
package apiimp
import (
"context"
"fmt"
"net"
"net/url"
"sort"
"strings"
"time"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"go.uber.org/zap"
)
const (
discoveryBootstrapTimeout = 3 * time.Second
discoveryBootstrapSender = "server_bootstrap"
discoveryGatewayRailCrypto = "CRYPTO"
defaultClientDialTimeoutSecs = 5
defaultClientCallTimeoutSecs = 5
paymentQuoteOperation = "payment.quote"
paymentInitiateOperation = "payment.initiate"
ledgerDebitOperation = "ledger.debit"
ledgerCreditOperation = "ledger.credit"
gatewayReadBalanceOperation = "balance.read"
)
var (
ledgerDiscoveryServiceNames = []string{
"LEDGER",
string(mservice.Ledger),
}
paymentOrchestratorDiscoveryServiceNames = []string{
"PAYMENTS_ORCHESTRATOR",
string(mservice.PaymentOrchestrator),
}
paymentQuotationDiscoveryServiceNames = []string{
"PAYMENTS_QUOTATION",
"PAYMENTS_QUOTE",
"PAYMENT_QUOTATION",
"payment_quotation",
}
)
type discoveryEndpoint struct {
address string
insecure bool
raw string
}
type serviceSelection struct {
service discovery.ServiceSummary
endpoint discoveryEndpoint
opMatch bool
nameRank int
}
type gatewaySelection struct {
gateway discovery.GatewaySummary
endpoint discoveryEndpoint
networkMatch bool
opMatch bool
}
// resolveServiceAddressesFromDiscovery looks up downstream service addresses once
// during startup and applies them to the runtime config.
func (a *APIImp) resolveServiceAddressesFromDiscovery() {
if a == nil || a.config == nil || a.config.Mw == nil {
return
}
msgCfg := a.config.Mw.Messaging
if msgCfg.Driver == "" {
return
}
logger := a.logger.Named("discovery_bootstrap")
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), &msgCfg)
if err != nil {
logger.Warn("Failed to create discovery bootstrap broker", zap.Error(err))
return
}
client, err := discovery.NewClient(logger, broker, nil, discoveryBootstrapSender)
if err != nil {
logger.Warn("Failed to create discovery bootstrap client", zap.Error(err))
return
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), discoveryBootstrapTimeout)
defer cancel()
lookup, err := client.Lookup(ctx)
if err != nil {
logger.Warn("Failed to fetch discovery registry during startup", zap.Error(err))
return
}
a.resolveChainGatewayAddress(lookup.Gateways)
orchestratorFound, orchestratorEndpoint := a.resolvePaymentOrchestratorAddress(lookup.Services)
a.resolveLedgerAddress(lookup.Services)
a.resolvePaymentQuotationAddress(lookup.Services, orchestratorFound, orchestratorEndpoint)
}
func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary) {
cfg := a.config.ChainGateway
if cfg == nil {
return
}
endpoint, selected, ok := selectGatewayEndpoint(
gateways,
cfg.DefaultAsset.Chain,
[]string{gatewayReadBalanceOperation},
)
if !ok {
return
}
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsChainGateway(cfg)
a.logger.Info("Resolved chain gateway address from discovery",
zap.String("rail", selected.Rail),
zap.String("gateway_id", selected.ID),
zap.String("network", selected.Network),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) {
endpoint, selected, ok := selectServiceEndpoint(
services,
ledgerDiscoveryServiceNames,
[]string{ledgerDebitOperation, ledgerCreditOperation},
)
if !ok {
return
}
cfg := ensureLedgerConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsLedger(cfg)
a.logger.Info("Resolved ledger address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceSummary) (bool, discoveryEndpoint) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentOrchestratorDiscoveryServiceNames,
[]string{paymentInitiateOperation},
)
if !ok {
return false, discoveryEndpoint{}
}
cfg := ensurePaymentOrchestratorConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment orchestrator address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
return true, endpoint
}
func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSummary, orchestratorFound bool, orchestratorEndpoint discoveryEndpoint) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentQuotationDiscoveryServiceNames,
[]string{paymentQuoteOperation},
)
if !ok {
cfg := a.config.PaymentQuotation
if cfg != nil && strings.TrimSpace(cfg.Address) != "" {
return
}
if !orchestratorFound {
return
}
// Fall back to orchestrator endpoint when quotation service is not announced.
endpoint = orchestratorEndpoint
selected = discovery.ServiceSummary{Service: "PAYMENTS_ORCHESTRATOR"}
}
cfg := ensurePaymentQuotationConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment quotation address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []string, requiredOps []string) (discoveryEndpoint, discovery.ServiceSummary, bool) {
selections := make([]serviceSelection, 0)
for _, svc := range services {
if !svc.Healthy {
continue
}
if strings.TrimSpace(svc.InvokeURI) == "" {
continue
}
nameRank, ok := serviceRank(svc.Service, serviceNames)
if !ok {
continue
}
endpoint, err := parseDiscoveryInvokeURI(svc.InvokeURI)
if err != nil {
continue
}
selections = append(selections, serviceSelection{
service: svc,
endpoint: endpoint,
opMatch: hasAnyOperation(svc.Ops, requiredOps),
nameRank: nameRank,
})
}
if len(selections) == 0 {
return discoveryEndpoint{}, discovery.ServiceSummary{}, false
}
sort.Slice(selections, func(i, j int) bool {
if selections[i].opMatch != selections[j].opMatch {
return selections[i].opMatch
}
if selections[i].nameRank != selections[j].nameRank {
return selections[i].nameRank < selections[j].nameRank
}
if selections[i].service.ID != selections[j].service.ID {
return selections[i].service.ID < selections[j].service.ID
}
return selections[i].service.InstanceID < selections[j].service.InstanceID
})
selected := selections[0]
return selected.endpoint, selected.service, true
}
func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork string, requiredOps []string) (discoveryEndpoint, discovery.GatewaySummary, bool) {
preferredNetwork = strings.TrimSpace(preferredNetwork)
selections := make([]gatewaySelection, 0)
for _, gateway := range gateways {
if !gateway.Healthy {
continue
}
if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discoveryGatewayRailCrypto) {
continue
}
if strings.TrimSpace(gateway.InvokeURI) == "" {
continue
}
endpoint, err := parseDiscoveryInvokeURI(gateway.InvokeURI)
if err != nil {
continue
}
selections = append(selections, gatewaySelection{
gateway: gateway,
endpoint: endpoint,
networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork),
opMatch: hasAnyOperation(gateway.Ops, requiredOps),
})
}
if len(selections) == 0 {
return discoveryEndpoint{}, discovery.GatewaySummary{}, false
}
sort.Slice(selections, func(i, j int) bool {
if selections[i].networkMatch != selections[j].networkMatch {
return selections[i].networkMatch
}
if selections[i].opMatch != selections[j].opMatch {
return selections[i].opMatch
}
if selections[i].gateway.RoutingPriority != selections[j].gateway.RoutingPriority {
return selections[i].gateway.RoutingPriority > selections[j].gateway.RoutingPriority
}
if selections[i].gateway.ID != selections[j].gateway.ID {
return selections[i].gateway.ID < selections[j].gateway.ID
}
return selections[i].gateway.InstanceID < selections[j].gateway.InstanceID
})
selected := selections[0]
return selected.endpoint, selected.gateway, true
}
func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return discoveryEndpoint{}, fmt.Errorf("invoke uri is empty")
}
// Without a scheme we expect a plain host:port target.
if !strings.Contains(raw, "://") {
if _, _, err := net.SplitHostPort(raw); err != nil {
return discoveryEndpoint{}, fmt.Errorf("invoke uri must include host:port: %w", err)
}
return discoveryEndpoint{
address: raw,
insecure: true,
raw: raw,
}, nil
}
parsed, err := url.Parse(raw)
if err != nil {
return discoveryEndpoint{}, err
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "grpc":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, fmt.Errorf("grpc invoke uri must include host:port: %w", splitErr)
}
return discoveryEndpoint{
address: address,
insecure: true,
raw: raw,
}, nil
case "grpcs":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, fmt.Errorf("grpcs invoke uri must include host:port: %w", splitErr)
}
return discoveryEndpoint{
address: address,
insecure: false,
raw: raw,
}, nil
case "dns", "passthrough":
// gRPC resolver targets such as dns:///service:port.
return discoveryEndpoint{
address: raw,
insecure: true,
raw: raw,
}, nil
default:
return discoveryEndpoint{}, fmt.Errorf("unsupported invoke uri scheme: %s", parsed.Scheme)
}
}
func serviceRank(service string, names []string) (int, bool) {
service = strings.TrimSpace(service)
if service == "" {
return 0, false
}
for i, name := range names {
if strings.EqualFold(service, strings.TrimSpace(name)) {
return i, true
}
}
return 0, false
}
func hasAnyOperation(ops []string, required []string) bool {
if len(required) == 0 {
return true
}
for _, op := range ops {
normalized := strings.TrimSpace(op)
if normalized == "" {
continue
}
for _, target := range required {
if strings.EqualFold(normalized, strings.TrimSpace(target)) {
return true
}
}
}
return false
}
func ensureLedgerConfig(cfg *eapi.Config) *eapi.LedgerConfig {
if cfg == nil {
return nil
}
if cfg.Ledger == nil {
cfg.Ledger = &eapi.LedgerConfig{}
}
return cfg.Ledger
}
func ensurePaymentOrchestratorConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentOrchestrator == nil {
cfg.PaymentOrchestrator = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentOrchestrator
}
func ensurePaymentQuotationConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentQuotation == nil {
cfg.PaymentQuotation = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentQuotation
}
func ensureTimeoutsLedger(cfg *eapi.LedgerConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}
func ensureTimeoutsChainGateway(cfg *eapi.ChainGatewayConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}
func ensureTimeoutsPayment(cfg *eapi.PaymentOrchestratorConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}

View File

@@ -0,0 +1,140 @@
package apiimp
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
)
func TestParseDiscoveryInvokeURI(t *testing.T) {
testCases := []struct {
name string
raw string
address string
insecure bool
wantErr bool
}{
{
name: "host_port",
raw: "ledger:50052",
address: "ledger:50052",
insecure: true,
},
{
name: "grpc_scheme",
raw: "grpc://payments-orchestrator:50062",
address: "payments-orchestrator:50062",
insecure: true,
},
{
name: "grpcs_scheme",
raw: "grpcs://payments-orchestrator:50062",
address: "payments-orchestrator:50062",
insecure: false,
},
{
name: "dns_scheme",
raw: "dns:///ledger:50052",
address: "dns:///ledger:50052",
insecure: true,
},
{
name: "invalid",
raw: "ledger",
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
endpoint, err := parseDiscoveryInvokeURI(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q", tc.raw)
}
return
}
if err != nil {
t.Fatalf("parseDiscoveryInvokeURI(%q) failed: %v", tc.raw, err)
}
if endpoint.address != tc.address {
t.Fatalf("expected address %q, got %q", tc.address, endpoint.address)
}
if endpoint.insecure != tc.insecure {
t.Fatalf("expected insecure %t, got %t", tc.insecure, endpoint.insecure)
}
})
}
}
func TestSelectServiceEndpointPrefersRequiredOperation(t *testing.T) {
services := []discovery.ServiceSummary{
{
ID: "candidate-without-op",
Service: "LEDGER",
Healthy: true,
InvokeURI: "ledger-2:50052",
Ops: []string{"balance.read"},
},
{
ID: "candidate-with-op",
Service: "LEDGER",
Healthy: true,
InvokeURI: "ledger-1:50052",
Ops: []string{"ledger.debit"},
},
}
endpoint, selected, ok := selectServiceEndpoint(services, []string{"LEDGER"}, []string{"ledger.debit"})
if !ok {
t.Fatal("expected service endpoint to be selected")
}
if selected.ID != "candidate-with-op" {
t.Fatalf("expected candidate-with-op, got %s", selected.ID)
}
if endpoint.address != "ledger-1:50052" {
t.Fatalf("expected address ledger-1:50052, got %s", endpoint.address)
}
}
func TestSelectGatewayEndpointPrefersNetworkAndOperation(t *testing.T) {
gateways := []discovery.GatewaySummary{
{
ID: "high-priority-no-op",
Rail: "CRYPTO",
Network: "TRON_NILE",
Healthy: true,
InvokeURI: "gw-high:50053",
RoutingPriority: 10,
},
{
ID: "low-priority-with-op",
Rail: "CRYPTO",
Network: "TRON_NILE",
Healthy: true,
InvokeURI: "gw-low:50053",
Ops: []string{"balance.read"},
RoutingPriority: 1,
},
{
ID: "different-network",
Rail: "CRYPTO",
Network: "ARBITRUM_ONE",
Healthy: true,
InvokeURI: "gw-other:50053",
Ops: []string{"balance.read"},
RoutingPriority: 100,
},
}
endpoint, selected, ok := selectGatewayEndpoint(gateways, "TRON_NILE", []string{"balance.read"})
if !ok {
t.Fatal("expected gateway endpoint to be selected")
}
if selected.ID != "low-priority-with-op" {
t.Fatalf("expected low-priority-with-op, got %s", selected.ID)
}
if endpoint.address != "gw-low:50053" {
t.Fatalf("expected address gw-low:50053, got %s", endpoint.address)
}
}

View File

@@ -77,7 +77,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
}
p.permissionRef = desc.ID
if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator); err != nil {
if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator, apiCtx.Config().PaymentQuotation); err != nil {
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
return nil, err
}
@@ -98,7 +98,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
return p, nil
}
func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig) error {
func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig, quoteCfg *eapi.PaymentOrchestratorConfig) error {
if cfg == nil {
return merrors.InvalidArgument("payment orchestrator configuration is not provided")
}
@@ -111,11 +111,23 @@ func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig) erro
return merrors.InvalidArgument(fmt.Sprintf("payment orchestrator address is not specified and address env %s is empty", cfg.AddressEnv))
}
quoteAddress := address
if quoteCfg != nil {
if addr := strings.TrimSpace(quoteCfg.Address); addr != "" {
quoteAddress = addr
} else if env := strings.TrimSpace(quoteCfg.AddressEnv); env != "" {
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
quoteAddress = resolved
}
}
}
clientCfg := orchestratorclient.Config{
Address: address,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
Address: address,
QuoteAddress: quoteAddress,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
}
client, err := orchestratorclient.New(context.Background(), clientCfg)

View File

@@ -30,7 +30,7 @@ func (a *VerificationAPI) requestCode(r *http.Request, account *model.Account, t
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
target := a.resolveTarget(req.Destination, account)
target := a.resolveTarget(req.Target, account)
if target == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
@@ -51,7 +51,7 @@ func (a *VerificationAPI) requestCode(r *http.Request, account *model.Account, t
return response.Accepted(a.logger, verificationResponse{
TTLSeconds: int(vReq.Ttl.Seconds()),
CooldownSeconds: int(a.config.Cooldown.Seconds()),
Destination: mask.Email(target),
Target: mask.Email(target),
IdempotencyKey: req.IdempotencyKey,
})
}

View File

@@ -21,11 +21,7 @@ func (s *ConfirmationStore) Create(
ctx context.Context,
request *verification.Request,
) (verificationCode string, err error) {
code, err := s.db.Create(ctx, request)
if err != nil {
return "", err
}
return code, nil
return s.db.Create(ctx, request)
}
func (s *ConfirmationStore) Verify(

View File

@@ -6,7 +6,7 @@ import (
type verificationCodeRequest struct {
Purpose string `json:"purpose"`
Destination string `json:"destination,omitempty"`
Target string `json:"target,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
@@ -20,5 +20,5 @@ type verificationResponse struct {
IdempotencyKey string `json:"idempotencyKey"`
TTLSeconds int `json:"ttl_seconds"`
CooldownSeconds int `json:"cooldown_seconds"`
Destination string `json:"destination"`
Target string `json:"target"`
}

View File

@@ -27,17 +27,20 @@ func (a *VerificationAPI) verifyCode(r *http.Request, account *model.Account, to
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if strings.TrimSpace(req.Code) == "" {
code := strings.TrimSpace(req.Code)
if code == "" {
return response.BadRequest(a.logger, a.Name(), "missing_code", "confirmation code is required")
}
target := a.resolveTarget(req.Destination, account)
target := a.resolveTarget(req.Target, account)
if target == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
dst, err := a.store.Verify(r.Context(), account.ID, purpose, req.Code)
dst, err := a.store.Verify(r.Context(), account.ID, purpose, code)
if err != nil {
a.logger.Debug("Code verification failed", zap.Error(err))
a.logger.Debug("Code verification failed", zap.Error(err),
mzap.AccRef(account.ID), zap.String("purpose", req.Purpose),
)
return mutil.MapTokenErrorToResponse(a.logger, a.Name(), err)
}
if dst != target {