cashless2ecash

cashless2ecash: pay with cards for digital cash (experimental)
Log | Files | Refs | README

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:
Ac2ec/api-auth.go | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rc2ec/auth_test.go -> c2ec/api-auth_test.go | 0
Ac2ec/api-bank-integration.go | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/api-terminals.go | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/api-wire-gateway.go | 427+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dc2ec/attestor.go | 153-------------------------------------------------------------------------------
Dc2ec/auth.go | 157-------------------------------------------------------------------------------
Dc2ec/bank-integration.go | 251-------------------------------------------------------------------------------
Ac2ec/db-postgres.go | 787+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/db.go | 32+++++++++++++++++++++++++-------
Mc2ec/db/0001-c2ec_schema.sql | 20++++++++++++++------
Mc2ec/db/drop.sql | 2++
Mc2ec/db/proc-c2ec_payment_notification_listener.sql | 4++--
Mc2ec/db/proc-c2ec_retry_listener.sql | 2+-
Mc2ec/db/proc-c2ec_status_listener.sql | 4++--
Dc2ec/db/procedures.sql | 163-------------------------------------------------------------------------------
Dc2ec/db/procedures.sql.in | 2--
Rc2ec/db/test_c2ec_test.sql -> c2ec/db/test_c2ec_simulation.sql | 0
Rc2ec/db/test_c2ec_test_rollback.sql -> c2ec/db/test_c2ec_simulation_rollback.sql | 0
Mc2ec/encoding.go | 6------
Mc2ec/http-util.go | 48+++++-------------------------------------------
Ac2ec/install/build_app.sh | 21+++++++++++++++++++++
Ac2ec/install/setup_db.sh | 37+++++++++++++++++++++++++++++++++++++
Ac2ec/install/start.sh | 26++++++++++++++++++++++++++
Ac2ec/install/wipe_db.sh | 27+++++++++++++++++++++++++++
Mc2ec/main.go | 12+++++++++++-
Dc2ec/postgres.go | 720-------------------------------------------------------------------------------
Ac2ec/proc-attestor.go | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rc2ec/listener.go -> c2ec/proc-listener.go | 0
Rc2ec/transfer.go -> c2ec/proc-transfer.go | 0
Dc2ec/terminals.go | 178-------------------------------------------------------------------------------
Dc2ec/wire-gateway.go | 519-------------------------------------------------------------------------------
Mcli/cli.go | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcli/db.go | 2+-
Adocs/content/implementation/bank-integration.tex | 0
Adocs/content/implementation/terminal-api.tex | 27+++++++++++++++++++++++++++
Adocs/content/implementation/wire-gateway.tex | 0
Mdocs/project.bib | 7+++++++
Msimulation/c2ec-simulation | 0
Msimulation/go.mod | 2++
Asimulation/go.sum | 2++
Msimulation/http-util.go | 110+++++++++++++++----------------------------------------------------------------
Msimulation/main.go | 24------------------------
Msimulation/model.go | 7-------
Msimulation/sim-terminal.go | 182++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msimulation/sim-wallet.go | 50++++++++++++++++++++++++++++++++++++++++++++++++--
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"` +}