commit ad94bc50356dc7639a13bf55f6522cf83d240729
parent 76246c338abf6470313267b0d1c2df6798ba4d6d
Author: Joel-Haeberli <haebu@rubigen.ch>
Date: Fri, 26 Apr 2024 16:13:47 +0200
fix: implement terminal api, enable basic auth, fix simulation
Diffstat:
46 files changed, 2593 insertions(+), 2384 deletions(-)
diff --git a/c2ec/api-auth.go b/c2ec/api-auth.go
@@ -0,0 +1,219 @@
+package main
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "golang.org/x/crypto/argon2"
+)
+
+const AUTHORIZATION_HEADER = "Authorization"
+const BASIC_AUTH_PREFIX = "Basic "
+
+// Authenticates the Exchange against C2EC
+// returns true if authentication was successful, otherwise false
+// when not successful, the api shall return immediately
+// The exchange is specified to use basic auth
+func AuthenticateExchange(req *http.Request) bool {
+
+ auth := req.Header.Get(AUTHORIZATION_HEADER)
+ if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found {
+
+ ba := fmt.Sprintf("%s:%s", CONFIG.Server.WireGateway.Username, CONFIG.Server.WireGateway.Password)
+ encoded := base64.StdEncoding.EncodeToString([]byte(ba))
+ return encoded == basicAuth
+ }
+ return false
+}
+
+// Authenticates a terminal against C2EC
+// returns true if authentication was successful, otherwise false
+// when not successful, the api shall return immediately
+//
+// Terminals are authenticated using basic auth.
+// The basic authorization header MUST be base64 encoded.
+// The username part is the name of the provider (case sensitive) a '-' sign, followed
+// by the id of the terminal, which is a number.
+func AuthenticateTerminal(req *http.Request) bool {
+
+ auth := req.Header.Get(AUTHORIZATION_HEADER)
+ if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found {
+
+ decoded, err := base64.StdEncoding.DecodeString(basicAuth)
+ if err != nil {
+ LogWarn("auth", "failed decoding basic auth header from base64")
+ return false
+ }
+
+ username, password, err := parseBasicAuth(string(decoded))
+ if err != nil {
+ LogWarn("auth", "failed parsing username password from basic auth")
+ return false
+ }
+
+ provider, terminalId, err := parseTerminalUser(username)
+ if err != nil {
+ LogWarn("auth", "failed parsing terminal from username in basic auth")
+ return false
+ }
+ LogInfo("auth", fmt.Sprintf("req=%s by terminal with id=%d, provider=%s", req.RequestURI, terminalId, provider))
+
+ terminal, err := DB.GetTerminalById(terminalId)
+ if err != nil {
+ return false
+ }
+
+ if !terminal.Active {
+ LogWarn("auth", fmt.Sprintf("request from inactive terminal. id=%d", terminalId))
+ return false
+ }
+
+ prvdr, err := DB.GetTerminalProviderByName(provider)
+ if err != nil {
+ LogWarn("auth", fmt.Sprintf("failed requesting provider by name %s", err.Error()))
+ return false
+ }
+
+ if terminal.ProviderId != prvdr.ProviderId {
+ LogWarn("auth", "terminal's provider id did not match provider id of supplied provider")
+ return false
+ }
+
+ return ValidPassword(password, terminal.AccessToken)
+ }
+
+ return false
+}
+
+func parseBasicAuth(basicAuth string) (string, string, error) {
+
+ parts := strings.Split(basicAuth, ":")
+ if len(parts) != 2 {
+ return "", "", errors.New("malformed basic auth")
+ }
+ return parts[0], parts[1], nil
+}
+
+// parses the username of the basic auth param of the terminal.
+// the username has following format:
+//
+// [PROVIDER_NAME]-[TERMINAL_ID]
+func parseTerminalUser(username string) (string, int, error) {
+
+ parts := strings.Split(username, "-")
+ if len(parts) != 2 {
+ return "", -1, errors.New("malformed basic auth username")
+ }
+
+ providerName := parts[0]
+ terminalId, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return "", -1, errors.New("malformed basic auth username")
+ }
+
+ return providerName, terminalId, nil
+}
+
+// Parses the terminal id from the token.
+// This function is used to determine the terminal
+// which orchestrates the withdrawal.
+func parseTerminalId(req *http.Request) int {
+ auth := req.Header.Get(AUTHORIZATION_HEADER)
+ if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found {
+
+ decoded, err := base64.StdEncoding.DecodeString(basicAuth)
+ if err != nil {
+ return -1
+ }
+
+ username, _, err := parseBasicAuth(string(decoded))
+ if err != nil {
+ return -1
+ }
+
+ _, terminalId, err := parseTerminalUser(username)
+ if err != nil {
+ return -1
+ }
+
+ return terminalId
+ }
+
+ return -1
+}
+
+func parseProvider(req *http.Request) (*Provider, error) {
+
+ auth := req.Header.Get(AUTHORIZATION_HEADER)
+ if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found {
+
+ decoded, err := base64.StdEncoding.DecodeString(basicAuth)
+ if err != nil {
+ return nil, err
+ }
+
+ username, _, err := parseBasicAuth(string(decoded))
+ if err != nil {
+ return nil, err
+ }
+
+ providerName, _, err := parseTerminalUser(username)
+ if err != nil {
+ return nil, err
+ }
+
+ p, err := DB.GetTerminalProviderByName(providerName)
+ if err != nil {
+ return nil, err
+ }
+
+ return p, nil
+ }
+
+ return nil, errors.New("authorization header did not match expectations")
+}
+
+// takes a password and a base64 encoded password hash, including salt and checks
+// the password supplied against it.
+// the format of the password hash is expected to be the following:
+//
+// [32 BYTES HASH][16 BYTES SALT] = Bytes array with length of 48 bytes.
+//
+// returns true if password matches the password hash. Otherwise false.
+func ValidPassword(pw string, base64EncodedHashAndSalt string) bool {
+
+ hashedBytes := make([]byte, 48)
+ decodedLen, err := base64.StdEncoding.Decode(hashedBytes, []byte(base64EncodedHashAndSalt))
+ if err != nil {
+ return false
+ }
+
+ if decodedLen != 48 {
+ // malformed credentials
+ return false
+ }
+
+ salt := hashedBytes[32:48]
+ rfcTime := 3
+ rfcMemory := 32 * 1024
+ key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32)
+
+ if len(key) != 32 {
+ // length mismatch
+ return false
+ }
+
+ for i := range key {
+ if key[i] != hashedBytes[i] {
+ // wrong password (application user key)
+ return false
+ }
+ }
+
+ // password correct.
+ return true
+}
diff --git a/c2ec/auth_test.go b/c2ec/api-auth_test.go
diff --git a/c2ec/api-bank-integration.go b/c2ec/api-bank-integration.go
@@ -0,0 +1,252 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "fmt"
+ http "net/http"
+ "strconv"
+ "time"
+)
+
+const WITHDRAWAL_OPERATION = "/withdrawal-operation"
+
+const WOPID_PARAMETER = "wopid"
+const BANK_INTEGRATION_CONFIG_PATTERN = "/config"
+const WITHDRAWAL_OPERATION_PATTERN = WITHDRAWAL_OPERATION
+const WITHDRAWAL_OPERATION_BY_WOPID_PATTERN = WITHDRAWAL_OPERATION + "/{" + WOPID_PARAMETER + "}"
+const WITHDRAWAL_OPERATION_ABORTION_PATTERN = WITHDRAWAL_OPERATION_BY_WOPID_PATTERN + "/abort"
+
+const DEFAULT_LONG_POLL_MS = 1000
+const DEFAULT_OLD_STATE = PENDING
+
+// https://docs.taler.net/core/api-exchange.html#tsref-type-CurrencySpecification
+type CurrencySpecification struct {
+ Name string `json:"name"`
+ Currency string `json:"currency"`
+ NumFractionalInputDigits int `json:"num_fractional_input_digits"`
+ NumFractionalNormalDigits int `json:"num_fractional_normal_digits"`
+ NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"`
+ AltUnitNames string `json:"alt_unit_names"`
+}
+
+// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig
+type BankIntegrationConfig struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Implementation string `json:"implementation"`
+ Currency string `json:"currency"`
+ CurrencySpecification CurrencySpecification `json:"currency_specification"`
+ // TODO: maybe add exchanges payto uri for transfers etc.?
+}
+
+type BankWithdrawalOperationPostRequest struct {
+ ReservePubKey EddsaPublicKey `json:"reserve_pub"`
+ SelectedExchange string `json:"selected_exchange"`
+ Amount *Amount `json:"amount"`
+}
+
+type BankWithdrawalOperationPostResponse struct {
+ Status WithdrawalOperationStatus `json:"status"`
+ ConfirmTransferUrl string `json:"confirm_transfer_url"`
+ TransferDone bool `json:"transfer_done"`
+}
+
+type BankWithdrawalOperationStatus struct {
+ Status WithdrawalOperationStatus `json:"status"`
+ Amount Amount `json:"amount"`
+ SenderWire string `json:"sender_wire"`
+ WireTypes []string `json:"wire_types"`
+ ReservePubKey EddsaPublicKey `json:"selected_reserve_pub"`
+}
+
+func bankIntegrationConfig(res http.ResponseWriter, req *http.Request) {
+
+ cfg := BankIntegrationConfig{
+ Name: "taler-bank-integration",
+ Version: "0:0:1",
+ }
+
+ serializedCfg, err := NewJsonCodec[BankIntegrationConfig]().EncodeToBytes(&cfg)
+ if err != nil {
+ LogInfo("bank-integration-api", fmt.Sprintf("failed serializing config: %s", err.Error()))
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ res.WriteHeader(HTTP_OK)
+ res.Write(serializedCfg)
+}
+
+func handleParameterRegistration(res http.ResponseWriter, req *http.Request) {
+
+ jsonCodec := NewJsonCodec[BankWithdrawalOperationPostRequest]()
+ registration, err := ReadStructFromBody(req, jsonCodec)
+ if err != nil {
+ LogWarn("bank-integration-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error()))
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ // read and validate the wopid path parameter
+ wopid := req.PathValue(WOPID_PARAMETER)
+ wpd, err := ParseWopid(wopid)
+ if err != nil {
+ LogWarn("bank-integration-api", "wopid "+wopid+" not valid")
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ if _, err = DB.GetWithdrawalByWopid(wpd); err != nil {
+ LogError("bank-integration-api", err)
+ res.WriteHeader(HTTP_NOT_FOUND)
+ return
+ }
+
+ if err = DB.RegisterWithdrawalParameters(
+ wpd,
+ registration.ReservePubKey,
+ ); err != nil {
+ LogError("bank-integration-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ withdrawal, err := DB.GetWithdrawalByWopid(wpd)
+ if err != nil {
+ LogError("bank-integration-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+
+ resbody := &BankWithdrawalOperationPostResponse{
+ Status: withdrawal.WithdrawalStatus,
+ ConfirmTransferUrl: "", // not used in our case
+ TransferDone: withdrawal.WithdrawalStatus == CONFIRMED,
+ }
+
+ resbyts, err := NewJsonCodec[BankWithdrawalOperationPostResponse]().EncodeToBytes(resbody)
+ if err != nil {
+ LogError("bank-integration-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+
+ res.Write(resbyts)
+}
+
+// Get status of withdrawal associated with the given WOPID
+//
+// Parameters:
+// - long_poll_ms (optional):
+// milliseconds to wait for state to change
+// given old_state until responding
+// - old_state (optional):
+// Default is 'pending'
+func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
+
+ // read and validate request query parameters
+ shouldStartLongPoll := true
+ longPollMilli := DEFAULT_LONG_POLL_MS
+ if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse(
+ "long_poll_ms", strconv.Atoi, req, res,
+ ); accepted {
+ if longPollMilliPtr != nil {
+ longPollMilli = *longPollMilliPtr
+ } else {
+ // this means parameter was not given.
+ // no long polling (simple get)
+ shouldStartLongPoll = false
+ }
+ } else {
+ shouldStartLongPoll = false
+ }
+
+ // read and validate the wopid path parameter
+ wopid := req.PathValue(WOPID_PARAMETER)
+ wpd, err := ParseWopid(wopid)
+ if err != nil {
+ LogWarn("bank-integration-api", "wopid "+wopid+" not valid")
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ if shouldStartLongPoll {
+
+ timeoutCtx, cancelFunc := context.WithTimeout(
+ req.Context(),
+ time.Duration(longPollMilli)*time.Millisecond,
+ )
+ defer cancelFunc()
+
+ notifications := make(chan *Notification)
+ channel := "w_" + base64.StdEncoding.EncodeToString(wpd)
+
+ listenFunc, err := DB.NewListener(
+ channel,
+ notifications,
+ )
+
+ if err != nil {
+ res.WriteHeader(HTTP_NO_CONTENT)
+ return
+ }
+
+ go listenFunc(timeoutCtx)
+
+ for {
+ select {
+ case <-timeoutCtx.Done():
+ LogInfo("bank-integration-api", "long poll time exceeded")
+ res.WriteHeader(HTTP_NO_CONTENT)
+ return
+ case <-notifications:
+ writeWithdrawalOrError(wpd, res)
+ return
+ }
+ }
+ }
+
+ writeWithdrawalOrError(wpd, res)
+}
+
+func handleWithdrawalAbort(res http.ResponseWriter, req *http.Request) {
+ res.WriteHeader(HTTP_OK)
+ res.Write(bytes.NewBufferString("retrieved withdrawal operation abortion request").Bytes())
+}
+
+// Tries to load a WithdrawalOperationStatus from the database. If no
+// entry could been found, it will write the correct error to the response.
+func writeWithdrawalOrError(wopid []byte, res http.ResponseWriter) {
+ // read the withdrawal from the database
+ withdrawal, err := DB.GetWithdrawalByWopid(wopid)
+ if err != nil {
+ LogError("bank-integration-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ if withdrawal == nil {
+ // not found -> 404
+ res.WriteHeader(HTTP_NOT_FOUND)
+ return
+ }
+
+ // return the C2ECWithdrawalStatus
+ if amount, err := ToAmount(withdrawal.Amount); err != nil {
+ LogError("bank-integration-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ } else {
+ withdrawalStatusBytes, err := NewJsonCodec[BankWithdrawalOperationStatus]().EncodeToBytes(&BankWithdrawalOperationStatus{
+ Status: withdrawal.WithdrawalStatus,
+ Amount: *amount,
+ })
+ if err != nil {
+ LogError("bank-integration-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+ res.WriteHeader(HTTP_OK)
+ res.Write(withdrawalStatusBytes)
+ }
+}
diff --git a/c2ec/api-terminals.go b/c2ec/api-terminals.go
@@ -0,0 +1,243 @@
+package main
+
+import (
+ "crypto/rand"
+ "fmt"
+ "net/http"
+)
+
+const TERMINAL_API_CONFIG = "/config"
+const TERMINAL_API_REGISTER_WITHDRAWAL = "/withdrawals"
+const TERMINAL_API_WITHDRAWAL_STATUS = "/withdrawals/{wopid}"
+const TERMINAL_API_CHECK_WITHDRAWAL = "/withdrawals/{wopid}/check"
+
+type TerminalConfig struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ ProviderName string `json:"provider_name"`
+ WireType string `json:"wire_type"`
+}
+
+type TerminalWithdrawalSetup struct {
+ Amount *Amount `json:"amount"`
+ SuggestedAmount *Amount `json:"suggested_amount"`
+ ProviderTransactionId string `json:"provider_transaction_id"`
+ TerminalFees *Amount `json:"terminal_fees"`
+ RequestUid string `json:"request_uid"`
+ UserUuid string `json:"user_uuid"`
+ Lock string `json:"lock"`
+}
+
+type TerminalWithdrawalSetupResponse struct {
+ Wopid string `json:"withdrawal_id"`
+}
+
+type TerminalWithdrawalConfirmationRequest struct {
+ ProviderTransactionId string `json:"provider_transaction_id"`
+ TerminalFees *Amount `json:"terminal_fees"`
+ UserUuid string `json:"user_uuid"`
+ Lock string `json:"lock"`
+}
+
+func handleTerminalConfig(res http.ResponseWriter, req *http.Request) {
+
+ p, auth, err := authAndParseProvider(req)
+ if !auth {
+ res.WriteHeader(HTTP_UNAUTHORIZED)
+ return
+ }
+
+ if err != nil || p == nil {
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ cfg, err := NewJsonCodec[TerminalConfig]().EncodeToBytes(&TerminalConfig{
+ Name: "taler-terminal",
+ Version: "0:0:0",
+ ProviderName: p.Name,
+ WireType: p.PaytoTargetType,
+ })
+ if err != nil {
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+ res.WriteHeader(HTTP_OK)
+ res.Write(cfg)
+}
+
+func handleWithdrawalSetup(res http.ResponseWriter, req *http.Request) {
+
+ p, auth, err := authAndParseProvider(req)
+ if !auth {
+ res.WriteHeader(HTTP_UNAUTHORIZED)
+ return
+ }
+ if err != nil || p == nil {
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ jsonCodec := NewJsonCodec[TerminalWithdrawalSetup]()
+ setup, err := ReadStructFromBody(req, jsonCodec)
+ if err != nil {
+ LogWarn("terminals-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error()))
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ if hasConflict(setup) {
+ res.WriteHeader(HTTP_CONFLICT)
+ return
+ }
+
+ // generate wopid
+ generatedWopid := make([]byte, 32)
+ _, err = rand.Read(generatedWopid)
+ if err != nil {
+ LogWarn("terminals-api", "unable to generate correct wopid")
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ }
+
+ err = DB.SetupWithdrawal(
+ generatedWopid,
+ preventNilAmount(setup.SuggestedAmount),
+ preventNilAmount(setup.Amount),
+ setup.ProviderTransactionId,
+ preventNilAmount(setup.TerminalFees),
+ setup.RequestUid,
+ )
+
+ if err != nil {
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ encodedBody, err := NewJsonCodec[TerminalWithdrawalSetupResponse]().EncodeToBytes(
+ &TerminalWithdrawalSetupResponse{
+ Wopid: talerBinaryEncode(generatedWopid),
+ },
+ )
+ if err != nil {
+ LogError("terminal-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ res.Write(encodedBody)
+}
+
+func handleWithdrawalCheck(res http.ResponseWriter, req *http.Request) {
+
+ p, auth, err := authAndParseProvider(req)
+ if !auth {
+ res.WriteHeader(HTTP_UNAUTHORIZED)
+ return
+ }
+
+ if err != nil || p == nil {
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ wopid := req.PathValue(WOPID_PARAMETER)
+ wpd, err := ParseWopid(wopid)
+ if err != nil {
+ LogWarn("bank-integration-api", "wopid "+wopid+" not valid")
+ if wopid == "" {
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+ }
+
+ jsonCodec := NewJsonCodec[TerminalWithdrawalConfirmationRequest]()
+ paymentNotification, err := ReadStructFromBody(req, jsonCodec)
+ if err != nil {
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ LogInfo("bank-integration-api", "received payment notification")
+
+ terminalId := parseTerminalId(req)
+ if terminalId == -1 {
+ LogWarn("terminals-api", "terminal id could not be read from authorization header")
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ if paymentNotification.TerminalFees == nil {
+ paymentNotification.TerminalFees = &Amount{"", 0, 0}
+ }
+
+ err = DB.NotifyPayment(
+ wpd,
+ paymentNotification.ProviderTransactionId,
+ terminalId,
+ *paymentNotification.TerminalFees,
+ )
+ if err != nil {
+ LogError("terminals-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ res.WriteHeader(HTTP_NO_CONTENT)
+}
+
+func preventNilAmount(a *Amount) Amount {
+
+ if a == nil {
+ return Amount{"", 0, 0}
+ }
+
+ return *a
+}
+
+func hasConflict(t *TerminalWithdrawalSetup) bool {
+
+ w, err := DB.GetWithdrawalByRequestUid(t.RequestUid)
+ if err != nil {
+ LogError("terminals-api", err)
+ return true
+ }
+
+ if w == nil {
+ return false // no request with this uid
+ }
+
+ isEqual := w.Amount.Curr == t.Amount.Currency &&
+ w.Amount.Val == int64(t.Amount.Value) &&
+ w.Amount.Frac == int32(t.Amount.Fraction) &&
+ w.TerminalFees.Curr == t.TerminalFees.Currency &&
+ uint64(w.TerminalFees.Val) == t.TerminalFees.Value &&
+ uint64(w.TerminalFees.Frac) == t.TerminalFees.Fraction &&
+ w.SuggestedAmount.Curr == t.SuggestedAmount.Currency &&
+ uint64(w.SuggestedAmount.Val) == t.SuggestedAmount.Value &&
+ uint64(w.SuggestedAmount.Frac) == t.SuggestedAmount.Fraction &&
+ w.ProviderTransactionId == &t.ProviderTransactionId &&
+ w.RequestUid == t.RequestUid
+
+ return !isEqual
+}
+
+func authAndParseProvider(req *http.Request) (*Provider, bool, error) {
+
+ if authenticated := AuthenticateTerminal(req); !authenticated {
+ return nil, false, nil
+ }
+
+ p, err := parseProvider(req)
+ if err != nil {
+ return nil, true, err
+ }
+
+ return p, true, nil
+}
diff --git a/c2ec/api-wire-gateway.go b/c2ec/api-wire-gateway.go
@@ -0,0 +1,427 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log"
+ http "net/http"
+ "strconv"
+ "time"
+)
+
+const WIRE_GATEWAY_CONFIG_ENDPOINT = "/config"
+const WIRE_GATEWAY_HISTORY_ENDPOINT = "/history"
+
+const WIRE_GATEWAY_CONFIG_PATTERN = WIRE_GATEWAY_CONFIG_ENDPOINT
+const WIRE_TRANSFER_PATTERN = "/transfer"
+const WIRE_HISTORY_INCOMING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/incoming"
+const WIRE_HISTORY_OUTGOING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/outgoing"
+const WIRE_ADMIN_ADD_INCOMING_PATTERN = "/admin/add-incoming"
+
+const INCOMING_RESERVE_TRANSACTION_TYPE = "RESERVE"
+
+// https://docs.taler.net/core/api-bank-wire.html#tsref-type-WireConfig
+type WireConfig struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Currency string `json:"currency"`
+ Implementation string `json:"implementation"`
+}
+
+// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferRequest
+type TransferRequest struct {
+ RequestUid HashCode `json:"request_uid"`
+ Amount Amount `json:"amount"`
+ ExchangeBaseUrl string `json:"exchange_base_url"`
+ Wtid ShortHashCode `json:"wtid"`
+ CreditAccount string `json:"credit_account"`
+}
+
+// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse
+type TransferResponse struct {
+ Timestamp Timestamp `json:"timestamp"`
+ RowId int `json:"row_id"`
+}
+
+// https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory
+type IncomingHistory struct {
+ IncomingTransactions []IncomingReserveTransaction `json:"incoming_transactions"`
+ CreditAccount string `json:"credit_account"`
+}
+
+// type RESERVE | https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingReserveTransaction
+type IncomingReserveTransaction struct {
+ Type string `json:"type"`
+ RowId int `json:"row_id"`
+ Date Timestamp `json:"date"`
+ Amount Amount `json:"amount"`
+ DebitAccount string `json:"debit_account"`
+ ReservePub EddsaPublicKey `json:"reserve_pub"`
+}
+
+type OutgoingHistory struct {
+ OutgoingTransactions []*OutgoingBankTransaction `json:"outgoing_transactions"`
+ DebitAccount string `json:"debit_account"`
+}
+
+type OutgoingBankTransaction struct {
+ RowId uint64 `json:"row_id"`
+ Date Timestamp `json:"date"`
+ Amount Amount `json:"amount"`
+ CreditAccount string `json:"credit_account"`
+ Wtid ShortHashCode `json:"wtid"`
+ ExchangeBaseUrl string `json:"exchange_base_url"`
+}
+
+func NewIncomingReserveTransaction(w *Withdrawal) *IncomingReserveTransaction {
+
+ if w == nil {
+ LogWarn("wire-gateway", "the withdrawal was nil")
+ return nil
+ }
+
+ provider, err := DB.GetProviderByTerminal(int(*w.TerminalId))
+ if err != nil {
+ LogError("wire-gateway", err)
+ return nil
+ }
+
+ client := PROVIDER_CLIENTS[provider.Name]
+ if client == nil {
+ LogError("wire-gateway", errors.New("no provider client with name="+provider.Name))
+ return nil
+ }
+
+ t := new(IncomingReserveTransaction)
+ t.Amount = Amount{
+ Value: uint64(w.Amount.Val),
+ Fraction: uint64(w.Amount.Frac),
+ Currency: w.Amount.Curr,
+ }
+ t.Date = Timestamp{
+ Ts: int(w.RegistrationTs),
+ }
+ t.DebitAccount = client.FormatPayto(w)
+ t.ReservePub = FormatEddsaPubKey(w.ReservePubKey)
+ t.RowId = int(w.WithdrawalRowId)
+ t.Type = INCOMING_RESERVE_TRANSACTION_TYPE
+ return t
+}
+
+func NewOutgoingBankTransaction(tr *Transfer) *OutgoingBankTransaction {
+ t := new(OutgoingBankTransaction)
+ t.Amount = Amount{
+ Value: uint64(tr.Amount.Val),
+ Fraction: uint64(tr.Amount.Frac),
+ Currency: tr.Amount.Curr,
+ }
+ t.Date = Timestamp{
+ Ts: int(tr.TransferTs),
+ }
+ t.CreditAccount = tr.CreditAccount
+ t.ExchangeBaseUrl = tr.ExchangeBaseUrl
+ t.RowId = uint64(tr.RowId)
+ t.Wtid = ShortHashCode(tr.Wtid)
+ return t
+}
+
+func wireGatewayConfig(res http.ResponseWriter, req *http.Request) {
+
+ cfg := WireConfig{
+ Name: "taler-wire-gateway",
+ Version: "0:0:1",
+ }
+
+ serializedCfg, err := NewJsonCodec[WireConfig]().EncodeToBytes(&cfg)
+ if err != nil {
+ log.Default().Printf("failed serializing config: %s", err.Error())
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ res.WriteHeader(HTTP_OK)
+ res.Write(serializedCfg)
+}
+
+func transfer(res http.ResponseWriter, req *http.Request) {
+
+ jsonCodec := NewJsonCodec[TransferRequest]()
+ transfer, err := ReadStructFromBody(req, jsonCodec)
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ paytoTargetType, tid, err := ParsePaytoWalleeTransaction(transfer.CreditAccount)
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ p, err := DB.GetTerminalProviderByPaytoTargetType(paytoTargetType)
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ decodedRequestUid, err := talerBinaryDecode(string(transfer.RequestUid))
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_BAD_REQUEST)
+ return
+ }
+
+ t, err := DB.GetTransferById(decodedRequestUid)
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ if t == nil {
+ // no transfer for this request_id -> generate new
+ err := DB.AddTransfer(
+ decodedRequestUid,
+ &transfer.Amount,
+ transfer.ExchangeBaseUrl,
+ string(transfer.Wtid),
+ transfer.CreditAccount,
+ )
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+ } else {
+ // the transfer is only processed if the body matches.
+ if transfer.Amount.Value != uint64(t.Amount.Val) ||
+ transfer.Amount.Fraction != uint64(t.Amount.Frac) ||
+ transfer.Amount.Currency != t.Amount.Curr ||
+ transfer.ExchangeBaseUrl != t.ExchangeBaseUrl ||
+ transfer.Wtid != ShortHashCode(t.Wtid) ||
+ transfer.CreditAccount != t.CreditAccount {
+
+ LogWarn("wire-gateway-api", "idempotency violation")
+ res.WriteHeader(HTTP_CONFLICT)
+ return
+ }
+
+ ptid := strconv.Itoa(tid)
+ w, err := DB.GetWithdrawalByProviderTransactionId(ptid)
+ if err != nil || w == nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ refundClient := PROVIDER_CLIENTS[p.Name]
+ if refundClient == nil {
+ LogError("wire-gateway-api", errors.New("client for provider "+p.Name+" not initialized"))
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+ refundClient.Refund(ptid)
+ }
+}
+
+// :query start: *Optional.*
+//
+// Row identifier to explicitly set the *starting point* of the query.
+//
+// :query delta:
+//
+// The *delta* value that determines the range of the query.
+//
+// :query long_poll_ms: *Optional.*
+//
+// If this parameter is specified and the result of the query would be empty,
+// the bank will wait up to ``long_poll_ms`` milliseconds for new transactions
+// that match the query to arrive and only then send the HTTP response.
+// A client must never rely on this behavior, as the bank may return a response
+// immediately or after waiting only a fraction of ``long_poll_ms``.
+func historyIncoming(res http.ResponseWriter, req *http.Request) {
+
+ // read and validate request query parameters
+ shouldStartLongPoll := true
+ var longPollMilli int
+ if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse(
+ "long_poll_ms", strconv.Atoi, req, res,
+ ); accepted {
+ } else {
+ if longPollMilliPtr != nil {
+ longPollMilli = *longPollMilliPtr
+ } else {
+ // this means parameter was not given.
+ // no long polling (simple get)
+ shouldStartLongPoll = false
+ }
+ }
+
+ var start int
+ if startPtr, accepted := AcceptOptionalParamOrWriteResponse(
+ "start", strconv.Atoi, req, res,
+ ); accepted {
+ } else {
+ if startPtr != nil {
+ start = *startPtr
+ }
+ }
+
+ var delta int
+ if deltaPtr, accepted := AcceptOptionalParamOrWriteResponse(
+ "delta", strconv.Atoi, req, res,
+ ); accepted {
+ } else {
+ if deltaPtr != nil {
+ delta = *deltaPtr
+ } else {
+ // this means parameter was not given.
+ // no long polling (simple get)
+ shouldStartLongPoll = false
+ }
+ }
+
+ if delta == 0 {
+ delta = 10
+ }
+
+ if shouldStartLongPoll {
+
+ // wait for the completion of the context
+ waitMs, cancelFunc := context.WithTimeout(req.Context(), time.Duration(longPollMilli)*time.Millisecond)
+ defer cancelFunc()
+
+ // this will just wait / block until the milliseconds are exceeded.
+ <-waitMs.Done()
+ }
+
+ withdrawals, err := DB.GetConfirmedWithdrawals(start, delta)
+
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ if len(withdrawals) < 1 {
+ res.WriteHeader(HTTP_NOT_FOUND)
+ return
+ }
+
+ transactions := make([]*IncomingReserveTransaction, 0)
+ for _, w := range withdrawals {
+ transaction := NewIncomingReserveTransaction(w)
+ if transaction != nil {
+ transactions = append(transactions, transaction)
+ }
+ }
+
+ enc, err := NewJsonCodec[[]*IncomingReserveTransaction]().EncodeToBytes(&transactions)
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ res.WriteHeader(HTTP_OK)
+ res.Write(enc)
+}
+
+func historyOutgoing(res http.ResponseWriter, req *http.Request) {
+
+ // read and validate request query parameters
+ shouldStartLongPoll := true
+ var longPollMilli int
+ if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse(
+ "long_poll_ms", strconv.Atoi, req, res,
+ ); accepted {
+ } else {
+ if longPollMilliPtr != nil {
+ longPollMilli = *longPollMilliPtr
+ } else {
+ // this means parameter was not given.
+ // no long polling (simple get)
+ shouldStartLongPoll = false
+ }
+ }
+
+ var start int
+ if startPtr, accepted := AcceptOptionalParamOrWriteResponse(
+ "start", strconv.Atoi, req, res,
+ ); accepted {
+ } else {
+ if startPtr != nil {
+ start = *startPtr
+ }
+ }
+
+ var delta int
+ if deltaPtr, accepted := AcceptOptionalParamOrWriteResponse(
+ "delta", strconv.Atoi, req, res,
+ ); accepted {
+ } else {
+ if deltaPtr != nil {
+ delta = *deltaPtr
+ } else {
+ // this means parameter was not given.
+ // no long polling (simple get)
+ shouldStartLongPoll = false
+ }
+ }
+
+ if shouldStartLongPoll {
+
+ // this will just wait / block until the milliseconds are exceeded.
+ time.Sleep(time.Duration(longPollMilli) * time.Millisecond)
+ }
+
+ transfers, err := DB.GetTransfers(start, delta)
+
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ filtered := make([]*Transfer, 0)
+ for _, t := range transfers {
+ if t.Status == 0 {
+ // only consider transfer which were successful
+ filtered = append(filtered, t)
+ }
+ }
+
+ if len(filtered) < 1 {
+ res.WriteHeader(HTTP_NOT_FOUND)
+ return
+ }
+
+ transactions := make([]*OutgoingBankTransaction, len(filtered))
+ for _, t := range filtered {
+ transactions = append(transactions, NewOutgoingBankTransaction(t))
+ }
+
+ outgoingHistory := OutgoingHistory{
+ OutgoingTransactions: transactions,
+ DebitAccount: CONFIG.Server.CreditAccount,
+ }
+ enc, err := NewJsonCodec[OutgoingHistory]().EncodeToBytes(&outgoingHistory)
+ if err != nil {
+ LogError("wire-gateway-api", err)
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
+ return
+ }
+
+ res.WriteHeader(HTTP_OK)
+ res.Write(enc)
+}
+
+// This method is currently dead and implemented for API conformance
+func adminAddIncoming(res http.ResponseWriter, req *http.Request) {
+
+ // not implemented, because not used
+ res.WriteHeader(HTTP_BAD_REQUEST)
+}
diff --git a/c2ec/attestor.go b/c2ec/attestor.go
@@ -1,153 +0,0 @@
-package main
-
-import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
- "time"
-)
-
-const PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE = 10
-const PS_PAYMENT_NOTIFICATION_CHANNEL = "payment_notification"
-
-// Sets up and runs an attestor in the background. This must be called at startup.
-func RunAttestor(
- ctx context.Context,
- errs chan error,
-) {
-
- go RunListener(
- ctx,
- PS_PAYMENT_NOTIFICATION_CHANNEL,
- attestationCallback,
- make(chan *Notification, PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE),
- errs,
- )
-}
-
-func attestationCallback(notification *Notification, errs chan error) {
-
- LogInfo("attestor", fmt.Sprintf("retrieved information on channel=%s with payload=%s", notification.Channel, notification.Payload))
-
- // The payload is formatted like: "{PROVIDER_NAME}|{WITHDRAWAL_ID}|{PROVIDER_TRANSACTION_ID}"
- // the validation is strict. This means, that the dispatcher emits an error
- // and returns, if a property is malformed.
- payload := strings.Split(notification.Payload, "|")
- if len(payload) != 3 {
- errs <- errors.New("malformed notification payload: " + notification.Payload)
- return
- }
-
- providerName := payload[0]
- if providerName == "" {
- errs <- errors.New("the provider of the payment is not specified")
- return
- }
- withdrawalRowId, err := strconv.Atoi(payload[1])
- if err != nil {
- errs <- errors.New("malformed withdrawal_id: " + err.Error())
- return
- }
- providerTransactionId := payload[2]
-
- client := PROVIDER_CLIENTS[providerName]
- if client == nil {
- errs <- errors.New("no provider client registered for provider " + providerName)
- }
-
- transaction, err := client.GetTransaction(providerTransactionId)
- if err != nil {
- LogError("attestor", err)
- prepareRetryOrAbort(withdrawalRowId, errs)
- return
- }
-
- finaliseOrSetRetry(
- transaction,
- withdrawalRowId,
- errs,
- )
-}
-
-func finaliseOrSetRetry(
- transaction ProviderTransaction,
- withdrawalRowId int,
- errs chan error,
-) {
-
- if transaction == nil {
- err := errors.New("transaction was nil. will set retry or abort")
- LogError("attestor", err)
- errs <- err
- prepareRetryOrAbort(withdrawalRowId, errs)
- return
- }
-
- completionProof := transaction.Bytes()
- if len(completionProof) > 0 {
- // only allow finalization operation, when the completion
- // proof of the transaction could be retrieved
- if transaction.AllowWithdrawal() {
-
- err := DB.FinaliseWithdrawal(withdrawalRowId, CONFIRMED, completionProof)
- if err != nil {
- LogError("attestor", err)
- prepareRetryOrAbort(withdrawalRowId, errs)
- }
- } else {
- // when the received transaction is not allowed, we first check if the
- // transaction is in a final state which will not allow the withdrawal
- // and therefore the operation can be aborted, without further retries.
- if transaction.AbortWithdrawal() {
- err := DB.FinaliseWithdrawal(withdrawalRowId, ABORTED, completionProof)
- if err != nil {
- LogError("attestor", err)
- prepareRetryOrAbort(withdrawalRowId, errs)
- return
- }
- }
- prepareRetryOrAbort(withdrawalRowId, errs)
- }
- return
- }
- // when the transaction proof was not present (empty proof), retry.
- prepareRetryOrAbort(withdrawalRowId, errs)
-}
-
-// Checks wether the maximal amount of retries was already
-// reached and the withdrawal operation shall be aborted or
-// triggers the next retry by setting the last_retry_ts field
-// which will trigger the stored procedure triggering the retry
-// process. The retry counter of the retries is handled by the
-// retrier logic and shall not be set here!
-func prepareRetryOrAbort(
- withdrawalRowId int,
- errs chan error,
-) {
-
- withdrawal, err := DB.GetWithdrawalById(withdrawalRowId)
- if err != nil {
- LogError("attestor", err)
- errs <- err
- return
- }
-
- if withdrawal.RetryCounter >= CONFIG.Server.MaxRetries {
-
- LogInfo("attestor", fmt.Sprintf("max retries for withdrawal with id=%d was reached. withdrawal is aborted.", withdrawal.WithdrawalId))
- err := DB.FinaliseWithdrawal(withdrawalRowId, ABORTED, make([]byte, 0))
- if err != nil {
- LogError("attestor", err)
- }
- } else {
-
- lastRetryTs := time.Now().Unix()
- err := DB.SetLastRetry(withdrawalRowId, lastRetryTs)
- if err != nil {
- LogError("attestor", err)
- }
- }
-
-}
diff --git a/c2ec/auth.go b/c2ec/auth.go
@@ -1,157 +0,0 @@
-package main
-
-import (
- "encoding/base64"
- "errors"
- "fmt"
- "net/http"
- "strconv"
- "strings"
-
- "golang.org/x/crypto/argon2"
-)
-
-const AUTHORIZATION_HEADER = "Authorization"
-const BASIC_AUTH_PREFIX = "Basic "
-
-// Authenticates the Exchange against C2EC
-// returns true if authentication was successful, otherwise false
-// when not successful, the api shall return immediately
-// The exchange is specified to use basic auth
-func AuthenticateExchange(req *http.Request) bool {
-
- auth := req.Header.Get(AUTHORIZATION_HEADER)
- if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found {
-
- ba := fmt.Sprintf("%s:%s", CONFIG.Server.WireGateway.Username, CONFIG.Server.WireGateway.Password)
- encoded := base64.StdEncoding.EncodeToString([]byte(ba))
- return encoded == basicAuth
- }
- return false
-}
-
-// Authenticates a terminal against C2EC
-// returns true if authentication was successful, otherwise false
-// when not successful, the api shall return immediately
-//
-// Terminals are authenticated using basic auth.
-// The basic authorization header MUST be base64 encoded.
-// The username part is the name of the provider (case sensitive) a '-' sign, followed
-// by the id of the terminal, which is a number.
-func AuthenticateTerminal(req *http.Request) bool {
-
- auth := req.Header.Get(AUTHORIZATION_HEADER)
- if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found {
-
- decoded, err := base64.StdEncoding.DecodeString(basicAuth)
- if err != nil {
- return false
- }
-
- username, password, err := parseBasicAuth(string(decoded))
- if err != nil {
- return false
- }
-
- provider, terminalId, err := parseTerminalUser(username)
- if err != nil {
- return false
- }
- LogInfo("auth", fmt.Sprintf("req=%s by terminal with id=%d, provider=%s", req.RequestURI, terminalId, provider))
-
- terminal, err := DB.GetTerminalById(terminalId)
- if err != nil {
- return false
- }
-
- if !terminal.Active {
- LogWarn("auth", fmt.Sprintf("request from inactive terminal. id=%d", terminalId))
- return false
- }
-
- prvdr, err := DB.GetTerminalProviderByName(provider)
- if err != nil {
- LogWarn("auth", fmt.Sprintf("failed requesting provider by name %s", err.Error()))
- return false
- }
-
- if terminal.ProviderId != prvdr.ProviderId {
- LogWarn("auth", "terminal's provider id did not match provider id of supplied provider")
- return false
- }
-
- return ValidPassword(password, terminal.AccessToken)
- }
-
- return false
-}
-
-func parseBasicAuth(basicAuth string) (string, string, error) {
-
- parts := strings.Split(basicAuth, ":")
- if len(parts) != 2 {
- return "", "", errors.New("malformed basic auth")
- }
- return parts[0], parts[1], nil
-}
-
-// parses the username of the basic auth param of the terminal.
-// the username has following format:
-//
-// [PROVIDER_NAME]-[TERMINAL_ID]
-func parseTerminalUser(username string) (string, int, error) {
-
- parts := strings.Split(username, "-")
- if len(parts) != 2 {
- return "", -1, errors.New("malformed basic auth username")
- }
-
- providerName := parts[0]
- terminalId, err := strconv.Atoi(parts[1])
- if err != nil {
- return "", -1, errors.New("malformed basic auth username")
- }
-
- return providerName, terminalId, nil
-}
-
-// takes a password and a base64 encoded password hash, including salt and checks
-// the password supplied against it.
-// the format of the password hash is expected to be the following:
-//
-// [32 BYTES HASH][16 BYTES SALT] = Bytes array with length of 48 bytes.
-//
-// returns true if password matches the password hash. Otherwise false.
-func ValidPassword(pw string, base64EncodedHashAndSalt string) bool {
-
- hashedBytes := make([]byte, 48)
- decodedLen, err := base64.StdEncoding.Decode(hashedBytes, []byte(base64EncodedHashAndSalt))
- if err != nil {
- return false
- }
-
- if decodedLen != 48 {
- // malformed credentials
- return false
- }
-
- salt := hashedBytes[32:48]
- rfcTime := 3
- rfcMemory := 32 * 1024
- key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32)
-
- if len(key) != 32 {
- // length mismatch
- return false
- }
-
- for i := range key {
- if key[i] != hashedBytes[i] {
- // wrong password (application user key)
- return false
- }
- }
-
- // password correct.
- return true
-}
diff --git a/c2ec/bank-integration.go b/c2ec/bank-integration.go
@@ -1,251 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "encoding/base64"
- "fmt"
- http "net/http"
- "strconv"
- "time"
-)
-
-const WITHDRAWAL_OPERATION = "/withdrawal-operation"
-
-const WOPID_PARAMETER = "wopid"
-const BANK_INTEGRATION_CONFIG_PATTERN = "/config"
-const WITHDRAWAL_OPERATION_PATTERN = WITHDRAWAL_OPERATION
-const WITHDRAWAL_OPERATION_BY_WOPID_PATTERN = WITHDRAWAL_OPERATION + "/{" + WOPID_PARAMETER + "}"
-const WITHDRAWAL_OPERATION_ABORTION_PATTERN = WITHDRAWAL_OPERATION_BY_WOPID_PATTERN + "/abort"
-
-const DEFAULT_LONG_POLL_MS = 1000
-const DEFAULT_OLD_STATE = PENDING
-
-// https://docs.taler.net/core/api-exchange.html#tsref-type-CurrencySpecification
-type CurrencySpecification struct {
- Name string `json:"name"`
- Currency string `json:"currency"`
- NumFractionalInputDigits int `json:"num_fractional_input_digits"`
- NumFractionalNormalDigits int `json:"num_fractional_normal_digits"`
- NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"`
- AltUnitNames string `json:"alt_unit_names"`
-}
-
-// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig
-type BankIntegrationConfig struct {
- Name string `json:"name"`
- Version string `json:"version"`
- Implementation string `json:"implementation"`
- Currency string `json:"currency"`
- CurrencySpecification CurrencySpecification `json:"currency_specification"`
- // TODO: maybe add exchanges payto uri for transfers etc.?
-}
-
-type C2ECWithdrawRegistration struct {
- ReservePubKey EddsaPublicKey `json:"reserve_pub_key"`
-}
-
-type C2ECWithdrawalStatus struct {
- Status WithdrawalOperationStatus `json:"status"`
- Amount Amount `json:"amount"`
- SenderWire string `json:"sender_wire"`
- WireTypes []string `json:"wire_types"`
- ReservePubKey EddsaPublicKey `json:"selected_reserve_pub"`
-}
-
-type C2ECPaymentNotification struct {
- ProviderTransactionId string `json:"provider_transaction_id"`
- TerminalId int `json:"terminal_id"`
- Amount Amount `json:"amount"`
- Fees Amount `json:"card_fees"`
-}
-
-func bankIntegrationConfig(res http.ResponseWriter, req *http.Request) {
-
- cfg := BankIntegrationConfig{
- Name: "taler-bank-integration",
- Version: "0:0:1",
- }
-
- serializedCfg, err := NewJsonCodec[BankIntegrationConfig]().EncodeToBytes(&cfg)
- if err != nil {
- LogInfo("bank-integration-api", fmt.Sprintf("failed serializing config: %s", err.Error()))
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- return
- }
-
- res.WriteHeader(HTTP_OK)
- res.Write(serializedCfg)
-}
-
-// Get status of withdrawal associated with the given WOPID
-//
-// Parameters:
-// - long_poll_ms (optional):
-// milliseconds to wait for state to change
-// given old_state until responding
-// - old_state (optional):
-// Default is 'pending'
-func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) {
-
- // read and validate request query parameters
- shouldStartLongPoll := true
- longPollMilli := DEFAULT_LONG_POLL_MS
- if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse(
- "long_poll_ms", strconv.Atoi, req, res,
- ); accepted {
- if longPollMilliPtr != nil {
- longPollMilli = *longPollMilliPtr
- } else {
- // this means parameter was not given.
- // no long polling (simple get)
- shouldStartLongPoll = false
- }
- } else {
- shouldStartLongPoll = false
- }
-
- // read and validate the wopid path parameter
- wopid := req.PathValue(WOPID_PARAMETER)
- wpd, err := ParseWopid(wopid)
- if err != nil {
- LogWarn("bank-integration-api", "wopid "+wopid+" not valid")
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER",
- Title: "invalid request path parameter",
- Detail: "the withdrawal status request path parameter 'wopid' is malformed",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- if shouldStartLongPoll {
-
- timeoutCtx, cancelFunc := context.WithTimeout(
- req.Context(),
- time.Duration(longPollMilli)*time.Millisecond,
- )
- defer cancelFunc()
-
- notifications := make(chan *Notification)
- channel := "w_" + base64.StdEncoding.EncodeToString(wpd)
-
- listenFunc, err := DB.NewListener(
- channel,
- notifications,
- )
-
- if err != nil {
- err := WriteProblem(res, HTTP_NO_CONTENT, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_LISTEN_FAILURE",
- Title: "Failed setting up listener",
- Detail: fmt.Sprintf("unable to start long polling due to %s", err.Error()),
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- go listenFunc(timeoutCtx)
-
- // go DB.ListenForWithdrawalStatusChange(timeoutCtx, WithdrawalIdentifier(base64.StdEncoding.EncodeToString(wpd)), statusChannel, errChan)
- for {
- select {
- case <-timeoutCtx.Done():
- err := WriteProblem(res, HTTP_NO_CONTENT, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_LONG_POLL_TIME_EXCEEDED",
- Title: "time exceeded",
- Detail: fmt.Sprintf("long poll ended due to timeout: %dms", longPollMilli),
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- case <-notifications:
- writeWithdrawalOrError(wpd, res, req.RequestURI)
- return
- }
- }
- }
-
- writeWithdrawalOrError(wpd, res, req.RequestURI)
-}
-
-func handleWithdrawalAbort(res http.ResponseWriter, req *http.Request) {
-
- res.WriteHeader(HTTP_OK)
- res.Write(bytes.NewBufferString("retrieved withdrawal operation abortion request").Bytes())
-}
-
-// Tries to load a WithdrawalOperationStatus from the database. If no
-// entry could been found, it will write the correct error to the response.
-func writeWithdrawalOrError(wopid []byte, res http.ResponseWriter, reqUri string) {
- // read the withdrawal from the database
- withdrawal, err := DB.GetWithdrawalByWopid(wopid)
- if err != nil {
-
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_DB_FAILURE",
- Title: "database failure",
- Detail: "db failure while requesting withdrawal (error=" + err.Error() + ")",
- Instance: reqUri,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- if withdrawal == nil {
- // not found -> 404
- err := WriteProblem(res, HTTP_NOT_FOUND, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_NOT_FOUND",
- Title: "Not Found",
- Detail: "No withdrawal with wopid=" + talerBinaryEncode(wopid) + " could been found.",
- Instance: reqUri,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- // return the C2ECWithdrawalStatus
- if amount, err := ToAmount(withdrawal.Amount); err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_CONVERSION_FAILURE",
- Title: "conversion failure",
- Detail: "failed converting amount object (error:" + err.Error() + ")",
- Instance: reqUri,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- } else {
- withdrawalStatusBytes, err := NewJsonCodec[C2ECWithdrawalStatus]().EncodeToBytes(&C2ECWithdrawalStatus{
- Status: withdrawal.WithdrawalStatus,
- Amount: *amount,
- })
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAWAL_STATUS_CONVERSION_FAILURE",
- Title: "conversion failure",
- Detail: "failed converting C2ECWithdrawalStatus object (error:" + err.Error() + ")",
- Instance: reqUri,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
- res.WriteHeader(HTTP_OK)
- res.Write(withdrawalStatusBytes)
- }
-}
diff --git a/c2ec/db-postgres.go b/c2ec/db-postgres.go
@@ -0,0 +1,787 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/jackc/pgxlisten"
+)
+
+const PS_INSERT_WITHDRAWAL = "INSERT INTO " + WITHDRAWAL_TABLE_NAME + " (" +
+ WITHDRAWAL_FIELD_NAME_WOPID + ", " + WITHDRAWAL_FIELD_NAME_RUID + ", " +
+ WITHDRAWAL_FIELD_NAME_SUGGESTED_AMOUNT + ", " + WITHDRAWAL_FIELD_NAME_AMOUNT + ", " +
+ WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + ", " + WITHDRAWAL_FIELD_NAME_FEES + ", " + WITHDRAWAL_FIELD_NAME_TS +
+ ") VALUES ($1,$2,($3,$4,$5),($6,$7,$8),$9,($10,$11,$12),$13)"
+
+const PS_REGISTER_WITHDRAWAL_PARAMS = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
+ WITHDRAWAL_FIELD_NAME_RESPUBKEY + "," +
+ WITHDRAWAL_FIELD_NAME_STATUS + "," +
+ WITHDRAWAL_FIELD_NAME_TS + ")" +
+ " = ($1,$2,$3)" +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$4"
+
+const PS_GET_UNCONFIRMED_WITHDRAWALS = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + " IS NOT NULL" +
+ " AND " + WITHDRAWAL_FIELD_NAME_STATUS + " = '" + string(SELECTED) + "'"
+
+const PS_PAYMENT_NOTIFICATION = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
+ WITHDRAWAL_FIELD_NAME_FEES + "," + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "," +
+ WITHDRAWAL_FIELD_NAME_TERMINAL_ID + ")" +
+ " = (($1,$2,$3),$4,$5)" +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$6"
+
+const PS_FINALISE_PAYMENT = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
+ WITHDRAWAL_FIELD_NAME_STATUS + "," +
+ WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF + ")" +
+ " = ($1, $2)" +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$3"
+
+const PS_SET_LAST_RETRY = "UPDATE " + WITHDRAWAL_TABLE_NAME +
+ " SET " + WITHDRAWAL_FIELD_NAME_LAST_RETRY + "=$1" +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$2"
+
+const PS_SET_RETRY_COUNTER = "UPDATE " + WITHDRAWAL_TABLE_NAME +
+ " SET " + WITHDRAWAL_FIELD_NAME_RETRY_COUNTER + "=$1" +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$2"
+
+const PS_CONFIRMED_TRANSACTIONS_ASC = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_STATUS + "='" + string(CONFIRMED) + "'" +
+ " ORDER BY " + WITHDRAWAL_FIELD_NAME_ID + " ASC" +
+ " LIMIT $1" +
+ " OFFSET $2"
+
+const PS_CONFIRMED_TRANSACTIONS_DESC = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_STATUS + "='" + string(CONFIRMED) + "'" +
+ " ORDER BY " + WITHDRAWAL_FIELD_NAME_ID + " DESC" +
+ " LIMIT $1" +
+ " OFFSET $2"
+
+const PS_GET_WITHDRAWAL_BY_RUID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_RUID + "=$1"
+
+const PS_GET_WITHDRAWAL_BY_ID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$1"
+
+const PS_GET_WITHDRAWAL_BY_WOPID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$1"
+
+const PS_GET_WITHDRAWAL_BY_PTID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
+ " WHERE " + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "=$1"
+
+const PS_GET_PROVIDER_BY_TERMINAL = "SELECT * FROM " + PROVIDER_TABLE_NAME +
+ " WHERE " + PROVIDER_FIELD_NAME_ID +
+ " = (SELECT " + TERMINAL_FIELD_NAME_PROVIDER_ID + " FROM " + TERMINAL_TABLE_NAME +
+ " WHERE " + TERMINAL_FIELD_NAME_ID + "=$1)"
+
+const PS_GET_PROVIDER_BY_NAME = "SELECT * FROM " + PROVIDER_TABLE_NAME +
+ " WHERE " + PROVIDER_FIELD_NAME_NAME + "=$1"
+
+const PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE = "SELECT * FROM " + PROVIDER_TABLE_NAME +
+ " WHERE " + PROVIDER_FIELD_NAME_PAYTO_TARGET_TYPE + "=$1"
+
+const PS_GET_TERMINAL_BY_ID = "SELECT * FROM " + TERMINAL_TABLE_NAME +
+ " WHERE " + TERMINAL_FIELD_NAME_ID + "=$1"
+
+const PS_GET_TRANSFER_BY_ID = "SELECT * FROM " + TRANSFER_TABLE_NAME +
+ " WHERE " + TRANSFER_FIELD_NAME_ID + "=$1"
+
+const PS_ADD_TRANSFER = "INSERT INTO " + TRANSFER_TABLE_NAME +
+ " (" + TRANSFER_FIELD_NAME_ID + ", " + TRANSFER_FIELD_NAME_AMOUNT + ", " +
+ TRANSFER_FIELD_NAME_EXCHANGE_BASE_URL + ", " + TRANSFER_FIELD_NAME_WTID + ", " +
+ TRANSFER_FIELD_NAME_CREDIT_ACCOUNT + ") VALUES ($1, $2, $3, $4, $5)"
+
+const PS_UPDATE_TRANSFER = "UPDATE " + TRANSFER_TABLE_NAME + " SET (" +
+ TRANSFER_FIELD_NAME_TS + ", " + TRANSFER_FIELD_NAME_STATUS + ", " +
+ TRANSFER_FIELD_NAME_RETRIES + ") VALUES ($1,$2,$3) WHERE " +
+ TRANSFER_FIELD_NAME_ID + "=$4"
+
+const PS_GET_TRANSFERS_ASC = "SELECT * FROM " + TRANSFER_TABLE_NAME +
+ " ORDER BY " + TRANSFER_FIELD_NAME_ROW_ID + " ASC" +
+ " LIMIT $1" +
+ " OFFSET $2"
+
+const PS_GET_TRANSFERS_DESC = "SELECT * FROM " + TRANSFER_TABLE_NAME +
+ " ORDER BY " + TRANSFER_FIELD_NAME_ROW_ID + " DESC" +
+ " LIMIT $1" +
+ " OFFSET $2"
+
+// Postgres implementation of the C2ECDatabase
+type C2ECPostgres struct {
+ C2ECDatabase
+
+ ctx context.Context
+ pool *pgxpool.Pool
+}
+
+func PostgresConnectionString(cfg *C2ECDatabseConfig) string {
+ return fmt.Sprintf(
+ "postgres://%s:%s@%s:%d/%s",
+ cfg.Username,
+ cfg.Password,
+ cfg.Host,
+ cfg.Port,
+ cfg.Database,
+ )
+}
+
+func NewC2ECPostgres(cfg *C2ECDatabseConfig) (*C2ECPostgres, error) {
+
+ ctx := context.Background()
+ db := new(C2ECPostgres)
+
+ connectionString := PostgresConnectionString(cfg)
+ pgHost := os.Getenv("PGHOST")
+ if pgHost != "" {
+ LogInfo("postgres", "pghost was set")
+ connectionString = pgHost
+ }
+
+ dbConnCfg, err := pgxpool.ParseConfig(connectionString)
+ if err != nil {
+ panic(err.Error())
+ }
+ dbConnCfg.AfterConnect = db.registerCustomTypesHook
+ db.pool, err = pgxpool.NewWithConfig(context.Background(), dbConnCfg)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ db.ctx = ctx
+
+ return db, nil
+}
+
+func (db *C2ECPostgres) registerCustomTypesHook(ctx context.Context, conn *pgx.Conn) error {
+
+ t, err := conn.LoadType(ctx, "c2ec.taler_amount_currency")
+ if err != nil {
+ return err
+ }
+
+ conn.TypeMap().RegisterType(t)
+ return nil
+}
+
+func (db *C2ECPostgres) SetupWithdrawal(
+ wopid []byte,
+ suggestedAmount Amount,
+ amount Amount,
+ providerTransactionId string,
+ terminalFees Amount,
+ requestUid string,
+) error {
+
+ ts := time.Now()
+ res, err := db.pool.Exec(
+ db.ctx,
+ PS_INSERT_WITHDRAWAL,
+ wopid,
+ requestUid,
+ suggestedAmount.Value,
+ suggestedAmount.Fraction,
+ suggestedAmount.Currency,
+ amount.Value,
+ amount.Fraction,
+ amount.Currency,
+ providerTransactionId,
+ terminalFees.Value,
+ terminalFees.Fraction,
+ terminalFees.Currency,
+ ts.Unix(),
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ LogInfo("postgres", "query="+PS_INSERT_WITHDRAWAL)
+ LogInfo("postgres", "setup withdrawal successfully. affected rows="+strconv.Itoa(int(res.RowsAffected())))
+ return nil
+}
+
+func (db *C2ECPostgres) RegisterWithdrawalParameters(
+ wopid []byte,
+ resPubKey EddsaPublicKey,
+) error {
+
+ resPubKeyBytes, err := ParseEddsaPubKey(resPubKey)
+ if err != nil {
+ return err
+ }
+
+ ts := time.Now()
+ res, err := db.pool.Exec(
+ db.ctx,
+ PS_REGISTER_WITHDRAWAL_PARAMS,
+ resPubKeyBytes,
+ SELECTED,
+ ts.Unix(),
+ wopid,
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ LogInfo("postgres", "query="+PS_REGISTER_WITHDRAWAL_PARAMS)
+ LogInfo("postgres", "registered withdrawal successfully. affected rows="+strconv.Itoa(int(res.RowsAffected())))
+ return nil
+}
+
+func (db *C2ECPostgres) GetWithdrawalByRequestUid(requestUid string) (*Withdrawal, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_WITHDRAWAL_BY_RUID,
+ requestUid,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+ defer row.Close()
+ LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_RUID)
+ collected, err := pgx.CollectOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return collected, nil
+ }
+}
+
+func (db *C2ECPostgres) GetWithdrawalById(withdrawalId int) (*Withdrawal, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_WITHDRAWAL_BY_ID,
+ withdrawalId,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+ LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_ID)
+ return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal])
+ }
+}
+
+func (db *C2ECPostgres) GetWithdrawalByWopid(wopid []byte) (*Withdrawal, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_WITHDRAWAL_BY_WOPID,
+ wopid,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+ LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_WOPID)
+ return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal])
+ }
+}
+
+func (db *C2ECPostgres) GetWithdrawalByProviderTransactionId(tid string) (*Withdrawal, error) {
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_WITHDRAWAL_BY_PTID,
+ tid,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+ LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_PTID)
+ return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal])
+ }
+}
+
+func (db *C2ECPostgres) NotifyPayment(
+ wopid []byte,
+ providerTransactionId string,
+ terminalId int,
+ fees Amount,
+) error {
+
+ res, err := db.pool.Exec(
+ db.ctx,
+ PS_PAYMENT_NOTIFICATION,
+ fees.Value,
+ fees.Fraction,
+ fees.Currency,
+ providerTransactionId,
+ terminalId,
+ wopid,
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ LogInfo("postgres", "query="+PS_PAYMENT_NOTIFICATION+", affected rows="+strconv.Itoa(int(res.RowsAffected())))
+ return nil
+}
+
+func (db *C2ECPostgres) GetAttestableWithdrawals() ([]*Withdrawal, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_UNCONFIRMED_WITHDRAWALS,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ LogInfo("postgres", "query="+PS_GET_UNCONFIRMED_WITHDRAWALS)
+ return removeNulls(withdrawals), nil
+ }
+}
+
+func (db *C2ECPostgres) FinaliseWithdrawal(
+ withdrawalId int,
+ confirmOrAbort WithdrawalOperationStatus,
+ completionProof []byte,
+) error {
+
+ if confirmOrAbort != CONFIRMED && confirmOrAbort != ABORTED {
+ return errors.New("can only finalise payment when new status is either confirmed or aborted")
+ }
+
+ _, err := db.pool.Exec(
+ db.ctx,
+ PS_FINALISE_PAYMENT,
+ confirmOrAbort,
+ completionProof,
+ withdrawalId,
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ LogInfo("postgres", "query="+PS_FINALISE_PAYMENT)
+ return nil
+}
+
+func (db *C2ECPostgres) SetLastRetry(withdrawalId int, lastRetryTsUnix int64) error {
+
+ _, err := db.pool.Exec(
+ db.ctx,
+ PS_SET_LAST_RETRY,
+ lastRetryTsUnix,
+ withdrawalId,
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ LogInfo("postgres", "query="+PS_SET_LAST_RETRY)
+ return nil
+}
+
+func (db *C2ECPostgres) SetRetryCounter(withdrawalId int, retryCounter int) error {
+
+ _, err := db.pool.Exec(
+ db.ctx,
+ PS_SET_RETRY_COUNTER,
+ retryCounter,
+ withdrawalId,
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ LogInfo("postgres", "query="+PS_SET_RETRY_COUNTER)
+ return nil
+}
+
+// The query at the postgres database works as specified by the
+// wire gateway api.
+func (db *C2ECPostgres) GetConfirmedWithdrawals(start int, delta int) ([]*Withdrawal, error) {
+
+ query := PS_CONFIRMED_TRANSACTIONS_ASC
+ if delta < 0 {
+ query = PS_CONFIRMED_TRANSACTIONS_DESC
+ }
+
+ limit := math.Abs(float64(delta))
+ offset := start
+ if delta < 0 {
+ offset = start - int(limit)
+ }
+ if offset < 0 {
+ offset = 0
+ }
+
+ var row pgx.Rows
+ var err error
+ if start < 0 {
+ // use MAX(id) instead of a concrete id, because start
+ // identifier was negative. Inidicates to read the most
+ // recent ids.
+ row, err = db.pool.Query(
+ db.ctx,
+ query,
+ limit,
+ "MAX("+WITHDRAWAL_FIELD_NAME_ID+")",
+ )
+ } else {
+ row, err = db.pool.Query(
+ db.ctx,
+ query,
+ limit,
+ offset,
+ )
+ }
+
+ LogInfo("postgres", "query="+query)
+ if err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ return removeNulls(withdrawals), nil
+ }
+}
+
+func (db *C2ECPostgres) GetProviderByTerminal(terminalId int) (*Provider, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_PROVIDER_BY_TERMINAL,
+ terminalId,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_TERMINAL)
+ return provider, nil
+ }
+}
+
+func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*Provider, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_PROVIDER_BY_NAME,
+ name,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_NAME)
+ return provider, nil
+ }
+}
+
+func (db *C2ECPostgres) GetTerminalProviderByPaytoTargetType(paytoTargetType string) (*Provider, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE,
+ paytoTargetType,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE)
+ return provider, nil
+ }
+}
+
+func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_TERMINAL_BY_ID,
+ id,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ terminal, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Terminal])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ LogInfo("postgres", "query="+PS_GET_TERMINAL_BY_ID)
+ return terminal, nil
+ }
+}
+
+func (db *C2ECPostgres) GetTransferById(requestUid []byte) (*Transfer, error) {
+
+ if row, err := db.pool.Query(
+ db.ctx,
+ PS_GET_TRANSFER_BY_ID,
+ requestUid,
+ ); err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ transfer, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Transfer])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ LogInfo("postgres", "query="+PS_GET_TRANSFER_BY_ID)
+ return transfer, nil
+ }
+
+}
+
+func (db *C2ECPostgres) AddTransfer(
+ requestUid []byte,
+ amount *Amount,
+ exchangeBaseUrl string,
+ wtid string,
+ credit_account string,
+) error {
+
+ dbAmount := TalerAmountCurrency{
+ Val: int64(amount.Value),
+ Frac: int32(amount.Fraction),
+ Curr: amount.Currency,
+ }
+
+ res, err := db.pool.Query(
+ db.ctx,
+ PS_ADD_TRANSFER,
+ requestUid,
+ dbAmount,
+ exchangeBaseUrl,
+ wtid,
+ credit_account,
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ res.Close()
+ LogInfo("postgres", "query="+PS_ADD_TRANSFER)
+ return nil
+}
+
+func (db *C2ECPostgres) UpdateTransfer(
+ requestUid []byte,
+ timestamp int64,
+ status int16,
+ retries int16,
+) error {
+
+ _, err := db.pool.Exec(
+ db.ctx,
+ PS_UPDATE_TRANSFER,
+ timestamp,
+ status,
+ retries,
+ requestUid,
+ )
+ if err != nil {
+ LogError("postgres", err)
+ return err
+ }
+ LogInfo("postgres", "query="+PS_UPDATE_TRANSFER)
+ return nil
+}
+
+func (db *C2ECPostgres) GetTransfers(start int, delta int) ([]*Transfer, error) {
+
+ query := PS_GET_TRANSFERS_ASC
+ if delta < 0 {
+ query = PS_GET_TRANSFERS_DESC
+ }
+
+ limit := math.Abs(float64(delta))
+ offset := start
+ if delta < 0 {
+ offset = start - int(limit)
+ }
+ if offset < 0 {
+ offset = 0
+ }
+
+ var row pgx.Rows
+ var err error
+ if start < 0 {
+ // use MAX(id) instead of a concrete id, because start
+ // identifier was negative. Inidicates to read the most
+ // recent ids.
+ row, err = db.pool.Query(
+ db.ctx,
+ query,
+ limit,
+ "MAX("+TRANSFER_FIELD_NAME_ROW_ID+")",
+ )
+ } else {
+ row, err = db.pool.Query(
+ db.ctx,
+ query,
+ limit,
+ offset,
+ )
+ }
+
+ LogInfo("postgres", "query="+query)
+ if err != nil {
+ LogError("postgres", err)
+ if row != nil {
+ row.Close()
+ }
+ return nil, err
+ } else {
+
+ defer row.Close()
+
+ transfers, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Transfer])
+ if err != nil {
+ LogError("postgres", err)
+ return nil, err
+ }
+
+ return removeNulls(transfers), nil
+ }
+}
+
+// Sets up a a listener for the given channel.
+// Notifications will be sent through the out channel.
+func (db *C2ECPostgres) NewListener(
+ cn string,
+ out chan *Notification,
+) (func(context.Context) error, error) {
+
+ connectionString := PostgresConnectionString(&CONFIG.Database)
+ pgHost := os.Getenv("PGHOST")
+ if pgHost != "" {
+ LogInfo("postgres", "pghost was set")
+ connectionString = pgHost
+ }
+
+ cfg, err := pgx.ParseConfig(connectionString)
+ if err != nil {
+ return nil, err
+ }
+
+ listener := &pgxlisten.Listener{
+ Connect: func(ctx context.Context) (*pgx.Conn, error) {
+ LogInfo("postgres", "listener connecting to the database")
+ return pgx.ConnectConfig(ctx, cfg)
+ },
+ }
+
+ LogInfo("postgres", "handling notifications on channel="+cn)
+ listener.Handle(cn, pgxlisten.HandlerFunc(func(ctx context.Context, notification *pgconn.Notification, conn *pgx.Conn) error {
+ LogInfo("postgres", fmt.Sprintf("handling postgres notification. channel=%s", notification.Channel))
+ out <- &Notification{
+ Channel: notification.Channel,
+ Payload: notification.Payload,
+ }
+ return nil
+ }))
+
+ return listener.Listen, nil
+}
+
+func removeNulls[T any](l []*T) []*T {
+
+ withoutNulls := make([]*T, 0)
+ for _, e := range l {
+ if e != nil {
+ withoutNulls = append(withoutNulls, e)
+ }
+ }
+ return withoutNulls
+}
diff --git a/c2ec/db.go b/c2ec/db.go
@@ -19,12 +19,14 @@ const TERMINAL_FIELD_NAME_DESCRIPTION = "description"
const TERMINAL_FIELD_NAME_PROVIDER_ID = "provider_id"
const WITHDRAWAL_TABLE_NAME = "c2ec.withdrawal"
-const WITHDRAWAL_FIELD_NAME_ID = "withdrawal_id"
+const WITHDRAWAL_FIELD_NAME_ID = "withdrawal_row_id"
+const WITHDRAWAL_FIELD_NAME_RUID = "request_uid"
const WITHDRAWAL_FIELD_NAME_WOPID = "wopid"
const WITHDRAWAL_FIELD_NAME_RESPUBKEY = "reserve_pub_key"
const WITHDRAWAL_FIELD_NAME_TS = "registration_ts"
const WITHDRAWAL_FIELD_NAME_AMOUNT = "amount"
-const WITHDRAWAL_FIELD_NAME_FEES = "fees"
+const WITHDRAWAL_FIELD_NAME_SUGGESTED_AMOUNT = "suggested_amount"
+const WITHDRAWAL_FIELD_NAME_FEES = "terminal_fees"
const WITHDRAWAL_FIELD_NAME_STATUS = "withdrawal_status"
const WITHDRAWAL_FIELD_NAME_TERMINAL_ID = "terminal_id"
const WITHDRAWAL_FIELD_NAME_TRANSACTION_ID = "provider_transaction_id"
@@ -60,12 +62,14 @@ type Terminal struct {
}
type Withdrawal struct {
- WithdrawalId uint64 `db:"withdrawal_id"`
+ WithdrawalRowId uint64 `db:"withdrawal_row_id"`
+ RequestUid string `db:"request_uid"`
Wopid []byte `db:"wopid"`
ReservePubKey []byte `db:"reserve_pub_key"`
RegistrationTs int64 `db:"registration_ts"`
Amount *TalerAmountCurrency `db:"amount" scan:"follow"`
- Fees *TalerAmountCurrency `db:"fees" scan:"follow"`
+ SuggestedAmount *TalerAmountCurrency `db:"suggested_amount" scan:"follow"`
+ TerminalFees *TalerAmountCurrency `db:"terminal_fees" scan:"follow"`
WithdrawalStatus WithdrawalOperationStatus `db:"withdrawal_status"`
TerminalId *int64 `db:"terminal_id"`
ProviderTransactionId *string `db:"provider_transaction_id"`
@@ -101,13 +105,28 @@ type Notification struct {
// C2EC compliant database interface must implement
// in order to be bound to the c2ec API.
type C2ECDatabase interface {
- // Registers a wopid and reserve public key.
+ // A terminal sets up a withdrawal
+ // with this query.
// This initiates the withdrawal.
- RegisterWithdrawal(
+ SetupWithdrawal(
+ wopid []byte,
+ suggestedAmount Amount,
+ amount Amount,
+ providerTransactionId string,
+ terminalFees Amount,
+ requestUid string,
+ ) error
+
+ // Registers a reserve public key
+ // belonging to the respective wopid.
+ RegisterWithdrawalParameters(
wopid []byte,
resPubKey EddsaPublicKey,
) error
+ // Get the withdrawal associated with the given request uid.
+ GetWithdrawalByRequestUid(requestUid string) (*Withdrawal, error)
+
// Get the withdrawal associated with the given withdrawal identifier.
GetWithdrawalById(withdrawalId int) (*Withdrawal, error)
@@ -124,7 +143,6 @@ type C2ECDatabase interface {
wopid []byte,
providerTransactionId string,
terminalId int,
- amount Amount,
fees Amount,
) error
diff --git a/c2ec/db/0001-c2ec_schema.sql b/c2ec/db/0001-c2ec_schema.sql
@@ -75,12 +75,14 @@ COMMENT ON COLUMN terminal.provider_id
CREATE TABLE IF NOT EXISTS withdrawal (
- withdrawal_id INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ withdrawal_row_id INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ request_uid TEXT UNIQUE NOT NULL,
wopid BYTEA CHECK (LENGTH(wopid)=32) UNIQUE NOT NULL,
- reserve_pub_key BYTEA CHECK (LENGTH(reserve_pub_key)=32) NOT NULL,
+ reserve_pub_key BYTEA CHECK (LENGTH(reserve_pub_key)=32),
registration_ts INT8 NOT NULL,
amount taler_amount_currency,
- fees taler_amount_currency,
+ suggested_amount taler_amount_currency,
+ terminal_fees taler_amount_currency,
withdrawal_status withdrawal_operation_status NOT NULL DEFAULT 'pending',
terminal_id INT8 REFERENCES terminal(terminal_id),
provider_transaction_id TEXT,
@@ -90,7 +92,10 @@ CREATE TABLE IF NOT EXISTS withdrawal (
);
COMMENT ON TABLE withdrawal
IS 'Table representing withdrawal processes initiated by terminals';
-COMMENT ON COLUMN withdrawal.withdrawal_id
+COMMENT ON COLUMN withdrawal.request_uid
+ IS 'The request uid identifies each request and is stored to make the API interacting
+ with withdrawals idempotent.';
+COMMENT ON COLUMN withdrawal.withdrawal_row_id
IS 'The withdrawal id is used a technical id used by the wire gateway to sequentially select new transactions';
COMMENT ON COLUMN withdrawal.wopid
IS 'The wopid (withdrawal operation id) is a nonce generated by the terminal requesting a withdrawal.
@@ -101,8 +106,11 @@ COMMENT ON COLUMN withdrawal.registration_ts
IS 'Timestamp of when the withdrawal request was registered';
COMMENT ON COLUMN withdrawal.amount
IS 'Effective amount to be put into the reserve after completion';
-COMMENT ON COLUMN withdrawal.fees
- IS 'Fees associated with the withdrawal, including exchange and provider fees';
+COMMENT ON COLUMN withdrawal.suggested_amount
+ IS 'The suggested amount is given by the entity initializing the wihdrawal.
+ If the suggested amount is given, the wallet may still change the amount.';
+COMMENT ON COLUMN withdrawal.terminal_fees
+ IS 'Fees associated with the withdrawal but not related to the taler payment system.';
COMMENT ON COLUMN withdrawal.withdrawal_status
IS 'Status of the withdrawal process';
COMMENT ON COLUMN withdrawal.terminal_id
diff --git a/c2ec/db/drop.sql b/c2ec/db/drop.sql
@@ -3,4 +3,6 @@ BEGIN;
DROP SCHEMA IF EXISTS c2ec CASCADE;
+DROP SCHEMA IF EXISTS _v CASCADE;
+
COMMIT;
\ No newline at end of file
diff --git a/c2ec/db/proc-c2ec_payment_notification_listener.sql b/c2ec/db/proc-c2ec_payment_notification_listener.sql
@@ -15,10 +15,10 @@ BEGIN
ON t.provider_id = p.provider_id
LEFT JOIN c2ec.withdrawal AS w
ON t.terminal_id = NEW.terminal_id
- WHERE w.withdrawal_id = NEW.withdrawal_id;
+ WHERE w.withdrawal_row_id = NEW.withdrawal_row_id;
PERFORM pg_notify('payment_notification',
provider_name || '|' ||
- NEW.withdrawal_id || '|' ||
+ NEW.withdrawal_row_id || '|' ||
NEW.provider_transaction_id
);
RETURN NULL;
diff --git a/c2ec/db/proc-c2ec_retry_listener.sql b/c2ec/db/proc-c2ec_retry_listener.sql
@@ -8,7 +8,7 @@ SET search_path TO c2ec;
CREATE OR REPLACE FUNCTION emit_retry_notification()
RETURNS TRIGGER AS $$
BEGIN
- PERFORM pg_notify('retry', '' || NEW.withdrawal_id);
+ PERFORM pg_notify('retry', '' || NEW.withdrawal_row_id);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
diff --git a/c2ec/db/proc-c2ec_status_listener.sql b/c2ec/db/proc-c2ec_status_listener.sql
@@ -28,13 +28,13 @@ COMMENT ON TRIGGER c2ec_withdrawal_created ON withdrawal
IS 'After creation of the withdrawal entry a notification shall
be triggered using this trigger.';
-CREATE OR REPLACE TRIGGER c2ec_withdrawal_changed
+CREATE OR REPLACE TRIGGER c2ec_withdrawal_status_changed
AFTER UPDATE OF withdrawal_status
ON withdrawal
FOR EACH ROW
WHEN (OLD.withdrawal_status IS DISTINCT FROM NEW.withdrawal_status)
EXECUTE FUNCTION emit_withdrawal_status();
-COMMENT ON TRIGGER c2ec_withdrawal_changed ON withdrawal
+COMMENT ON TRIGGER c2ec_withdrawal_status_changed ON withdrawal
IS 'After the update of the status (only the status is of interest)
a notification shall be triggered using this trigger.';
diff --git a/c2ec/db/procedures.sql b/c2ec/db/procedures.sql
@@ -1,162 +0,0 @@
-BEGIN;
-
-SELECT _v.register_patch('proc-c2ec-status-listener', ARRAY['0001-c2ec-schema'], NULL);
-
-SET search_path TO c2ec;
-
--- to create a function, the user needs USAGE privilege on arguments and return types
-CREATE OR REPLACE FUNCTION emit_withdrawal_status()
-RETURNS TRIGGER AS $$
-BEGIN
- PERFORM pg_notify('w_' || encode(NEW.wopid::BYTEA, 'base64'), NEW.withdrawal_status::TEXT);
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-COMMENT ON FUNCTION emit_withdrawal_status
- IS 'The function encodes the wopid in base64 and
- sends a notification on the channel "w_{wopid}"
- with the status in the payload.';
-
--- for creating a trigger the user must have TRIGGER pivilege on the table.
--- to execute the trigger, the user needs EXECUTE privilege on the trigger function.
-CREATE OR REPLACE TRIGGER c2ec_withdrawal_created
- AFTER INSERT
- ON withdrawal
- FOR EACH ROW
- EXECUTE FUNCTION emit_withdrawal_status();
-COMMENT ON TRIGGER c2ec_withdrawal_created ON withdrawal
- IS 'After creation of the withdrawal entry a notification shall
- be triggered using this trigger.';
-
-CREATE OR REPLACE TRIGGER c2ec_withdrawal_changed
- AFTER UPDATE OF withdrawal_status
- ON withdrawal
- FOR EACH ROW
- WHEN (OLD.withdrawal_status IS DISTINCT FROM NEW.withdrawal_status)
- EXECUTE FUNCTION emit_withdrawal_status();
-COMMENT ON TRIGGER c2ec_withdrawal_changed ON withdrawal
- IS 'After the update of the status (only the status is of interest)
- a notification shall be triggered using this trigger.';
-
-COMMIT;
-
-BEGIN;
-
-SELECT _v.register_patch('proc-c2ec-retry-listener', ARRAY['0001-c2ec-schema'], NULL);
-
-SET search_path TO c2ec;
-
--- to create a function, the user needs USAGE privilege on arguments and return types
-CREATE OR REPLACE FUNCTION emit_retry_notification()
-RETURNS TRIGGER AS $$
-BEGIN
- PERFORM pg_notify('retry', '' || NEW.withdrawal_id);
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-COMMENT ON FUNCTION emit_retry_notification
- IS 'The function emits the id of the withdrawal for which the last
- retry timestamp was updated. This shall trigger a retry operation.
- How many retries are attempted is specified and handled by the application';
-
--- for creating a trigger the user must have TRIGGER pivilege on the table.
--- to execute the trigger, the user needs EXECUTE privilege on the trigger function.
-CREATE OR REPLACE TRIGGER c2ec_retry_notify
- AFTER UPDATE OF last_retry_ts
- ON withdrawal
- FOR EACH ROW
- EXECUTE FUNCTION emit_retry_notification();
-COMMENT ON TRIGGER c2ec_retry_notify ON withdrawal
- IS 'After setting the last retry timestamp on the withdrawal,
- trigger the retry mechanism through the respective mechanism.';
-
-COMMIT;
-
-BEGIN;
-
-SELECT _v.register_patch('proc-c2ec-payment-notification-listener', ARRAY['0001-c2ec-schema'], NULL);
-
-SET search_path TO c2ec;
-
--- to create a function, the user needs USAGE privilege on arguments and return types
-CREATE OR REPLACE FUNCTION emit_payment_notification()
-RETURNS TRIGGER AS $$
-DECLARE
- provider_name TEXT;
-BEGIN
- SELECT p.name INTO provider_name FROM c2ec.provider AS p
- LEFT JOIN c2ec.terminal AS t
- ON t.provider_id = p.provider_id
- LEFT JOIN c2ec.withdrawal AS w
- ON t.terminal_id = w.terminal_id
- WHERE w.withdrawal_id = NEW.withdrawal_id;
- PERFORM pg_notify('payment_notification',
- provider_name || '|' ||
- NEW.withdrawal_id || '|' ||
- NEW.provider_transaction_id
- );
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-COMMENT ON FUNCTION emit_payment_notification
- IS 'The function emits the name of the provider, row id of the withdrawal
- and the provider_transaction_id, on the channel "payment_notification".
- The format of the payload is as follows:
- "{PROVIDER_NAME}|{WITHDRAWAL_ID}|{PROVIDER_TRANSACTION_ID}". The subscriber
- shall decide which attestation process to use, based on the name of
- the provider.';
-
--- for creating a trigger the user must have TRIGGER pivilege on the table.
--- to execute the trigger, the user needs EXECUTE privilege on the trigger function.
-CREATE OR REPLACE TRIGGER c2ec_on_payment_notify
- AFTER UPDATE OF provider_transaction_id
- ON withdrawal
- FOR EACH ROW
- WHEN (NEW.provider_transaction_id IS NOT NULL)
- EXECUTE FUNCTION emit_payment_notification();
-COMMENT ON TRIGGER c2ec_on_payment_notify ON withdrawal
- IS 'After setting the provider transaction id following a payment notification,
- trigger the emit to the respective channel.';
-
-COMMIT;
-
-BEGIN;
-
-SELECT _v.register_patch('proc-c2ec-transfer-listener', ARRAY['0001-c2ec-schema'], NULL);
-
-SET search_path TO c2ec;
-
--- to create a function, the user needs USAGE privilege on arguments and return types
-CREATE OR REPLACE FUNCTION emit_transfer_notification()
-RETURNS TRIGGER AS $$
-BEGIN
- PERFORM pg_notify('transfer', encode(NEW.request_uid::BYTEA, 'base64'));
- RETURN NULL;
-END;
-$$ LANGUAGE plpgsql;
-COMMENT ON FUNCTION emit_transfer_notification
- IS 'The function emits the request_uid of a transfer which shall trigger a transfer
- by the receiver of the notification.';
-
--- for creating a trigger the user must have TRIGGER pivilege on the table.
--- to execute the trigger, the user needs EXECUTE privilege on the trigger function.
-CREATE OR REPLACE TRIGGER c2ec_on_transfer_failed
- AFTER INSERT
- ON transfer
- FOR EACH ROW
- EXECUTE FUNCTION emit_transfer_notification();
-COMMENT ON TRIGGER c2ec_on_transfer_failed ON transfer
- IS 'When a new transfer is set, the transfer shall executed. This trigger aims to
- trigger this operation at its listeners.';
-
-CREATE OR REPLACE TRIGGER c2ec_on_transfer_failed
- AFTER UPDATE OF retries
- ON transfer
- FOR EACH ROW
- WHEN (NEW.retries > 0)
- EXECUTE FUNCTION emit_transfer_notification();
-COMMENT ON TRIGGER c2ec_on_transfer_failed ON transfer
- IS 'When retries is (re)set this will trigger the notification of the listening
- receivers, which will further process the transfer';
-
-COMMIT;
-\ No newline at end of file
diff --git a/c2ec/db/procedures.sql.in b/c2ec/db/procedures.sql.in
@@ -1 +0,0 @@
-Note: cat into procedures.sql
-\ No newline at end of file
diff --git a/c2ec/db/test_c2ec_test.sql b/c2ec/db/test_c2ec_simulation.sql
diff --git a/c2ec/db/test_c2ec_test_rollback.sql b/c2ec/db/test_c2ec_simulation_rollback.sql
diff --git a/c2ec/encoding.go b/c2ec/encoding.go
@@ -9,17 +9,11 @@ import (
func talerBinaryEncode(byts []byte) string {
return encodeCrock(byts)
- //return talerBase32Encoding().EncodeToString(byts)
}
func talerBinaryDecode(str string) ([]byte, error) {
return decodeCrock(str)
- // decoded, err := talerBase32Encoding().DecodeString(strings.ToUpper(str))
- // if err != nil {
- // return nil, err
- // }
- // return decoded, nil
}
func ParseWopid(wopid string) ([]byte, error) {
diff --git a/c2ec/http-util.go b/c2ec/http-util.go
@@ -20,31 +20,6 @@ const HTTP_METHOD_NOT_ALLOWED = 405
const HTTP_CONFLICT = 409
const HTTP_INTERNAL_SERVER_ERROR = 500
-const TALER_URI_PROBLEM_PREFIX = "taler://problem"
-
-type RFC9457Problem struct {
- TypeUri string `json:"type"`
- Title string `json:"title"`
- Detail string `json:"detail"`
- Instance string `json:"instance"`
-}
-
-// Writes a problem as specified by RFC 9457 to
-// the response. The problem is always serialized
-// as JSON.
-func WriteProblem(res http.ResponseWriter, status int, problem *RFC9457Problem) error {
-
- c := NewJsonCodec[RFC9457Problem]()
- problm, err := c.EncodeToBytes(problem)
- if err != nil {
- return err
- }
-
- res.WriteHeader(status)
- res.Write(problm)
- return nil
-}
-
// Function reads and validates a param of a request in the
// correct format according to the transform function supplied.
// When the transform fails, it returns false as second return
@@ -61,15 +36,7 @@ func AcceptOptionalParamOrWriteResponse[T any](
ptr, err := OptionalQueryParamOrError(name, transform, req)
if err != nil {
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_REQUEST_QUERY_PARAMETER",
- Title: "invalid request query parameter",
- Detail: "the withdrawal status request parameter '" + name + "' is malformed (error: " + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
+ res.WriteHeader(HTTP_BAD_REQUEST)
return nil, false
}
@@ -82,15 +49,7 @@ func AcceptOptionalParamOrWriteResponse[T any](
assertedObj, ok := any(obj).(T)
if !ok {
// this should generally not happen (due to the implementation)
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_FATAL_ERROR",
- Title: "Fatal Error",
- Detail: "Something strange happened. Probably not your fault.",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
+ res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
return nil, false
}
return &assertedObj, true
@@ -178,6 +137,9 @@ func HttpGet[T any](
if codec == nil {
return nil, res.StatusCode, err
} else {
+ if res.StatusCode > 299 {
+ return nil, res.StatusCode, nil
+ }
resBody, err := codec.Decode(res.Body)
return resBody, res.StatusCode, err
}
diff --git a/c2ec/install/build_app.sh b/c2ec/install/build_app.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+if [ "$#" -ne 1 ]; then
+ echo "Usage: $0 <source-root>"
+ exit 1
+fi
+
+REPO_ROOT=$1
+
+build_c2ec() {
+ go build $REPO_ROOT
+ if [ $? -ne 0 ]; then
+ echo "Failed to build C2EC using Go"
+ exit 1
+ fi
+}
+
+build_c2ec
+if [ $? -ne 0 ]; then
+ exit 1
+fi
diff --git a/c2ec/install/setup_db.sh b/c2ec/install/setup_db.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+
+if [ "$#" -ne 4 ]; then
+ echo "Usage: $0 <db-username> <db-password> <db-name> <source-root>"
+ exit 1
+fi
+
+DB_USERNAME=$1
+DB_PASSWORD=$2
+DB_NAME=$3
+REPO_ROOT=$4
+
+SQL_SCRIPTS=(
+ "$REPO_ROOT/db/versioning.sql"
+ "$REPO_ROOT/db/0001-c2ec_schema.sql"
+ "$REPO_ROOT/db/proc-c2ec_status_listener.sql"
+ "$REPO_ROOT/db/proc-c2ec_payment_notification_listener.sql"
+ "$REPO_ROOT/db/proc-c2ec_retry_listener.sql"
+ "$REPO_ROOT/db/proc-c2ec_transfer_listener.sql"
+)
+
+execute_sql_scripts() {
+ for script in "${SQL_SCRIPTS[@]}"; do
+ echo "Executing SQL script: $script"
+ PGPASSWORD=$DB_PASSWORD psql -U $DB_USERNAME -d $DB_NAME -f $script
+ if [ $? -ne 0 ]; then
+ echo "Failed to execute SQL script: $script"
+ exit 1
+ fi
+ done
+ PGPASSWORD=""
+}
+
+execute_sql_scripts
+if [ $? -ne 0 ]; then
+ exit 1
+fi
diff --git a/c2ec/install/start.sh b/c2ec/install/start.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+if [ "$#" -ne 1 ]; then
+ echo "Usage: $0 <config-file-path>"
+ exit 1
+fi
+CONFIG_FILE=$1
+
+build_and_run_go_app() {
+ go build -o app
+ if [ $? -ne 0 ]; then
+ echo "Failed to build Go application"
+ exit 1
+ fi
+
+ ./app "$CONFIG_FILE"
+ if [ $? -ne 0 ]; then
+ echo "Failed to run Go application"
+ exit 1
+ fi
+}
+
+build_and_run_go_app
+
+rm -f app
+\ No newline at end of file
diff --git a/c2ec/install/wipe_db.sh b/c2ec/install/wipe_db.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+if [ "$#" -ne 3 ]; then
+ echo "Usage: $0 <db-username> <db-password> <db-name>"
+ exit 1
+fi
+
+DB_USERNAME=$1
+DB_PASSWORD=$2
+DB_NAME=$3
+
+SQL_SCRIPTS=(
+ "./../db/drop.sql"
+)
+
+execute_sql_scripts() {
+ for script in "${SQL_SCRIPTS[@]}"; do
+ PGPASSWORD=$DB_PASSWORD psql -U $DB_USERNAME -d $DB_NAME -f "$script"
+ if [ $? -ne 0 ]; then
+ echo "Failed to execute SQL script: $script"
+ exit 1
+ fi
+ done
+ PGPASSWORD=""
+}
+
+execute_sql_scripts
diff --git a/c2ec/main.go b/c2ec/main.go
@@ -250,6 +250,11 @@ func setupBankIntegrationRoutes(router *http.ServeMux) {
)
router.HandleFunc(
+ POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN,
+ handleParameterRegistration,
+ )
+
+ router.HandleFunc(
POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_ABORTION_PATTERN,
handleWithdrawalAbort,
)
@@ -292,11 +297,16 @@ func setupTerminalRoutes(router *http.ServeMux) {
router.HandleFunc(
POST+TERMINAL_API_REGISTER_WITHDRAWAL,
- handleWithdrawalRegistration,
+ handleWithdrawalSetup,
)
router.HandleFunc(
POST+TERMINAL_API_CHECK_WITHDRAWAL,
handleWithdrawalCheck,
)
+
+ router.HandleFunc(
+ GET+TERMINAL_API_WITHDRAWAL_STATUS,
+ handleWithdrawalStatus,
+ )
}
diff --git a/c2ec/postgres.go b/c2ec/postgres.go
@@ -1,720 +0,0 @@
-package main
-
-import (
- "context"
- "errors"
- "fmt"
- "math"
- "os"
- "strconv"
- "time"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgconn"
- "github.com/jackc/pgx/v5/pgxpool"
- "github.com/jackc/pgxlisten"
-)
-
-const PS_INSERT_WITHDRAWAL = "INSERT INTO " + WITHDRAWAL_TABLE_NAME + " (" +
- WITHDRAWAL_FIELD_NAME_WOPID + "," +
- WITHDRAWAL_FIELD_NAME_RESPUBKEY + "," +
- WITHDRAWAL_FIELD_NAME_STATUS + "," +
- WITHDRAWAL_FIELD_NAME_TS + ")" +
- " VALUES ($1, $2, $3, $4);"
-
-const PS_GET_UNCONFIRMED_WITHDRAWALS = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
- " WHERE " + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + " IS NOT NULL" +
- " AND " + WITHDRAWAL_FIELD_NAME_STATUS + " = '" + string(SELECTED) + "'"
-
-const PS_PAYMENT_NOTIFICATION = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
- WITHDRAWAL_FIELD_NAME_AMOUNT + "," + WITHDRAWAL_FIELD_NAME_FEES + "," +
- WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "," + WITHDRAWAL_FIELD_NAME_TERMINAL_ID + ")" +
- " = (($1, $2, $3),($4, $5, $6),$7, $8)" +
- " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$9"
-
-const PS_FINALISE_PAYMENT = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" +
- WITHDRAWAL_FIELD_NAME_STATUS + "," +
- WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF + ")" +
- " = ($1, $2)" +
- " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$3"
-
-const PS_SET_LAST_RETRY = "UPDATE " + WITHDRAWAL_TABLE_NAME +
- " SET " + WITHDRAWAL_FIELD_NAME_LAST_RETRY + "=$1" +
- " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$2"
-
-const PS_SET_RETRY_COUNTER = "UPDATE " + WITHDRAWAL_TABLE_NAME +
- " SET " + WITHDRAWAL_FIELD_NAME_RETRY_COUNTER + "=$1" +
- " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$2"
-
-const PS_CONFIRMED_TRANSACTIONS_ASC = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
- " WHERE " + WITHDRAWAL_FIELD_NAME_STATUS + "='" + string(CONFIRMED) + "'" +
- " ORDER BY " + WITHDRAWAL_FIELD_NAME_ID + " ASC" +
- " LIMIT $1" +
- " OFFSET $2"
-
-const PS_CONFIRMED_TRANSACTIONS_DESC = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
- " WHERE " + WITHDRAWAL_FIELD_NAME_STATUS + "='" + string(CONFIRMED) + "'" +
- " ORDER BY " + WITHDRAWAL_FIELD_NAME_ID + " DESC" +
- " LIMIT $1" +
- " OFFSET $2"
-
-const PS_GET_WITHDRAWAL_BY_ID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
- " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$1"
-
-const PS_GET_WITHDRAWAL_BY_WOPID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
- " WHERE " + WITHDRAWAL_FIELD_NAME_WOPID + "=$1"
-
-const PS_GET_WITHDRAWAL_BY_PTID = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME +
- " WHERE " + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + "=$1"
-
-const PS_GET_PROVIDER_BY_TERMINAL = "SELECT * FROM " + PROVIDER_TABLE_NAME +
- " WHERE " + PROVIDER_FIELD_NAME_ID +
- " = (SELECT " + TERMINAL_FIELD_NAME_PROVIDER_ID + " FROM " + TERMINAL_TABLE_NAME +
- " WHERE " + TERMINAL_FIELD_NAME_ID + "=$1)"
-
-const PS_GET_PROVIDER_BY_NAME = "SELECT * FROM " + PROVIDER_TABLE_NAME +
- " WHERE " + PROVIDER_FIELD_NAME_NAME + "=$1"
-
-const PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE = "SELECT * FROM " + PROVIDER_TABLE_NAME +
- " WHERE " + PROVIDER_FIELD_NAME_PAYTO_TARGET_TYPE + "=$1"
-
-const PS_GET_TERMINAL_BY_ID = "SELECT * FROM " + TERMINAL_TABLE_NAME +
- " WHERE " + TERMINAL_FIELD_NAME_ID + "=$1"
-
-const PS_GET_TRANSFER_BY_ID = "SELECT * FROM " + TRANSFER_TABLE_NAME +
- " WHERE " + TRANSFER_FIELD_NAME_ID + "=$1"
-
-const PS_ADD_TRANSFER = "INSERT INTO " + TRANSFER_TABLE_NAME +
- " (" + TRANSFER_FIELD_NAME_ID + ", " + TRANSFER_FIELD_NAME_AMOUNT + ", " +
- TRANSFER_FIELD_NAME_EXCHANGE_BASE_URL + ", " + TRANSFER_FIELD_NAME_WTID + ", " +
- TRANSFER_FIELD_NAME_CREDIT_ACCOUNT + ") VALUES ($1, $2, $3, $4, $5)"
-
-const PS_UPDATE_TRANSFER = "UPDATE " + TRANSFER_TABLE_NAME + " SET (" +
- TRANSFER_FIELD_NAME_TS + ", " + TRANSFER_FIELD_NAME_STATUS + ", " +
- TRANSFER_FIELD_NAME_RETRIES + ") VALUES ($1,$2,$3) WHERE " +
- TRANSFER_FIELD_NAME_ID + "=$4"
-
-const PS_GET_TRANSFERS_ASC = "SELECT * FROM " + TRANSFER_TABLE_NAME +
- " ORDER BY " + TRANSFER_FIELD_NAME_ROW_ID + " ASC" +
- " LIMIT $1" +
- " OFFSET $2"
-
-const PS_GET_TRANSFERS_DESC = "SELECT * FROM " + TRANSFER_TABLE_NAME +
- " ORDER BY " + TRANSFER_FIELD_NAME_ROW_ID + " DESC" +
- " LIMIT $1" +
- " OFFSET $2"
-
-// Postgres implementation of the C2ECDatabase
-type C2ECPostgres struct {
- C2ECDatabase
-
- ctx context.Context
- pool *pgxpool.Pool
-}
-
-func PostgresConnectionString(cfg *C2ECDatabseConfig) string {
- return fmt.Sprintf(
- "postgres://%s:%s@%s:%d/%s",
- cfg.Username,
- cfg.Password,
- cfg.Host,
- cfg.Port,
- cfg.Database,
- )
-}
-
-func NewC2ECPostgres(cfg *C2ECDatabseConfig) (*C2ECPostgres, error) {
-
- ctx := context.Background()
- db := new(C2ECPostgres)
-
- connectionString := PostgresConnectionString(cfg)
- pgHost := os.Getenv("PGHOST")
- if pgHost != "" {
- LogInfo("postgres", "pghost was set")
- connectionString = pgHost
- }
-
- dbConnCfg, err := pgxpool.ParseConfig(connectionString)
- if err != nil {
- panic(err.Error())
- }
- dbConnCfg.AfterConnect = db.registerCustomTypesHook
- db.pool, err = pgxpool.NewWithConfig(context.Background(), dbConnCfg)
- if err != nil {
- panic(err.Error())
- }
-
- db.ctx = ctx
-
- return db, nil
-}
-
-func (db *C2ECPostgres) registerCustomTypesHook(ctx context.Context, conn *pgx.Conn) error {
-
- t, err := conn.LoadType(ctx, "c2ec.taler_amount_currency")
- if err != nil {
- return err
- }
-
- conn.TypeMap().RegisterType(t)
- return nil
-}
-
-func (db *C2ECPostgres) RegisterWithdrawal(
- wopid []byte,
- resPubKey EddsaPublicKey,
-) error {
-
- resPubKeyBytes, err := ParseEddsaPubKey(resPubKey)
- if err != nil {
- return err
- }
-
- ts := time.Now()
- res, err := db.pool.Exec(
- db.ctx,
- PS_INSERT_WITHDRAWAL,
- wopid,
- resPubKeyBytes,
- SELECTED,
- ts.Unix(),
- )
- if err != nil {
- LogError("postgres", err)
- return err
- }
- LogInfo("postgres", "query="+PS_INSERT_WITHDRAWAL)
- LogInfo("postgres", "registered withdrawal successfully. affected rows="+strconv.Itoa(int(res.RowsAffected())))
- return nil
-}
-
-func (db *C2ECPostgres) GetWithdrawalById(withdrawalId int) (*Withdrawal, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_WITHDRAWAL_BY_ID,
- withdrawalId,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
- LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_ID)
- return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal])
- }
-}
-
-func (db *C2ECPostgres) GetWithdrawalByWopid(wopid []byte) (*Withdrawal, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_WITHDRAWAL_BY_WOPID,
- wopid,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
- LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_WOPID)
- return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal])
- }
-}
-
-func (db *C2ECPostgres) GetWithdrawalByProviderTransactionId(tid string) (*Withdrawal, error) {
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_WITHDRAWAL_BY_PTID,
- tid,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
- LogInfo("postgres", "query="+PS_GET_WITHDRAWAL_BY_PTID)
- return pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Withdrawal])
- }
-}
-
-func (db *C2ECPostgres) NotifyPayment(
- wopid []byte,
- providerTransactionId string,
- terminalId int,
- amount Amount,
- fees Amount,
-) error {
-
- res, err := db.pool.Exec(
- db.ctx,
- PS_PAYMENT_NOTIFICATION,
- amount.Value,
- amount.Fraction,
- amount.Currency,
- fees.Value,
- fees.Fraction,
- fees.Currency,
- providerTransactionId,
- terminalId,
- wopid,
- )
- if err != nil {
- LogError("postgres", err)
- return err
- }
- LogInfo("postgres", "query="+PS_PAYMENT_NOTIFICATION+", affected rows="+strconv.Itoa(int(res.RowsAffected())))
- return nil
-}
-
-func (db *C2ECPostgres) GetAttestableWithdrawals() ([]*Withdrawal, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_UNCONFIRMED_WITHDRAWALS,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- LogInfo("postgres", "query="+PS_GET_UNCONFIRMED_WITHDRAWALS)
- return removeNulls(withdrawals), nil
- }
-}
-
-func (db *C2ECPostgres) FinaliseWithdrawal(
- withdrawalId int,
- confirmOrAbort WithdrawalOperationStatus,
- completionProof []byte,
-) error {
-
- if confirmOrAbort != CONFIRMED && confirmOrAbort != ABORTED {
- return errors.New("can only finalise payment when new status is either confirmed or aborted")
- }
-
- _, err := db.pool.Exec(
- db.ctx,
- PS_FINALISE_PAYMENT,
- confirmOrAbort,
- completionProof,
- withdrawalId,
- )
- if err != nil {
- LogError("postgres", err)
- return err
- }
- LogInfo("postgres", "query="+PS_FINALISE_PAYMENT)
- return nil
-}
-
-func (db *C2ECPostgres) SetLastRetry(withdrawalId int, lastRetryTsUnix int64) error {
-
- _, err := db.pool.Exec(
- db.ctx,
- PS_SET_LAST_RETRY,
- lastRetryTsUnix,
- withdrawalId,
- )
- if err != nil {
- LogError("postgres", err)
- return err
- }
- LogInfo("postgres", "query="+PS_SET_LAST_RETRY)
- return nil
-}
-
-func (db *C2ECPostgres) SetRetryCounter(withdrawalId int, retryCounter int) error {
-
- _, err := db.pool.Exec(
- db.ctx,
- PS_SET_RETRY_COUNTER,
- retryCounter,
- withdrawalId,
- )
- if err != nil {
- LogError("postgres", err)
- return err
- }
- LogInfo("postgres", "query="+PS_SET_RETRY_COUNTER)
- return nil
-}
-
-// The query at the postgres database works as specified by the
-// wire gateway api.
-func (db *C2ECPostgres) GetConfirmedWithdrawals(start int, delta int) ([]*Withdrawal, error) {
-
- query := PS_CONFIRMED_TRANSACTIONS_ASC
- if delta < 0 {
- query = PS_CONFIRMED_TRANSACTIONS_DESC
- }
-
- limit := math.Abs(float64(delta))
- offset := start
- if delta < 0 {
- offset = start - int(limit)
- }
- if offset < 0 {
- offset = 0
- }
-
- var row pgx.Rows
- var err error
- if start < 0 {
- // use MAX(id) instead of a concrete id, because start
- // identifier was negative. Inidicates to read the most
- // recent ids.
- row, err = db.pool.Query(
- db.ctx,
- query,
- limit,
- "MAX("+WITHDRAWAL_FIELD_NAME_ID+")",
- )
- } else {
- row, err = db.pool.Query(
- db.ctx,
- query,
- limit,
- offset,
- )
- }
-
- LogInfo("postgres", "query="+query)
- if err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- return removeNulls(withdrawals), nil
- }
-}
-
-func (db *C2ECPostgres) GetProviderByTerminal(terminalId int) (*Provider, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_PROVIDER_BY_TERMINAL,
- terminalId,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_TERMINAL)
- return provider, nil
- }
-}
-
-func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*Provider, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_PROVIDER_BY_NAME,
- name,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_NAME)
- return provider, nil
- }
-}
-
-func (db *C2ECPostgres) GetTerminalProviderByPaytoTargetType(paytoTargetType string) (*Provider, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE,
- paytoTargetType,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- provider, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Provider])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- LogInfo("postgres", "query="+PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE)
- return provider, nil
- }
-}
-
-func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_TERMINAL_BY_ID,
- id,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- terminal, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Terminal])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- LogInfo("postgres", "query="+PS_GET_TERMINAL_BY_ID)
- return terminal, nil
- }
-}
-
-func (db *C2ECPostgres) GetTransferById(requestUid []byte) (*Transfer, error) {
-
- if row, err := db.pool.Query(
- db.ctx,
- PS_GET_TRANSFER_BY_ID,
- requestUid,
- ); err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- transfer, err := pgx.CollectExactlyOneRow(row, pgx.RowToAddrOfStructByName[Transfer])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- LogInfo("postgres", "query="+PS_GET_TRANSFER_BY_ID)
- return transfer, nil
- }
-
-}
-
-func (db *C2ECPostgres) AddTransfer(
- requestUid []byte,
- amount *Amount,
- exchangeBaseUrl string,
- wtid string,
- credit_account string,
-) error {
-
- dbAmount := TalerAmountCurrency{
- Val: int64(amount.Value),
- Frac: int32(amount.Fraction),
- Curr: amount.Currency,
- }
-
- res, err := db.pool.Query(
- db.ctx,
- PS_ADD_TRANSFER,
- requestUid,
- dbAmount,
- exchangeBaseUrl,
- wtid,
- credit_account,
- )
- if err != nil {
- LogError("postgres", err)
- return err
- }
- res.Close()
- LogInfo("postgres", "query="+PS_ADD_TRANSFER)
- return nil
-}
-
-func (db *C2ECPostgres) UpdateTransfer(
- requestUid []byte,
- timestamp int64,
- status int16,
- retries int16,
-) error {
-
- _, err := db.pool.Exec(
- db.ctx,
- PS_UPDATE_TRANSFER,
- timestamp,
- status,
- retries,
- requestUid,
- )
- if err != nil {
- LogError("postgres", err)
- return err
- }
- LogInfo("postgres", "query="+PS_UPDATE_TRANSFER)
- return nil
-}
-
-func (db *C2ECPostgres) GetTransfers(start int, delta int) ([]*Transfer, error) {
-
- query := PS_GET_TRANSFERS_ASC
- if delta < 0 {
- query = PS_GET_TRANSFERS_DESC
- }
-
- limit := math.Abs(float64(delta))
- offset := start
- if delta < 0 {
- offset = start - int(limit)
- }
- if offset < 0 {
- offset = 0
- }
-
- var row pgx.Rows
- var err error
- if start < 0 {
- // use MAX(id) instead of a concrete id, because start
- // identifier was negative. Inidicates to read the most
- // recent ids.
- row, err = db.pool.Query(
- db.ctx,
- query,
- limit,
- "MAX("+TRANSFER_FIELD_NAME_ROW_ID+")",
- )
- } else {
- row, err = db.pool.Query(
- db.ctx,
- query,
- limit,
- offset,
- )
- }
-
- LogInfo("postgres", "query="+query)
- if err != nil {
- LogError("postgres", err)
- if row != nil {
- row.Close()
- }
- return nil, err
- } else {
-
- defer row.Close()
-
- transfers, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Transfer])
- if err != nil {
- LogError("postgres", err)
- return nil, err
- }
-
- return removeNulls(transfers), nil
- }
-}
-
-// Sets up a a listener for the given channel.
-// Notifications will be sent through the out channel.
-func (db *C2ECPostgres) NewListener(
- cn string,
- out chan *Notification,
-) (func(context.Context) error, error) {
-
- connectionString := PostgresConnectionString(&CONFIG.Database)
- pgHost := os.Getenv("PGHOST")
- if pgHost != "" {
- LogInfo("postgres", "pghost was set")
- connectionString = pgHost
- }
-
- cfg, err := pgx.ParseConfig(connectionString)
- if err != nil {
- return nil, err
- }
-
- listener := &pgxlisten.Listener{
- Connect: func(ctx context.Context) (*pgx.Conn, error) {
- LogInfo("postgres", "listener connecting to the database")
- return pgx.ConnectConfig(ctx, cfg)
- },
- }
-
- LogInfo("postgres", "handling notifications on channel="+cn)
- listener.Handle(cn, pgxlisten.HandlerFunc(func(ctx context.Context, notification *pgconn.Notification, conn *pgx.Conn) error {
- LogInfo("postgres", fmt.Sprintf("handling postgres notification. channel=%s", notification.Channel))
- out <- &Notification{
- Channel: notification.Channel,
- Payload: notification.Payload,
- }
- return nil
- }))
-
- return listener.Listen, nil
-}
-
-func removeNulls[T any](l []*T) []*T {
-
- withoutNulls := make([]*T, 0)
- for _, e := range l {
- if e != nil {
- withoutNulls = append(withoutNulls, e)
- }
- }
- return withoutNulls
-}
diff --git a/c2ec/proc-attestor.go b/c2ec/proc-attestor.go
@@ -0,0 +1,153 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE = 10
+const PS_PAYMENT_NOTIFICATION_CHANNEL = "payment_notification"
+
+// Sets up and runs an attestor in the background. This must be called at startup.
+func RunAttestor(
+ ctx context.Context,
+ errs chan error,
+) {
+
+ go RunListener(
+ ctx,
+ PS_PAYMENT_NOTIFICATION_CHANNEL,
+ attestationCallback,
+ make(chan *Notification, PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE),
+ errs,
+ )
+}
+
+func attestationCallback(notification *Notification, errs chan error) {
+
+ LogInfo("attestor", fmt.Sprintf("retrieved information on channel=%s with payload=%s", notification.Channel, notification.Payload))
+
+ // The payload is formatted like: "{PROVIDER_NAME}|{WITHDRAWAL_ID}|{PROVIDER_TRANSACTION_ID}"
+ // the validation is strict. This means, that the dispatcher emits an error
+ // and returns, if a property is malformed.
+ payload := strings.Split(notification.Payload, "|")
+ if len(payload) != 3 {
+ errs <- errors.New("malformed notification payload: " + notification.Payload)
+ return
+ }
+
+ providerName := payload[0]
+ if providerName == "" {
+ errs <- errors.New("the provider of the payment is not specified")
+ return
+ }
+ withdrawalRowId, err := strconv.Atoi(payload[1])
+ if err != nil {
+ errs <- errors.New("malformed withdrawal_row_id: " + err.Error())
+ return
+ }
+ providerTransactionId := payload[2]
+
+ client := PROVIDER_CLIENTS[providerName]
+ if client == nil {
+ errs <- errors.New("no provider client registered for provider " + providerName)
+ }
+
+ transaction, err := client.GetTransaction(providerTransactionId)
+ if err != nil {
+ LogError("attestor", err)
+ prepareRetryOrAbort(withdrawalRowId, errs)
+ return
+ }
+
+ finaliseOrSetRetry(
+ transaction,
+ withdrawalRowId,
+ errs,
+ )
+}
+
+func finaliseOrSetRetry(
+ transaction ProviderTransaction,
+ withdrawalRowId int,
+ errs chan error,
+) {
+
+ if transaction == nil {
+ err := errors.New("transaction was nil. will set retry or abort")
+ LogError("attestor", err)
+ errs <- err
+ prepareRetryOrAbort(withdrawalRowId, errs)
+ return
+ }
+
+ completionProof := transaction.Bytes()
+ if len(completionProof) > 0 {
+ // only allow finalization operation, when the completion
+ // proof of the transaction could be retrieved
+ if transaction.AllowWithdrawal() {
+
+ err := DB.FinaliseWithdrawal(withdrawalRowId, CONFIRMED, completionProof)
+ if err != nil {
+ LogError("attestor", err)
+ prepareRetryOrAbort(withdrawalRowId, errs)
+ }
+ } else {
+ // when the received transaction is not allowed, we first check if the
+ // transaction is in a final state which will not allow the withdrawal
+ // and therefore the operation can be aborted, without further retries.
+ if transaction.AbortWithdrawal() {
+ err := DB.FinaliseWithdrawal(withdrawalRowId, ABORTED, completionProof)
+ if err != nil {
+ LogError("attestor", err)
+ prepareRetryOrAbort(withdrawalRowId, errs)
+ return
+ }
+ }
+ prepareRetryOrAbort(withdrawalRowId, errs)
+ }
+ return
+ }
+ // when the transaction proof was not present (empty proof), retry.
+ prepareRetryOrAbort(withdrawalRowId, errs)
+}
+
+// Checks wether the maximal amount of retries was already
+// reached and the withdrawal operation shall be aborted or
+// triggers the next retry by setting the last_retry_ts field
+// which will trigger the stored procedure triggering the retry
+// process. The retry counter of the retries is handled by the
+// retrier logic and shall not be set here!
+func prepareRetryOrAbort(
+ withdrawalRowId int,
+ errs chan error,
+) {
+
+ withdrawal, err := DB.GetWithdrawalById(withdrawalRowId)
+ if err != nil {
+ LogError("attestor", err)
+ errs <- err
+ return
+ }
+
+ if withdrawal.RetryCounter >= CONFIG.Server.MaxRetries {
+
+ LogInfo("attestor", fmt.Sprintf("max retries for withdrawal with id=%d was reached. withdrawal is aborted.", withdrawal.WithdrawalRowId))
+ err := DB.FinaliseWithdrawal(withdrawalRowId, ABORTED, make([]byte, 0))
+ if err != nil {
+ LogError("attestor", err)
+ }
+ } else {
+
+ lastRetryTs := time.Now().Unix()
+ err := DB.SetLastRetry(withdrawalRowId, lastRetryTs)
+ if err != nil {
+ LogError("attestor", err)
+ }
+ }
+
+}
diff --git a/c2ec/listener.go b/c2ec/proc-listener.go
diff --git a/c2ec/transfer.go b/c2ec/proc-transfer.go
diff --git a/c2ec/terminals.go b/c2ec/terminals.go
@@ -1,178 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/http"
-)
-
-const TERMINAL_API_CONFIG = "/config"
-const TERMINAL_API_REGISTER_WITHDRAWAL = "/withdrawals"
-const TERMINAL_API_CHECK_WITHDRAWAL = "/withdrawals/:wopid/check"
-
-/**
-*
-TerminalConfig {
- // Name of the API.
- name: "taler-terminal";
-
- // libtool-style representation of the Bank protocol version, see
- // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
- // The format is "current:revision:age".
- version: string;
-
- // Terminal provider display name to be used in user interfaces.
- provider_name: string;
-
- // Currency supported by this terminal.
- // FIXME: needed?
- currency: string;
-
- // Wire transfer type supported by the terminal.
- // FIXME: needed?
- wire_type: stri
-*
-*/
-
-type TerminalConfig struct {
- Name string `json:"name"`
- Version string `json:"version"`
- ProviderName string `json:"provider_name"`
- Currency string `json:"currency"`
- WireType string `json:"wire_type"`
-}
-
-func handleTerminalConfig(res http.ResponseWriter, req *http.Request) {
-
- if authenticated := AuthenticateTerminal(req); !authenticated {
- res.WriteHeader(401)
- return
- }
-}
-
-func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) {
-
- if authenticated := AuthenticateTerminal(req); !authenticated {
- res.WriteHeader(401)
- return
- }
-
- jsonCodec := NewJsonCodec[C2ECWithdrawRegistration]()
- registration, err := ReadStructFromBody(req, jsonCodec)
- if err != nil {
- LogWarn("bank-integration-api", fmt.Sprintf("invalid body for withdrawal registration error=%s", err.Error()))
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAW_REGISTRATION_INVALID_REQ",
- Title: "invalid request",
- Detail: "the registration request for the withdrawal is malformed (error: " + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- // read and validate the wopid path parameter
- wopid := req.PathValue(WOPID_PARAMETER)
- wpd, err := ParseWopid(wopid)
- if err != nil {
- LogWarn("bank-integration-api", "wopid "+wopid+" not valid")
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER",
- Title: "invalid request path parameter",
- Detail: "the withdrawal status request path parameter 'wopid' is malformed",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- err = DB.RegisterWithdrawal(
- wpd,
- registration.ReservePubKey,
- )
-
- if err != nil {
-
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAW_REGISTRATION_DB_FAILURE",
- Title: "database failure",
- Detail: "the registration of the withdrawal failed due to db failure (error:" + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- writeWithdrawalOrError(wpd, res, req.RequestURI)
-}
-
-func handleWithdrawalCheck(res http.ResponseWriter, req *http.Request) {
-
- if authenticated := AuthenticateTerminal(req); !authenticated {
- res.WriteHeader(401)
- return
- }
-
- wopid := req.PathValue(WOPID_PARAMETER)
- wpd, err := ParseWopid(wopid)
- if err != nil {
- LogWarn("bank-integration-api", "wopid "+wopid+" not valid")
- if wopid == "" {
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER",
- Title: "invalid request path parameter",
- Detail: "the withdrawal status request path parameter 'wopid' is malformed",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
- }
-
- jsonCodec := NewJsonCodec[C2ECPaymentNotification]()
- paymentNotification, err := ReadStructFromBody(req, jsonCodec)
- if err != nil {
- LogWarn("bank-integration-api", fmt.Sprintf("invalid body for payment notification error=%s", err.Error()))
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_WITHDRAW_REGISTRATION_INVALID_REQ",
- Title: "invalid request",
- Detail: "the payment notification request for the withdrawal is malformed (error: " + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- LogInfo("bank-integration-api", "received payment notification")
-
- err = DB.NotifyPayment(
- wpd,
- paymentNotification.ProviderTransactionId,
- paymentNotification.TerminalId,
- paymentNotification.Amount,
- paymentNotification.Fees,
- )
- if err != nil {
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_PAYMENT_NOTIFICATION_FAILED",
- Title: "payment notification failed",
- Detail: "the payment notification failed during the processing of the message: " + err.Error(),
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- res.WriteHeader(HTTP_NO_CONTENT)
-}
diff --git a/c2ec/wire-gateway.go b/c2ec/wire-gateway.go
@@ -1,519 +0,0 @@
-package main
-
-import (
- "context"
- "errors"
- "log"
- http "net/http"
- "strconv"
- "time"
-)
-
-const WIRE_GATEWAY_CONFIG_ENDPOINT = "/config"
-const WIRE_GATEWAY_HISTORY_ENDPOINT = "/history"
-
-const WIRE_GATEWAY_CONFIG_PATTERN = WIRE_GATEWAY_CONFIG_ENDPOINT
-const WIRE_TRANSFER_PATTERN = "/transfer"
-const WIRE_HISTORY_INCOMING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/incoming"
-const WIRE_HISTORY_OUTGOING_PATTERN = WIRE_GATEWAY_HISTORY_ENDPOINT + "/outgoing"
-const WIRE_ADMIN_ADD_INCOMING_PATTERN = "/admin/add-incoming"
-
-const INCOMING_RESERVE_TRANSACTION_TYPE = "RESERVE"
-
-// https://docs.taler.net/core/api-bank-wire.html#tsref-type-WireConfig
-type WireConfig struct {
- Name string `json:"name"`
- Version string `json:"version"`
- Currency string `json:"currency"`
- Implementation string `json:"implementation"`
-}
-
-// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferRequest
-type TransferRequest struct {
- RequestUid HashCode `json:"request_uid"`
- Amount Amount `json:"amount"`
- ExchangeBaseUrl string `json:"exchange_base_url"`
- Wtid ShortHashCode `json:"wtid"`
- CreditAccount string `json:"credit_account"`
-}
-
-// https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse
-type TransferResponse struct {
- Timestamp Timestamp `json:"timestamp"`
- RowId int `json:"row_id"`
-}
-
-// https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory
-type IncomingHistory struct {
- IncomingTransactions []IncomingReserveTransaction `json:"incoming_transactions"`
- CreditAccount string `json:"credit_account"`
-}
-
-// type RESERVE | https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingReserveTransaction
-type IncomingReserveTransaction struct {
- Type string `json:"type"`
- RowId int `json:"row_id"`
- Date Timestamp `json:"date"`
- Amount Amount `json:"amount"`
- DebitAccount string `json:"debit_account"`
- ReservePub EddsaPublicKey `json:"reserve_pub"`
-}
-
-type OutgoingHistory struct {
- OutgoingTransactions []*OutgoingBankTransaction `json:"outgoing_transactions"`
- DebitAccount string `json:"debit_account"`
-}
-
-type OutgoingBankTransaction struct {
- RowId uint64 `json:"row_id"`
- Date Timestamp `json:"date"`
- Amount Amount `json:"amount"`
- CreditAccount string `json:"credit_account"`
- Wtid ShortHashCode `json:"wtid"`
- ExchangeBaseUrl string `json:"exchange_base_url"`
-}
-
-func NewIncomingReserveTransaction(w *Withdrawal) *IncomingReserveTransaction {
-
- if w == nil {
- LogWarn("wire-gateway", "the withdrawal was nil")
- return nil
- }
-
- provider, err := DB.GetProviderByTerminal(int(*w.TerminalId))
- if err != nil {
- LogError("wire-gateway", err)
- return nil
- }
-
- client := PROVIDER_CLIENTS[provider.Name]
- if client == nil {
- LogError("wire-gateway", errors.New("no provider client with name="+provider.Name))
- return nil
- }
-
- t := new(IncomingReserveTransaction)
- t.Amount = Amount{
- Value: uint64(w.Amount.Val),
- Fraction: uint64(w.Amount.Frac),
- Currency: w.Amount.Curr,
- }
- t.Date = Timestamp{
- Ts: int(w.RegistrationTs),
- }
- t.DebitAccount = client.FormatPayto(w)
- t.ReservePub = FormatEddsaPubKey(w.ReservePubKey)
- t.RowId = int(w.WithdrawalId)
- t.Type = INCOMING_RESERVE_TRANSACTION_TYPE
- return t
-}
-
-func NewOutgoingBankTransaction(tr *Transfer) *OutgoingBankTransaction {
- t := new(OutgoingBankTransaction)
- t.Amount = Amount{
- Value: uint64(tr.Amount.Val),
- Fraction: uint64(tr.Amount.Frac),
- Currency: tr.Amount.Curr,
- }
- t.Date = Timestamp{
- Ts: int(tr.TransferTs),
- }
- t.CreditAccount = tr.CreditAccount
- t.ExchangeBaseUrl = tr.ExchangeBaseUrl
- t.RowId = uint64(tr.RowId)
- t.Wtid = ShortHashCode(tr.Wtid)
- return t
-}
-
-func wireGatewayConfig(res http.ResponseWriter, req *http.Request) {
-
- cfg := WireConfig{
- Name: "taler-wire-gateway",
- Version: "0:0:1",
- }
-
- serializedCfg, err := NewJsonCodec[WireConfig]().EncodeToBytes(&cfg)
- if err != nil {
- log.Default().Printf("failed serializing config: %s", err.Error())
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- return
- }
-
- res.WriteHeader(HTTP_OK)
- res.Write(serializedCfg)
-}
-
-func transfer(res http.ResponseWriter, req *http.Request) {
-
- jsonCodec := NewJsonCodec[TransferRequest]()
- transfer, err := ReadStructFromBody(req, jsonCodec)
- if err != nil {
-
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_TRANSFER_INVALID_REQ",
- Title: "invalid request",
- Detail: "the transfer request is malformed (error: " + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- paytoTargetType, tid, err := ParsePaytoWalleeTransaction(transfer.CreditAccount)
- if err != nil {
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_TRANSFER_INVALID_REQ",
- Title: "invalid payto-uri",
- Detail: "the transfer request contains an invalid payto-uri (error: " + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- p, err := DB.GetTerminalProviderByPaytoTargetType(paytoTargetType)
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_DATABASE_FAILURE",
- Title: "database request failed",
- Detail: "failed to retrieve the provider for the payto target type '" + paytoTargetType + "'",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- decodedRequestUid, err := talerBinaryDecode(string(transfer.RequestUid))
- if err != nil {
- err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_TRANSFER_INVALID_REQ",
- Title: "invalid request",
- Detail: "the transfer request is malformed (error: " + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- t, err := DB.GetTransferById(decodedRequestUid)
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_DATABASE_FAILURE",
- Title: "database request failed",
- Detail: "there was an error processing the database query",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- if t == nil {
- // no transfer for this request_id -> generate new
- err := DB.AddTransfer(
- decodedRequestUid,
- &transfer.Amount,
- transfer.ExchangeBaseUrl,
- string(transfer.Wtid),
- transfer.CreditAccount,
- )
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_DATABASE_FAILURE",
- Title: "database request failed",
- Detail: "there was an error creating the transfer",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
- } else {
- // the transfer is only processed if the body matches.
- if transfer.Amount.Value != uint64(t.Amount.Val) ||
- transfer.Amount.Fraction != uint64(t.Amount.Frac) ||
- transfer.Amount.Currency != t.Amount.Curr ||
- transfer.ExchangeBaseUrl != t.ExchangeBaseUrl ||
- transfer.Wtid != ShortHashCode(t.Wtid) ||
- transfer.CreditAccount != t.CreditAccount {
-
- err := WriteProblem(res, HTTP_CONFLICT, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_TRANSFER_INVALID_REQ",
- Title: "invalid request",
- Detail: "the transfer request did not match previous request with the same request identifier",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- ptid := strconv.Itoa(tid)
- w, err := DB.GetWithdrawalByProviderTransactionId(ptid)
- if err != nil || w == nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_DATABASE_FAILURE",
- Title: "database request failed",
- Detail: "there was an error processing the database query or no withdrawal could been found.",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- refundClient := PROVIDER_CLIENTS[p.Name]
- if refundClient == nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_UNKNOWN_TRANSFER_MECHANISM",
- Title: "unknown refund mechanism",
- Detail: "the target type of the payto uri for the transfer is not registered",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
- refundClient.Refund(ptid)
- }
-}
-
-// :query start: *Optional.*
-//
-// Row identifier to explicitly set the *starting point* of the query.
-//
-// :query delta:
-//
-// The *delta* value that determines the range of the query.
-//
-// :query long_poll_ms: *Optional.*
-//
-// If this parameter is specified and the result of the query would be empty,
-// the bank will wait up to ``long_poll_ms`` milliseconds for new transactions
-// that match the query to arrive and only then send the HTTP response.
-// A client must never rely on this behavior, as the bank may return a response
-// immediately or after waiting only a fraction of ``long_poll_ms``.
-func historyIncoming(res http.ResponseWriter, req *http.Request) {
-
- // read and validate request query parameters
- shouldStartLongPoll := true
- var longPollMilli int
- if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse(
- "long_poll_ms", strconv.Atoi, req, res,
- ); accepted {
- } else {
- if longPollMilliPtr != nil {
- longPollMilli = *longPollMilliPtr
- } else {
- // this means parameter was not given.
- // no long polling (simple get)
- shouldStartLongPoll = false
- }
- }
-
- var start int
- if startPtr, accepted := AcceptOptionalParamOrWriteResponse(
- "start", strconv.Atoi, req, res,
- ); accepted {
- } else {
- if startPtr != nil {
- start = *startPtr
- }
- }
-
- var delta int
- if deltaPtr, accepted := AcceptOptionalParamOrWriteResponse(
- "delta", strconv.Atoi, req, res,
- ); accepted {
- } else {
- if deltaPtr != nil {
- delta = *deltaPtr
- } else {
- // this means parameter was not given.
- // no long polling (simple get)
- shouldStartLongPoll = false
- }
- }
-
- if delta == 0 {
- delta = 10
- }
-
- if shouldStartLongPoll {
-
- // wait for the completion of the context
- waitMs, cancelFunc := context.WithTimeout(req.Context(), time.Duration(longPollMilli)*time.Millisecond)
- defer cancelFunc()
-
- // this will just wait / block until the milliseconds are exceeded.
- <-waitMs.Done()
- }
-
- withdrawals, err := DB.GetConfirmedWithdrawals(start, delta)
-
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_DATABASE_FAILURE",
- Title: "database request failed",
- Detail: "there was an error processing the database query. error=" + err.Error(),
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- if len(withdrawals) < 1 {
- res.WriteHeader(HTTP_NOT_FOUND)
- return
- }
-
- transactions := make([]*IncomingReserveTransaction, 0)
- for _, w := range withdrawals {
- transaction := NewIncomingReserveTransaction(w)
- if transaction != nil {
- transactions = append(transactions, transaction)
- }
- }
-
- enc, err := NewJsonCodec[[]*IncomingReserveTransaction]().EncodeToBytes(&transactions)
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_RESPONSE_ENCODING_FAILED",
- Title: "encoding failed",
- Detail: "the encoding of the response failed (error:" + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- res.WriteHeader(HTTP_OK)
- res.Write(enc)
-}
-
-func historyOutgoing(res http.ResponseWriter, req *http.Request) {
-
- // read and validate request query parameters
- shouldStartLongPoll := true
- var longPollMilli int
- if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse(
- "long_poll_ms", strconv.Atoi, req, res,
- ); accepted {
- } else {
- if longPollMilliPtr != nil {
- longPollMilli = *longPollMilliPtr
- } else {
- // this means parameter was not given.
- // no long polling (simple get)
- shouldStartLongPoll = false
- }
- }
-
- var start int
- if startPtr, accepted := AcceptOptionalParamOrWriteResponse(
- "start", strconv.Atoi, req, res,
- ); accepted {
- } else {
- if startPtr != nil {
- start = *startPtr
- }
- }
-
- var delta int
- if deltaPtr, accepted := AcceptOptionalParamOrWriteResponse(
- "delta", strconv.Atoi, req, res,
- ); accepted {
- } else {
- if deltaPtr != nil {
- delta = *deltaPtr
- } else {
- // this means parameter was not given.
- // no long polling (simple get)
- shouldStartLongPoll = false
- }
- }
-
- if shouldStartLongPoll {
-
- // this will just wait / block until the milliseconds are exceeded.
- time.Sleep(time.Duration(longPollMilli) * time.Millisecond)
- }
-
- transfers, err := DB.GetTransfers(start, delta)
-
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_DATABASE_FAILURE",
- Title: "database request failed",
- Detail: "there was an error processing the database query",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- filtered := make([]*Transfer, 0)
- for _, t := range transfers {
- if t.Status == 0 {
- // only consider transfer which were successful
- filtered = append(filtered, t)
- }
- }
-
- if len(filtered) < 1 {
- res.WriteHeader(HTTP_NOT_FOUND)
- return
- }
-
- transactions := make([]*OutgoingBankTransaction, len(filtered))
- for _, t := range filtered {
- transactions = append(transactions, NewOutgoingBankTransaction(t))
- }
-
- outgoingHistory := OutgoingHistory{
- OutgoingTransactions: transactions,
- DebitAccount: CONFIG.Server.CreditAccount,
- }
- enc, err := NewJsonCodec[OutgoingHistory]().EncodeToBytes(&outgoingHistory)
- if err != nil {
- err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{
- TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_RESPONSE_ENCODING_FAILED",
- Title: "encoding failed",
- Detail: "the encoding of the response failed (error:" + err.Error() + ")",
- Instance: req.RequestURI,
- })
- if err != nil {
- res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR)
- }
- return
- }
-
- res.WriteHeader(HTTP_OK)
- res.Write(enc)
-}
-
-// This method is currently dead and implemented for API conformance
-func adminAddIncoming(res http.ResponseWriter, req *http.Request) {
-
- // not implemented, because not used
- res.WriteHeader(HTTP_BAD_REQUEST)
-}
diff --git a/cli/cli.go b/cli/cli.go
@@ -2,6 +2,7 @@ package main
import (
"bufio"
+ "bytes"
"context"
"crypto/rand"
"encoding/base64"
@@ -16,6 +17,7 @@ import (
)
const ACTION_HELP = "h"
+const ACTION_SETUP_SIMULATION = "sim"
const ACTION_REGISTER_PROVIDER = "rp"
const ACTION_REGISTER_TERMINAL = "rt"
const ACTION_DEACTIVATE_TERMINAL = "dt"
@@ -147,7 +149,7 @@ func registerWalleeTerminal() error {
INSERT_TERMINAL,
hashedAccessToken,
description,
- p.ProviderTerminalID,
+ p.ProviderId,
)
if err != nil {
return err
@@ -176,6 +178,10 @@ func registerWalleeTerminal() error {
func deactivateTerminal() error {
+ if DB == nil {
+ return errors.New("connect to the database first (cmd: db)")
+ }
+
fmt.Println("You are about to deactivate terminal which allows withdrawals. This will make the terminal unusable.")
tuid := read("Terminal-User-Id: ")
parts := strings.Split(tuid, "-")
@@ -199,6 +205,94 @@ func deactivateTerminal() error {
return nil
}
+func setupSimulation() error {
+
+ if DB == nil {
+ return errors.New("connect to the database first (cmd: db)")
+ }
+
+ // SETTING UP PROVIDER
+ fmt.Println("Setting up simulation provider and terminal.")
+ name := "Simulation"
+ paytotargettype := "void"
+ backendUrl := "simulation provider will not contact any backend."
+ credsEncoded := base64.StdEncoding.EncodeToString(bytes.NewBufferString("simulation provider will not contact any backend.").Bytes())
+
+ _, err := DB.Exec(
+ context.Background(),
+ INSERT_PROVIDER,
+ name,
+ paytotargettype,
+ backendUrl,
+ credsEncoded,
+ )
+ if err != nil {
+ return err
+ }
+
+ // SETTING UP TERMINAL
+ description := "simulation terminal"
+
+ rows, err := DB.Query(
+ context.Background(),
+ GET_PROVIDER_BY_NAME,
+ name,
+ )
+ if err != nil {
+ return err
+ }
+
+ p, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Provider])
+ if err != nil {
+ return err
+ }
+ rows.Close() // release rows / connection
+
+ accessToken := make([]byte, 32)
+ _, err = rand.Read(accessToken)
+ if err != nil {
+ return err
+ }
+
+ accessTokenBase64 := base64.StdEncoding.EncodeToString(accessToken)
+
+ hashedAccessToken, err := pbkdf(accessTokenBase64)
+ if err != nil {
+ return err
+ }
+
+ _, err = DB.Exec(
+ context.Background(),
+ INSERT_TERMINAL,
+ hashedAccessToken,
+ description,
+ p.ProviderId,
+ )
+ if err != nil {
+ return err
+ }
+
+ fmt.Println("looking up last inserted terminal")
+ rows, err = DB.Query(
+ context.Background(),
+ GET_LAST_INSERTED_TERMINAL,
+ )
+ if err != nil {
+ return err
+ }
+ t, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Terminal])
+ if err != nil {
+ return err
+ }
+ rows.Close()
+
+ fmt.Println("Terminal-User-Id (used to identify terminal at the api. You want to note this):", name+"-"+strconv.Itoa(int(t.TerminalID)))
+ fmt.Println("GENERATED ACCESS-TOKEN (save it in your password manager. Can't be recovered!!):")
+ fmt.Println(accessTokenBase64)
+
+ return nil
+}
+
func connectDatabase() error {
u := read("Username: ")
@@ -234,6 +328,7 @@ func showHelp() error {
fmt.Println("register wallee provider (", ACTION_REGISTER_PROVIDER, ")")
fmt.Println("register wallee terminal (", ACTION_REGISTER_TERMINAL, ")")
fmt.Println("deactivate wallee terminal (", ACTION_DEACTIVATE_TERMINAL, ")")
+ fmt.Println("setup simulation (", ACTION_SETUP_SIMULATION, ")")
fmt.Println("connect database (", ACTION_CONNECT_DB, ")")
fmt.Println("show help (", ACTION_HELP, ")")
fmt.Println("quit (", ACTION_QUIT, ")")
@@ -299,6 +394,8 @@ func dispatchCommand(cmd string) error {
err = registerWalleeTerminal()
case ACTION_DEACTIVATE_TERMINAL:
err = deactivateTerminal()
+ case ACTION_SETUP_SIMULATION:
+ err = setupSimulation()
default:
fmt.Println("unknown action")
}
diff --git a/cli/db.go b/cli/db.go
@@ -7,7 +7,7 @@ const GET_PROVIDER_BY_NAME = "SELECT * FROM c2ec.provider WHERE name=$1"
const GET_LAST_INSERTED_TERMINAL = "SELECT * FROM c2ec.terminal WHERE terminal_id = (SELECT MAX(terminal_id) FROM c2ec.terminal)"
type Provider struct {
- ProviderTerminalID int64 `db:"provider_id"`
+ ProviderId int64 `db:"provider_id"`
Name string `db:"name"`
PaytoTargetType string `db:"payto_target_type"`
BackendBaseURL string `db:"backend_base_url"`
diff --git a/docs/content/implementation/bank-integration.tex b/docs/content/implementation/bank-integration.tex
diff --git a/docs/content/implementation/terminal-api.tex b/docs/content/implementation/terminal-api.tex
@@ -0,0 +1,27 @@
+\subsection{Terminal API}
+
+This section describes the Implementation of the Terminal API \cite{taler-terminal-api}.
+
+The C2EC component does not implement the \texttt{/quotas/*} endpoints, since those are not relevant for the withdrawal using a payment terminal.
+
+The exact specification can be found in (TODO REF APPENDIX)
+
+\subsubsection{Configuration (/config)}
+
+This endpoint returns the configuration. Especially the fields \texttt{currency} and \texttt{wire_type} are interesting, since they are used to
+
+\subsubsection{Registering a withdrawal (/withdrawals)}
+
+The registration of a withdrawal initializes the flow by the
+
+\subsubsection{Trigger Attestation (/withdrawals/[wopid]/check)}
+
+
+
+\subsubsection{Taler Integration (/taler-integration/*)}
+
+
+
+\subsubsection{Taler Integration (/taler-wire-gateway/*)}
+
+
diff --git a/docs/content/implementation/wire-gateway.tex b/docs/content/implementation/wire-gateway.tex
diff --git a/docs/project.bib b/docs/project.bib
@@ -226,6 +226,13 @@
howpublished = {\url{https://docs.taler.net/core/api-corebank.html#authentication}}
}
+@misc{taler-terminal-api,
+ author = {Taler},
+ howpublished = {\url{https://docs.taler.net/core/api-terminal.html}},
+ title = {Terminal API},
+ url = {https://docs.taler.net/core/api-terminal.html#endpoints-for-integrated-sub-apis}
+}
+
@misc{taler-design-document-49,
author = {Taler},
title = {Authentication},
diff --git a/simulation/c2ec-simulation b/simulation/c2ec-simulation
Binary files differ.
diff --git a/simulation/go.mod b/simulation/go.mod
@@ -1,3 +1,5 @@
module c2ec-simulation
go 1.22.1
+
+require github.com/gofrs/uuid v4.4.0+incompatible // indirect
diff --git a/simulation/go.sum b/simulation/go.sum
@@ -0,0 +1,2 @@
+github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
+github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
diff --git a/simulation/http-util.go b/simulation/http-util.go
@@ -3,7 +3,6 @@ package main
import (
"bytes"
- "errors"
"fmt"
"net/http"
"strings"
@@ -185,12 +184,6 @@ func HttpGet[T any](
}
if res.StatusCode > 299 {
- errBody, err := NewJsonCodec[RFC9457Problem]().Decode(res.Body)
- if err != nil {
- fmt.Println("error happened on GET. Failed parsing error")
- return nil, -1, err
- }
- fmt.Printf("Error (%d): %s (%s)", res.StatusCode, errBody.Title, errBody.Detail)
return nil, res.StatusCode, nil
}
@@ -202,101 +195,40 @@ func HttpGet[T any](
}
}
-// execute a POST request and parse response or retrieve error
-func HttpPost2[T any, R any](
- req string,
- body *T,
- requestCodec Codec[T],
- responseCodec Codec[R],
-) (*R, int, error) {
-
- return HttpPost(
- req,
- nil,
- nil,
- body,
- requestCodec,
- responseCodec,
- )
-}
-
-// execute a POST request and parse response or retrieve error
-// path- and query-parameters can be set to add query and path parameters
func HttpPost[T any, R any](
- req string,
- pathParams map[string]string,
- queryParams map[string]string,
+ url string,
+ headers map[string]string,
body *T,
- requestCodec Codec[T],
- responseCodec Codec[R],
+ reqCodec Codec[T],
+ resCodec Codec[R],
) (*R, int, error) {
- url := FormatUrl(req, pathParams, queryParams)
- fmt.Println("POST:", url)
-
- var res *http.Response
- if body == nil {
- if requestCodec == nil {
- res, err := http.Post(
- url,
- "",
- nil,
- )
-
- if err != nil {
- return nil, -1, err
- }
-
- return nil, res.StatusCode, nil
- } else {
- return nil, -1, errors.New("invalid arguments - body was not present but codec was defined")
- }
- } else {
- if requestCodec == nil {
- return nil, -1, errors.New("invalid arguments - body was present but no codec was defined")
- } else {
-
- encodedBody, err := requestCodec.Encode(body)
- if err != nil {
- return nil, -1, err
- }
-
- res, err = http.Post(
- url,
- requestCodec.HttpApplicationContentHeader(),
- encodedBody,
- )
-
- if err != nil {
- return nil, -1, err
- }
-
- buf := make([]byte, res.ContentLength)
- _, err = res.Body.Read(buf)
- if err != nil {
- fmt.Println("body after post:", string(buf))
- }
- }
+ bodyEncoded, err := reqCodec.EncodeToBytes(body)
+ if err != nil {
+ return nil, -1, err
}
- if responseCodec == nil {
- return nil, res.StatusCode, nil
+ req, err := http.NewRequest(HTTP_POST, url, bytes.NewBuffer(bodyEncoded))
+ if err != nil {
+ return nil, -1, err
}
- if res.StatusCode > 299 {
- errBody, err := NewJsonCodec[RFC9457Problem]().Decode(res.Body)
- if err != nil {
- return nil, -1, err
- }
- fmt.Printf("Error (%d): %s (%s)", res.StatusCode, errBody.Title, errBody.Detail)
- return nil, res.StatusCode, nil
+ for k, v := range headers {
+ req.Header.Add(k, v)
}
+ req.Header.Add("Accept", reqCodec.HttpApplicationContentHeader())
- resBody, err := responseCodec.Decode(res.Body)
+ res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, -1, err
}
- return resBody, res.StatusCode, err
+
+ if resCodec == nil {
+ return nil, res.StatusCode, err
+ } else {
+ resBody, err := resCodec.Decode(res.Body)
+ return resBody, res.StatusCode, err
+ }
}
// builds request URL containing the path and query
diff --git a/simulation/main.go b/simulation/main.go
@@ -8,11 +8,6 @@ import (
const DISABLE_DELAYS = true
const C2EC_BASE_URL = "http://localhost:8082"
-const C2EC_BANK_BASE_URL = C2EC_BASE_URL + "/c2ec"
-const C2EC_BANK_CONFIG_URL = C2EC_BANK_BASE_URL + "/config"
-const C2EC_BANK_WITHDRAWAL_STATUS_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid"
-const C2EC_BANK_WITHDRAWAL_REGISTRATION_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid"
-const C2EC_BANK_WITHDRAWAL_PAYMENT_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid/confirm"
// simulates the terminal talking to its backend system and executing the payment.
const PROVIDER_BACKEND_PAYMENT_DELAY_MS = 1000
@@ -26,25 +21,6 @@ const TERMINAL_ACCEPT_CARD_DELAY_MS = 5000
// simulates the user scanning the QR code presented at the terminal
const WALLET_SCAN_QR_CODE_DELAY_MS = 5000
-// https://docs.taler.net/core/api-exchange.html#tsref-type-CurrencySpecification
-type CurrencySpecification struct {
- Name string `json:"name"`
- Currency string `json:"currency"`
- NumFractionalInputDigits int `json:"num_fractional_input_digits"`
- NumFractionalNormalDigits int `json:"num_fractional_normal_digits"`
- NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"`
- AltUnitNames string `json:"alt_unit_names"`
-}
-
-// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig
-type BankIntegrationConfig struct {
- Name string `json:"name"`
- Version string `json:"version"`
- Implementation string `json:"implementation"`
- Currency string `json:"currency"`
- CurrencySpecification CurrencySpecification `json:"currency_specification"`
-}
-
type SimulatedPhysicalInteraction struct {
Msg string
}
diff --git a/simulation/model.go b/simulation/model.go
@@ -60,10 +60,3 @@ type C2ECWithdrawalStatus struct {
WireTypes []string `json:"wire_types"`
ReservePubKey EddsaPublicKey `json:"selected_reserve_pub"`
}
-
-type C2ECPaymentNotification struct {
- ProviderTransactionId string `json:"provider_transaction_id"`
- TerminalId int `json:"terminal_id"`
- Amount Amount `json:"amount"`
- Fees Amount `json:"card_fees"`
-}
diff --git a/simulation/sim-terminal.go b/simulation/sim-terminal.go
@@ -1,65 +1,131 @@
package main
import (
- "bytes"
- "crypto/rand"
"encoding/base64"
"errors"
"fmt"
- "net/http"
"strconv"
"time"
+
+ "github.com/gofrs/uuid"
)
+const C2EC_TERMINAL_CONFIG_API = C2EC_BASE_URL + "/config"
+const C2EC_TERMINAL_SETUP_WITHDRAWAL_API = C2EC_BASE_URL + "/withdrawals"
+const C2EC_TERMINAL_STATUS_WITHDRAWAL_API = C2EC_BASE_URL + "/withdrawals/:wopid"
+const C2EC_TERMINAL_CHECK_WITHDRAWAL_API = C2EC_BASE_URL + "/withdrawals/:wopid/check"
+
const TERMINAL_PROVIDER = "Simulation"
-const TERMINAL_ID = "1"
+// this must be the id retrieved by the cli
+const TERMINAL_ID = "2"
// retrieved from the cli tool when added the terminal
-const TERMINAL_USER_ID = TERMINAL_PROVIDER + "-" + TERMINAL_ID
+const TERMINAL_USER_ID = "Simulation-" + TERMINAL_ID
// retrieved from the cli tool when added the terminal
-const TERMINAL_ACCESS_TOKEN = "secret"
+const TERMINAL_ACCESS_TOKEN = "oVclsDlWVl0LaQg83e05M7/vCk2PfdJ785GaI0MQ0wc="
const SIM_TERMINAL_LONG_POLL_MS_STR = "20000" // 20 seconds
-const QR_CODE_CONTENT_BASE = "taler://withdraw/localhost:8082/c2ec/"
+const QR_CODE_CONTENT_BASE = "taler://withdraw/localhost:8082/taler-integration/"
func Terminal(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysicalInteraction, kill chan error) {
fmt.Println("TERMINAL: Terminal idle... awaiting readiness message of sim-wallet")
<-in
- fmt.Println("TERMINAL: Sim-Wallet ready, generating WOPID... ")
- wopidBytes := make([]byte, 32)
- _, err := rand.Read(wopidBytes)
+ fmt.Println("TERMINAL: basic auth header:", TerminalAuth())
+ fmt.Println("TERMINAL: loading terminal api config")
+ terminalApiCfg, status, err := HttpGet(
+ C2EC_TERMINAL_CONFIG_API,
+ map[string]string{"Authorization": TerminalAuth()},
+ NewJsonCodec[TerminalConfig](),
+ )
+ if err != nil {
+ kill <- err
+ return
+ }
+ if status != 200 {
+ kill <- errors.New("terminal api configuration failed with status " + strconv.Itoa(status))
+ return
+ }
+ fmt.Println("TERMINAL: API config loaded.", terminalApiCfg.Name, terminalApiCfg.Version, terminalApiCfg.ProviderName, terminalApiCfg.WireType)
+
+ fmt.Println("TERMINAL: Sim-Wallet ready, intiating withdrawal...")
+
+ uuid, err := uuid.NewGen().NewV7()
+ if err != nil {
+ kill <- err
+ return
+ }
+
+ setupReq := &TerminalWithdrawalSetup{
+ Amount: &Amount{"CHF", 10, 50},
+ SuggestedAmount: &Amount{"CHF", 10, 50},
+ ProviderTransactionId: "",
+ TerminalFees: &Amount{},
+ RequestUid: uuid.String(),
+ UserUuid: "",
+ Lock: "",
+ }
+
+ url := FormatUrl(
+ C2EC_TERMINAL_SETUP_WITHDRAWAL_API,
+ map[string]string{},
+ map[string]string{},
+ )
+ fmt.Println("TERMINAL: requesting url:", url)
+ response, status, err := HttpPost(
+ url,
+ map[string]string{"Authorization": TerminalAuth()},
+ setupReq,
+ NewJsonCodec[TerminalWithdrawalSetup](),
+ NewJsonCodec[TerminalWithdrawalSetupResponse](),
+ )
+ if err != nil {
+ kill <- err
+ return
+ }
+ if status != 200 {
+ kill <- errors.New("status of withdrawal setup response was " + strconv.Itoa(status))
+ return
+ }
+
+ wopidEncoded := response.Wopid
+ fmt.Println("TERMINAL: received wopid:", wopidEncoded)
+
+ // this decoding encoding cycle is useless but tests
+ // decoding and encoding of the wopid. That's why it is
+ // done here.
+ wopidDecoded, err := ParseWopid(wopidEncoded)
if err != nil {
- fmt.Println("TERMINAL: failed creating the wopid:", err.Error(), "(ends simulation)")
kill <- err
+ return
}
+ wopidEncoded = FormatWopid(wopidDecoded)
- wopid := FormatWopid(wopidBytes)
- fmt.Println("TERMINAL: Generated Nonce (base64 url encoded):", wopid)
- uri := QR_CODE_CONTENT_BASE + wopid
+ uri := QR_CODE_CONTENT_BASE + wopidEncoded
fmt.Println("TERMINAL: Taler Withdrawal URI:", uri)
// note for realworld implementation
// -> start long polling always before showing the QR code
- awaitSelection := make(chan *C2ECWithdrawalStatus)
+ awaitSelection := make(chan *BankWithdrawalOperationStatus)
longPollFailed := make(chan error)
fmt.Println("TERMINAL: now sending long poll request to c2ec from terminal and await parameter selection")
go func() {
url := FormatUrl(
- C2EC_BANK_WITHDRAWAL_STATUS_URL,
- map[string]string{"wopid": wopid},
+ C2EC_TERMINAL_STATUS_WITHDRAWAL_API,
+ map[string]string{"wopid": wopidEncoded},
map[string]string{"long_poll_ms": SIM_TERMINAL_LONG_POLL_MS_STR},
)
+ fmt.Println("TERMINAL: requesting status update for withdrawal", url)
response, status, err := HttpGet(
url,
map[string]string{"Authorization": TerminalAuth()},
- NewJsonCodec[C2ECWithdrawalStatus](),
+ NewJsonCodec[BankWithdrawalOperationStatus](),
)
if err != nil {
kill <- err
@@ -73,7 +139,7 @@ func Terminal(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysical
awaitSelection <- response
}()
- fmt.Println("Go is too fast :) ... need to sleep a bit that long polling request is guaranteed to be executed before the POST of the registration. This won't be a problem in real world appliance.")
+ fmt.Println("need to sleep a bit that long polling request is guaranteed to be executed before the POST of the registration. This won't be a problem in real world appliance.")
time.Sleep(time.Duration(10) * time.Millisecond)
if !DISABLE_DELAYS {
@@ -100,50 +166,39 @@ func Terminal(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysical
fmt.Println("TERMINAL: card accepted. terminal waits for response of provider backend.")
}
- terminalId, err := strconv.Atoi(TERMINAL_ID)
- if err != nil {
- fmt.Println("failed parsing the terminal id.")
- kill <- err
- }
-
- fmt.Println("TERMINAL: payment was processed at the provider backend. sending payment notification.")
- paymentNotification := &C2ECPaymentNotification{
+ fmt.Println("TERMINAL: payment was processed at the provider backend. sending check notification.")
+ checkNotification := &TerminalWithdrawalConfirmationRequest{
ProviderTransactionId: "simulation-transaction-id-0",
- TerminalId: terminalId,
- Amount: Amount{
- Currency: "CHF",
- Fraction: 10,
- Value: 10,
- },
- Fees: Amount{
+ TerminalFees: &Amount{
Currency: "CHF",
Fraction: 10,
Value: 0,
},
}
- cdc := NewJsonCodec[C2ECPaymentNotification]()
- pnbytes, err := cdc.EncodeToBytes(paymentNotification)
- if err != nil {
- fmt.Println("TERMINAL: failed serializing payment notification")
- kill <- err
- }
- paymentUrl := FormatUrl(
- C2EC_BANK_WITHDRAWAL_PAYMENT_URL,
- map[string]string{"wopid": wopid},
+ checkurl := FormatUrl(
+ C2EC_TERMINAL_CHECK_WITHDRAWAL_API,
+ map[string]string{"wopid": wopidEncoded},
map[string]string{},
)
- _, err = http.Post(
- paymentUrl,
- cdc.HttpApplicationContentHeader(),
- bytes.NewReader(pnbytes),
+ fmt.Println("TERMINAL: check url", checkurl)
+ _, status, err = HttpPost[TerminalWithdrawalConfirmationRequest, any](
+ checkurl,
+ map[string]string{"Authorization": TerminalAuth()},
+ checkNotification,
+ NewJsonCodec[TerminalWithdrawalConfirmationRequest](),
+ nil,
)
if err != nil {
fmt.Println("TERMINAL: error on POST request:", err.Error())
kill <- err
}
+ if status != 204 {
+ fmt.Println("TERMINAL: error while check payment POST: " + strconv.Itoa(status))
+ kill <- errors.New("payment check request by terminal failed")
+ }
fmt.Println("TERMINAL: Terminal flow ended")
case f := <-longPollFailed:
- fmt.Println("TERMINAL: long-polling for selection failed... error:", err.Error())
+ fmt.Println("TERMINAL: long-polling for selection failed... error:", err)
kill <- f
}
}
@@ -152,5 +207,34 @@ func Terminal(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysical
func TerminalAuth() string {
userAndPw := fmt.Sprintf("%s:%s", TERMINAL_USER_ID, TERMINAL_ACCESS_TOKEN)
- return base64.StdEncoding.EncodeToString([]byte(userAndPw))
+ return "Basic " + base64.StdEncoding.EncodeToString([]byte(userAndPw))
+}
+
+// Structs copied from c2ec
+type TerminalConfig struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ ProviderName string `json:"provider_name"`
+ WireType string `json:"wire_type"`
+}
+
+type TerminalWithdrawalSetup struct {
+ Amount *Amount `json:"amount"`
+ SuggestedAmount *Amount `json:"suggested_amount"`
+ ProviderTransactionId string `json:"provider_transaction_id"`
+ TerminalFees *Amount `json:"terminal_fees"`
+ RequestUid string `json:"request_uid"`
+ UserUuid string `json:"user_uuid"`
+ Lock string `json:"lock"`
+}
+
+type TerminalWithdrawalSetupResponse struct {
+ Wopid string `json:"withdrawal_id"`
+}
+
+type TerminalWithdrawalConfirmationRequest struct {
+ ProviderTransactionId string `json:"provider_transaction_id"`
+ TerminalFees *Amount `json:"terminal_fees"`
+ UserUuid string `json:"user_uuid"`
+ Lock string `json:"lock"`
}
diff --git a/simulation/sim-wallet.go b/simulation/sim-wallet.go
@@ -12,6 +12,11 @@ import (
"time"
)
+const C2EC_BANK_BASE_URL = C2EC_BASE_URL + "/taler-integration"
+const C2EC_BANK_CONFIG_URL = C2EC_BANK_BASE_URL + "/config"
+const C2EC_BANK_WITHDRAWAL_STATUS_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid"
+const C2EC_BANK_WITHDRAWAL_REGISTRATION_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid"
+
const SIM_WALLET_LONG_POLL_MS_STR = "10000" // 10 seconds
func Wallet(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysicalInteraction, kill chan error) {
@@ -37,9 +42,11 @@ func Wallet(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysicalIn
map[string]string{},
)
- cdc := NewJsonCodec[C2ECWithdrawRegistration]()
- reg := new(C2ECWithdrawRegistration)
+ cdc := NewJsonCodec[BankWithdrawalOperationPostRequest]()
+ reg := new(BankWithdrawalOperationPostRequest)
reg.ReservePubKey = EddsaPublicKey(simulateReservePublicKey())
+ reg.Amount = nil
+ reg.SelectedExchange = C2EC_BANK_BASE_URL
body, err := cdc.EncodeToBytes(reg)
regByte := bytes.NewBuffer(body)
// fmt.Println("WALLET : body (bytes):", regByte.Bytes())
@@ -130,3 +137,42 @@ func simulateReservePublicKey() string {
}
return talerBinaryEncode(mockedPubKey)
}
+
+type CurrencySpecification struct {
+ Name string `json:"name"`
+ Currency string `json:"currency"`
+ NumFractionalInputDigits int `json:"num_fractional_input_digits"`
+ NumFractionalNormalDigits int `json:"num_fractional_normal_digits"`
+ NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"`
+ AltUnitNames string `json:"alt_unit_names"`
+}
+
+// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig
+type BankIntegrationConfig struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Implementation string `json:"implementation"`
+ Currency string `json:"currency"`
+ CurrencySpecification CurrencySpecification `json:"currency_specification"`
+ // TODO: maybe add exchanges payto uri for transfers etc.?
+}
+
+type BankWithdrawalOperationPostRequest struct {
+ ReservePubKey EddsaPublicKey `json:"reserve_pub"`
+ SelectedExchange string `json:"selected_exchange"`
+ Amount *Amount `json:"amount"`
+}
+
+type BankWithdrawalOperationPostResponse struct {
+ Status WithdrawalOperationStatus `json:"status"`
+ ConfirmTransferUrl string `json:"confirm_transfer_url"`
+ TransferDone bool `json:"transfer_done"`
+}
+
+type BankWithdrawalOperationStatus struct {
+ Status WithdrawalOperationStatus `json:"status"`
+ Amount Amount `json:"amount"`
+ SenderWire string `json:"sender_wire"`
+ WireTypes []string `json:"wire_types"`
+ ReservePubKey EddsaPublicKey `json:"selected_reserve_pub"`
+}