cashless2ecash

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

commit dfdf9bbb7c928827928f3855ff5afea91daa28b9
parent 1049b5ec7594c2440500f950b29f94351cee757a
Author: Joel-Haeberli <haebu@rubigen.ch>
Date:   Wed,  3 Apr 2024 19:12:52 +0200

code: implement wire gateway

Diffstat:
M.gitignore | 2++
Mbruno/c2ec/(LOCAL-BIA) Withdrawal Status.bru | 2+-
Mc2ec/auth.go | 34++++++++++++++++++++++++++++++++++
Mc2ec/bank-integration.go | 59++++++++++++++++++++++-------------------------------------
Mc2ec/c2ec-config.yaml | 1+
Mc2ec/config.go | 1+
Mc2ec/db.go | 33+++++++++++++++++++++++++++++++--
Mc2ec/db/0000-c2ec_schema.sql | 5+++++
Ac2ec/db/0000-c2ec_transfers.sql | 23+++++++++++++++++++++++
Mc2ec/main.go | 9+++++++--
Ac2ec/payto.go | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/postgres.go | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mc2ec/provider-client.go | 2+-
Mc2ec/simulation-attestor.go | 8+++-----
Mc2ec/simulation-client.go | 6++++--
Mc2ec/wallee-attestor.go | 4+++-
Mc2ec/wallee-client.go | 5++++-
Mc2ec/wire-gateway.go | 308++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
18 files changed, 691 insertions(+), 92 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,6 +1,8 @@ schemaspy/*.jar schemaspy/Makefile infra/ +bruno/ +c2ec/c2ec LocalMakefile diff --git a/bruno/c2ec/(LOCAL-BIA) Withdrawal Status.bru b/bruno/c2ec/(LOCAL-BIA) Withdrawal Status.bru @@ -5,7 +5,7 @@ meta { } get { - url: http://localhost:8081/c2ec/withdrawal-operation/WOPID + url: http://localhost:8081/c2ec/withdrawal-operation/ body: none auth: none } diff --git a/c2ec/auth.go b/c2ec/auth.go @@ -7,6 +7,23 @@ import ( const AUTHORIZATION_HEADER = "Authorization" const BEARER_TOKEN_PREFIX = "Bearer" +// Authentication in C2EC requires following use cases: +// 1. Wallet authenticating itself using the Bank-Integration API +// 2. Provider authentication itself using the Bank-Integration API +// 3. Exchange Wirewatch component, using the Wire-Gateway API +// 3.1 The Wire-Gateway API specifies Basic-Auth (RFC7617) +// +// The Wire-Gateway API is the only API specification which makes +// prescriptions concerning the authenticaion. For simplicity, +// Basic-Auth will be applied to all client types (Exchange, Wallet, Providers) +// To distinguish what client type wants to request, a special format +// for the username type is created. +// +// use `PROVIDER-[PROVIDER_ID]:[PROVIDER_SECRET]` for provider clients +// use `WALLET:` +// +// in case no prefix was specified, it is assumed that the request originates +// from the exchange. func isAllowed(req *http.Request) bool { return true @@ -20,3 +37,20 @@ func isAllowed(req *http.Request) bool { // return strings.EqualFold(token, "") } + +// Is this needed? Understand how the wallet authenticates itself at the exchange currently first. +// https://docs.taler.net/design-documents/049-auth.html#dd48-token +// https://docs.taler.net/core/api-corebank.html#authentication +// +// /accounts/$USERNAME/token +// +// The username in our case is the reserve public key +// registered for withdrawal. At the initial registration +// of the reserve public key we leverage a TOFU trust model. +// during the registration of the reserve public key a new +// access token will be created with a limited lifetime. +// The token will not be refreshable and become invalid +// only after a few minutes. Since the Wallet will register +// a wopid and +// func handleTokenRequest() { +// } diff --git a/c2ec/bank-integration.go b/c2ec/bank-integration.go @@ -17,6 +17,7 @@ const WOPID_PARAMETER = "wopid" const BANK_INTEGRATION_CONFIG_PATTERN = BANK_INTEGRATION_CONFIG_ENDPOINT const WITHDRAWAL_OPERATION_PATTERN = WITHDRAWAL_OPERATION const WITHDRAWAL_OPERATION_BY_WOPID_PATTERN = WITHDRAWAL_OPERATION + "/{" + WOPID_PARAMETER + "}" +const WITHDRAWAL_OPERATION_PAYMENT_PATTERN = WITHDRAWAL_OPERATION_BY_WOPID_PATTERN + "/payment" const WITHDRAWAL_OPERATION_ABORTION_PATTERN = WITHDRAWAL_OPERATION_BY_WOPID_PATTERN + "/abort" const DEFAULT_LONG_POLL_MS = 1000 @@ -42,10 +43,8 @@ type BankIntegrationConfig struct { } type C2ECWithdrawRegistration struct { - Wopid WithdrawalIdentifier `json:"wopid"` - ReservePubKey EddsaPublicKey `json:"reserve_pub_key"` - Amount Amount `json:"amount"` - TerminalId uint64 `json:"terminal_id"` + ReservePubKey EddsaPublicKey `json:"reserve_pub_key"` + TerminalId uint64 `json:"terminal_id"` } type C2ECWithdrawalStatus struct { @@ -98,10 +97,27 @@ func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) { return } + // read and validate the wopid path parameter + wopid := req.PathValue(WOPID_PARAMETER) + if _, ok := any(wopid).(WithdrawalIdentifier); !ok { + + 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 + } + } + err = DB.RegisterWithdrawal( - registration.Wopid, + WithdrawalIdentifier(wopid), registration.ReservePubKey, - registration.Amount, registration.TerminalId, ) @@ -357,34 +373,3 @@ func getWithdrawalOrWriteError(wopid string, res http.ResponseWriter, reqUri str res.Write(withdrawalStatusBytes) } } - -// ---------------------- -// OFFICIAL MODELS -// ---------------------- -// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankWithdrawalOperationPostRequest -type BankWithdrawalOperationPostRequest struct { - ReservePub string `json:"reserve_pub"` - SelectedExchange string `json:"selected_exchange"` -} - -// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankWithdrawalOperationPostResponse -type BankWithdrawalOperationPostResponse struct { - Status WithdrawalOperationStatus `json:"status"` - ConfirmTransferUrl string `json:"confirm_transfer_url"` - TransferDone bool `json:"transfer_done"` -} - -// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankWithdrawalOperationStatus -type BankWithdrawalOperationStatus struct { - Status WithdrawalOperationStatus `json:"status"` - Amount Amount `json:"amount"` - SenderWire string `json:"sender_wire"` - SuggestedExchange string `json:"suggested_exchange"` - ConfirmTransferUrl string `json:"confirm_transfer_url"` - WireTypes []string `json:"wire_types"` - SelectedReservePub string `json:"selected_reserve_pub"` - SelectedExchangeAccount string `json:"selected_exchange_account"` - Aborted bool `json:"aborted"` - SelectionDone bool `json:"selection_done"` - TransferDone bool `json:"transfer_done"` -} diff --git a/c2ec/c2ec-config.yaml b/c2ec/c2ec-config.yaml @@ -5,6 +5,7 @@ c2ec: unix-domain-socket: false unix-socket-path: "c2ec.sock" fail-on-missing-attestors: false # forced if prod=true + credit-account: "payto://iban/CH50030202099498" # this account must be specified at the providers backends as well db: host: "localhost" port: 5432 diff --git a/c2ec/config.go b/c2ec/config.go @@ -19,6 +19,7 @@ type C2ECServerConfig struct { UseUnixDomainSocket bool `yaml:"unix-domain-socket"` UnixSocketPath string `yaml:"unix-socket-path"` StrictAttestors bool `yaml:"fail-on-missing-attestors"` + CreditAccount string `yaml:"credit-account"` } type C2ECDatabseConfig struct { diff --git a/c2ec/db.go b/c2ec/db.go @@ -7,6 +7,7 @@ import ( const PROVIDER_TABLE_NAME = "c2ec.provider" const PROVIDER_FIELD_NAME_ID = "terminal_id" const PROVIDER_FIELD_NAME_NAME = "name" +const PROVIDER_FIELD_NAME_PAYTO_TARGET_TYPE = "payto_target_type" const PROVIDER_FIELD_NAME_BACKEND_URL = "backend_base_url" const PROVIDER_FIELD_NAME_BACKEND_CREDENTIALS = "backend_credentials" @@ -31,9 +32,14 @@ const WITHDRAWAL_FIELD_NAME_LAST_RETRY = "last_retry_ts" const WITHDRAWAL_FIELD_NAME_RETRY_COUNTER = "retry_counter" const WITHDRAWAL_FIELD_NAME_COMPLETION_PROOF = "completion_proof" +const TRANSFER_TABLE_NAME = "c2ec.transfer" +const TRANSFER_FIELD_NAME_ID = "request_uid" +const TRANSFER_FIELD_NAME_HASH = "request_hash" + type Provider struct { ProviderTerminalID int64 `db:"provider_id"` Name string `db:"name"` + PaytoTargetType string `db:"payto_target_type"` BackendBaseURL string `db:"backend_base_url"` BackendCredentials string `db:"backend_credentials"` } @@ -47,7 +53,7 @@ type Terminal struct { } type Withdrawal struct { - WithdrawalId []byte `db:"withdrawal_id"` + WithdrawalId uint64 `db:"withdrawal_id"` Wopid uint64 `db:"wopid"` ReservePubKey []byte `db:"reserve_pub_key"` RegistrationTs int64 `db:"registration_ts"` @@ -67,6 +73,11 @@ type TalerAmountCurrency struct { Curr string `db:"curr"` } +type Transfer struct { + RequestId HashCode `db:"request_uid"` + RequestHash string `db:"request_hash"` +} + // C2ECDatabase defines the operations which a // C2EC compliant database interface must implement // in order to be bound to the c2ec API. @@ -76,13 +87,15 @@ type C2ECDatabase interface { RegisterWithdrawal( wopid WithdrawalIdentifier, resPubKey EddsaPublicKey, - amount Amount, terminalId uint64, ) error // Get the withdrawal associated with the given wopid. GetWithdrawalByWopid(wopid string) (*Withdrawal, error) + // Get the withdrawal associated with the provider specific transaction id. + GetWithdrawalByProviderTransactionId(tid string) (*Withdrawal, error) + // When the terminal receives the notification of the // Provider, that the payment went through, this will // save the provider specific transaction id in the database @@ -110,12 +123,28 @@ type C2ECDatabase interface { completionProof []byte, ) error + // The wire gateway allows the exchange to retrieve transactions + // starting at a certain starting point up until a certain delta + // if the delta is negative, previous transactions relative to the + // starting point are considered. When start is negative, the latest + // id shall be used as starting point. + GetConfirmedWithdrawals(start int, delta int) ([]*Withdrawal, error) + // Get a provider entry by its name GetTerminalProviderByName(name string) (*Provider, error) + // Get a provider entry by its name + GetTerminalProviderByPaytoTargetType(paytoTargetType string) (*Provider, error) + // Get a terminal entry by its identifier GetTerminalById(id int) (*Terminal, error) + // Returns the transfer for the given hashcode. + GetTransferById(requestUid HashCode) (*Transfer, error) + + // Inserts a new transfer into the database. + AddTransfer(requestId HashCode, requestHash string) error + // This will listen for on the given channel // and write results to the out channels. // Errors will be propagated through the errs diff --git a/c2ec/db/0000-c2ec_schema.sql b/c2ec/db/0000-c2ec_schema.sql @@ -35,6 +35,7 @@ COMMENT ON TYPE taler_amount_currency CREATE TABLE IF NOT EXISTS provider ( provider_id INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT NOT NULL UNIQUE, + payto_target_type TEXT NOT NULL UNIQUE, backend_base_url TEXT NOT NULL, backend_credentials TEXT NOT NULL ); @@ -44,6 +45,10 @@ COMMENT ON COLUMN provider.provider_id IS 'Uniquely identifies a provider'; COMMENT ON COLUMN provider.name IS 'Name of the provider, used for selection in transaction proofing'; +COMMENT ON COLUMN provider.payto_target_type + IS 'The Payto target type associated with the provider. Each payto target type + has exctly one provider. This is needed so that the attestor client can be dynamically + selected by C2EC.'; COMMENT ON COLUMN provider.backend_base_url IS 'URL of the provider backend for transaction proofing'; COMMENT ON COLUMN provider.backend_credentials diff --git a/c2ec/db/0000-c2ec_transfers.sql b/c2ec/db/0000-c2ec_transfers.sql @@ -0,0 +1,22 @@ +BEGIN; + +SELECT _v.register_patch('0000-c2ec-transfers', ARRAY['0000-c2ec-schema'], NULL); + +SET search_path TO c2ec; + +CREATE TABLE IF NOT EXISTS transfer ( + request_uid INT8 UNIQUE PRIMARY KEY, + request_hash TEXT NOT NULL +); +COMMENT ON TABLE transfer + IS 'Table storing transfers which are sent by the exchange.'; +COMMENT ON COLUMN transfers.request_uid + IS 'A unique identifier for the transfer. In the case of this + implementation its gonna be the wopid of the withdrawal which + is addressed by the transfer.'; +COMMENT ON COLUMN transfers.request_hash + IS 'Hash of the entire transfer request. Requests with the same + request identifier must have the identical hash to be processed + further.'; + +COMMIT; +\ No newline at end of file diff --git a/c2ec/main.go b/c2ec/main.go @@ -21,6 +21,11 @@ const DEFAULT_C2EC_CONFIG_PATH = "c2ec-config.yaml" var DB C2ECDatabase +// This map contains all clients initialized during the +// startup of the application. The clients SHALL register +// themselfs during the setup!! +var PROVIDER_CLIENTS = map[string]ProviderClient{} + // Starts the c2ec process. // The program takes following arguments (ordered): // 1. path to configuration file (.yaml) (optional) @@ -154,7 +159,7 @@ func setupBankIntegrationRoutes(router *http.ServeMux) { ) router.HandleFunc( - POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_PATTERN, + POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN, handleWithdrawalRegistration, ) @@ -164,7 +169,7 @@ func setupBankIntegrationRoutes(router *http.ServeMux) { ) router.HandleFunc( - POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_BY_WOPID_PATTERN, + POST+BANK_INTEGRATION_API+WITHDRAWAL_OPERATION_PAYMENT_PATTERN, handlePaymentNotification, ) diff --git a/c2ec/payto.go b/c2ec/payto.go @@ -0,0 +1,90 @@ +package main + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +const PAYTO_PARTS_SEPARATOR = "/" + +const PAYTO_SCHEME_PREFIX = "payto://" +const PAYTO_TAGRET_TYPE_IBAN = "iban" +const PAYTO_TARGET_TYPE_WALLEE_TRANSACTION = "wallee-transaction" + +var REGISTERED_TARGET_TYPES = []string{ + "ach", + "bic", + "iban", + "upi", + "bitcoin", + "ilp", + "void", + "ldap", + "eth", + "interac-etransfer", + "wallee-transaction", +} + +// This method parses a payto-uri (RFC 8905: https://www.rfc-editor.org/rfc/rfc8905.html) +// The method only parses the target type "wallee-transaction" as specified +// in the payto GANA registry (https://gana.gnunet.org/payto-payment-target-types/payto_payment_target_types.html) +func ParsePaytoWalleeTransaction(uri string) (string, int, error) { + + if t, i, err := ParsePaytoUri(uri); err != nil { + + tid, err := strconv.Atoi(i) + if err != nil { + return "", -1, errors.New("invalid transaction-id for wallee-transaction") + } + + return t, tid, nil + } else { + return t, -1, err + } +} + +// returns the Payto Target Type and Target Identifier as string +// if the uri is malformed, an error is returned (target type and +// identifier will be empty strings). +func ParsePaytoUri(uri string) (string, string, error) { + + if raw, found := strings.CutPrefix(uri, PAYTO_SCHEME_PREFIX); found { + + parts := strings.Split(raw, PAYTO_PARTS_SEPARATOR) + if len(parts) < 2 { + return "", "", errors.New("invalid wallee-transaction payto-uri") + } + + return parts[0], parts[1], nil + } + return "", "", errors.New("invalid payto-uri") +} + +func FormatPaytoWalleeTransaction(tid int) string { + return fmt.Sprintf("%s%s/%d", + PAYTO_SCHEME_PREFIX, + PAYTO_TARGET_TYPE_WALLEE_TRANSACTION, + tid, + ) +} + +func ParsePaytoTargetType(uri string) (string, error) { + + if raw, found := strings.CutPrefix(uri, PAYTO_SCHEME_PREFIX); found { + + parts := strings.Split(raw, PAYTO_PARTS_SEPARATOR) + if len(parts) < 2 { + return "", errors.New("invalid wallee-transaction payto-uri") + } + + for _, target := range REGISTERED_TARGET_TYPES { + if strings.EqualFold(target, parts[0]) { + return parts[0], nil + } + } + return "", errors.New("target type '" + parts[0] + "' is not registered") + } + return "", errors.New("invalid payto-uri") +} diff --git a/c2ec/postgres.go b/c2ec/postgres.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "errors" "fmt" + "math" "time" "github.com/jackc/pgx/v5" @@ -14,14 +15,16 @@ import ( "github.com/jackc/pgxlisten" ) +const PS_ASC_SELECTOR = "ASC" +const PS_DESC_SELECTOR = "DESC" + 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 + "," + - WITHDRAWAL_FIELD_NAME_AMOUNT + "," + WITHDRAWAL_FIELD_NAME_TERMINAL_ID + ")" + - " VALUES ($1, $2, $3, $4, $5, $6);" + " VALUES ($1, $2, $3, $4, $5);" const PS_GET_UNCONFIRMED_WITHDRAWALS = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME + " WHERE " + WITHDRAWAL_FIELD_NAME_TRANSACTION_ID + " IS NOT NULL" + @@ -40,15 +43,33 @@ const PS_FINALISE_PAYMENT = "UPDATE " + WITHDRAWAL_TABLE_NAME + " SET (" + " = ($1, $2)" + " WHERE " + WITHDRAWAL_FIELD_NAME_ID + "=$3" +const PS_CONFIRMED_TRANSACTIONS = "SELECT * FROM " + WITHDRAWAL_TABLE_NAME + + " LIMIT $1" + + " OFFSET $2" + + " ORDER BY " + WITHDRAWAL_FIELD_NAME_ID + " $3" + 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_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_HASH + ")" + + " VALUES ($1, $2)" + // Postgres implementation of the C2ECDatabase type C2ECPostgres struct { C2ECDatabase @@ -92,7 +113,6 @@ func NewC2ECPostgres(cfg *C2ECDatabseConfig) (*C2ECPostgres, error) { func (db *C2ECPostgres) RegisterWithdrawal( wopid WithdrawalIdentifier, resPubKey EddsaPublicKey, - amount Amount, terminalId uint64, ) error { @@ -104,7 +124,6 @@ func (db *C2ECPostgres) RegisterWithdrawal( resPubKey, SELECTED, ts.Unix(), - amount, terminalId, ) if err != nil { @@ -141,6 +160,32 @@ func (db *C2ECPostgres) GetWithdrawalByWopid(wopid string) (*Withdrawal, error) } } +func (db *C2ECPostgres) GetWithdrawalByProviderTransactionId(tid string) (*Withdrawal, error) { + if row, err := db.pool.Query( + db.ctx, + PS_GET_WITHDRAWAL_BY_PTID, + tid, + ); err != nil { + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) + if err != nil { + return nil, err + } + + if len(withdrawals) < 1 { + return nil, nil + } + return withdrawals[0], nil + } +} + func (db *C2ECPostgres) NotifyPayment( wopid WithdrawalIdentifier, providerTransactionId string, @@ -209,6 +254,65 @@ func (db *C2ECPostgres) FinaliseWithdrawal( 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) { + + sort := PS_ASC_SELECTOR + if delta < 0 { + sort = PS_DESC_SELECTOR + } + + 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, + PS_CONFIRMED_TRANSACTIONS, + limit, + "MAX("+WITHDRAWAL_FIELD_NAME_ID+")", + sort, + ) + } else { + row, err = db.pool.Query( + db.ctx, + PS_CONFIRMED_TRANSACTIONS, + limit, + offset, + sort, + ) + } + + if err != nil { + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) + if err != nil { + return nil, err + } + + return withdrawals, nil + } +} + func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*Provider, error) { if row, err := db.pool.Query( @@ -237,6 +341,34 @@ func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*Provider, error } } +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 { + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + provider, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Provider]) + if err != nil { + return nil, err + } + + if len(provider) < 1 { + return nil, nil + } + + return provider[0], nil + } +} + func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) { if row, err := db.pool.Query( @@ -252,13 +384,60 @@ func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) { defer row.Close() - terminal, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Terminal]) + terminals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Terminal]) + if err != nil { + return nil, err + } + + if len(terminals) < 1 { + return nil, nil + } + + return terminals[0], nil + } +} + +func (db *C2ECPostgres) GetTransferById(requestUid HashCode) (*Transfer, error) { + + if row, err := db.pool.Query( + db.ctx, + PS_GET_TRANSFER_BY_ID, + requestUid, + ); err != nil { + if row != nil { + row.Close() + } + return nil, err + } else { + + defer row.Close() + + transfers, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Transfer]) if err != nil { return nil, err } - return terminal[0], nil + if len(transfers) < 1 { + return nil, nil + } + return transfers[0], nil } + +} + +func (db *C2ECPostgres) AddTransfer(requestId HashCode, requestHash string) error { + + res, err := db.pool.Query( + db.ctx, + PS_ADD_TRANSFER, + requestId, + requestHash, + ) + if err != nil { + return err + } + res.Close() + return nil } func (db *C2ECPostgres) ListenForWithdrawalStatusChange( diff --git a/c2ec/provider-client.go b/c2ec/provider-client.go @@ -5,7 +5,7 @@ type ProviderTransaction interface { Bytes() []byte } -type ProviderClient[T any] interface { +type ProviderClient interface { SetupClient(provider *Provider) error GetTransaction(transactionId string) (ProviderTransaction, error) Refund(transactionId string) error diff --git a/c2ec/simulation-attestor.go b/c2ec/simulation-attestor.go @@ -12,9 +12,11 @@ import ( ) type SimulationAttestor struct { + Attestor[SimulationClient] + listener *pgxlisten.Listener provider *Provider - providerClient ProviderClient[WalleeClient] + providerClient ProviderClient } func (wa *SimulationAttestor) Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *pgconn.Notification, error) { @@ -84,15 +86,11 @@ func (wa *SimulationAttestor) Attest(withdrawalId int, providerTransactionId str err = DB.FinaliseWithdrawal(withdrawalId, CONFIRMED, transaction.Bytes()) if err != nil { - // TODO : do we abort the withdrawal here?? errs <- err } } else { - // TODO : this might be too early ?! What if the payment was not yet - // processed by the Wallee backend? Needs testing. err = DB.FinaliseWithdrawal(withdrawalId, ABORTED, transaction.Bytes()) if err != nil { - // TODO : do we abort the withdrawal here?? errs <- err } } diff --git a/c2ec/simulation-client.go b/c2ec/simulation-client.go @@ -11,7 +11,7 @@ type SimulationTransaction struct { } type SimulationClient struct { - ProviderClient[SimulationTransaction] + ProviderClient // toggle this to simulate failed transactions. AllowNextWithdrawal bool @@ -22,9 +22,11 @@ func (st *SimulationTransaction) AllowWithdrawal() bool { return st.allow } -func (*SimulationClient) SetupClient(p *Provider) error { +func (sc *SimulationClient) SetupClient(p *Provider) error { fmt.Println("setting up simulation client. probably not what you want in production") + + PROVIDER_CLIENTS["Simulation"] = sc return nil } diff --git a/c2ec/wallee-attestor.go b/c2ec/wallee-attestor.go @@ -12,9 +12,11 @@ import ( ) type WalleeAttestor struct { + Attestor[WalleeClient] + listener *pgxlisten.Listener provider *Provider - providerClient ProviderClient[WalleeClient] + providerClient ProviderClient } func (wa *WalleeAttestor) Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *pgconn.Notification, error) { diff --git a/c2ec/wallee-client.go b/c2ec/wallee-client.go @@ -30,7 +30,7 @@ type WalleeCredentials struct { } type WalleeClient struct { - ProviderClient[WalleeTransaction] + ProviderClient name string baseUrl string @@ -52,6 +52,9 @@ func (w *WalleeClient) SetupClient(p *Provider) error { w.name = p.Name w.baseUrl = p.BackendBaseURL w.credentials = creds + + PROVIDER_CLIENTS[w.name] = w + return nil } diff --git a/c2ec/wire-gateway.go b/c2ec/wire-gateway.go @@ -1,9 +1,13 @@ package main import ( - "bytes" + "context" + "crypto" + "encoding/base64" "log" http "net/http" + "strconv" + "time" ) const WIRE_GATEWAY_CONFIG_ENDPOINT = "/config" @@ -15,6 +19,8 @@ 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"` @@ -54,20 +60,21 @@ type IncomingReserveTransaction struct { ReservePub EddsaPublicKey `json:"reserve_pub"` } -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingHistory -type OutgoingHistory struct { - OutgoingBankTransaction []OutgoingBankTransaction `json:"outgoing_bank_transaction"` - DebitAccount string `json:"debit_account"` -} - -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-OutgoingBankTransaction -type OutgoingBankTransaction struct { - RowId int `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 { + 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 = "" + t.ReservePub = EddsaPublicKey(w.ReservePubKey) + t.RowId = int(w.WithdrawalId) + t.Type = INCOMING_RESERVE_TRANSACTION_TYPE + return t } func wireGatewayConfig(res http.ResponseWriter, req *http.Request) { @@ -90,14 +97,250 @@ func wireGatewayConfig(res http.ResponseWriter, req *http.Request) { func transfer(res http.ResponseWriter, req *http.Request) { - res.WriteHeader(HTTP_OK) - res.Write(bytes.NewBufferString("retrieved transfer request").Bytes()) + 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 + } + + t, err := DB.GetTransferById(transfer.RequestUid) + 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 + } + + body := make([]byte, req.ContentLength) + _, err = req.Body.Read(body) + if err != nil { + err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{ + TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_READ_BODY_FAILED", + Title: "reading body failed", + Detail: "there was an error processing the request body (error: " + err.Error() + ")", + Instance: req.RequestURI, + }) + if err != nil { + res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) + } + return + } + requestHash := hashRequest(body) + + if t == nil { + // no transfer for this request_id -> generate new + err := DB.AddTransfer(transfer.RequestUid, requestHash) + 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 requestHash != t.RequestHash { + err := WriteProblem(res, HTTP_BAD_REQUEST, &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 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", + 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, len(withdrawals)) + for _, w := range withdrawals { + transactions = append(transactions, NewIncomingReserveTransaction(w)) + } + + 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(bytes.NewBufferString("retrieved history incoming request").Bytes()) + res.Write(enc) } // This method is currently dead and implemented for API conformance @@ -107,25 +350,22 @@ func historyOutgoing(res http.ResponseWriter, req *http.Request) { res.WriteHeader(HTTP_BAD_REQUEST) } -// --------------------- -// TESTING (ONLY ADMINS) -// --------------------- - -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddIncomingRequest -type AddIncomingRequest struct { - Amount Amount `json:"amount"` - ReservcePub EddsaPublicKey `json:"reserve_pub"` - DebitAccount string `json:"debit_account"` -} - -// https://docs.taler.net/core/api-bank-wire.html#tsref-type-AddIncomingResponse -type AddIncomingResponse struct { - Timestamp Timestamp `json:"timestamp"` -} - // 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) } + +// hashes the request and encodes the request in base64. +// use this function to hash a transfer request and compare +// the result to the content of the database. +func hashRequest(transferBytes []byte) string { + + h := crypto.SHA256.New() + h.Reset() + h.Write(transferBytes) + result := make([]byte, 32) + result = h.Sum(result) + return base64.StdEncoding.EncodeToString(result) +}