api-wire-gateway.go (19290B)
1 // This file is part of taler-cashless2ecash. 2 // Copyright (C) 2024 Joel Häberli 3 // 4 // taler-cashless2ecash is free software: you can redistribute it and/or modify it 5 // under the terms of the GNU Affero General Public License as published 6 // by the Free Software Foundation, either version 3 of the License, 7 // or (at your option) any later version. 8 // 9 // taler-cashless2ecash is distributed in the hope that it will be useful, but 10 // WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 // Affero General Public License for more details. 13 // 14 // You should have received a copy of the GNU Affero General Public License 15 // along with this program. If not, see <http://www.gnu.org/licenses/>. 16 // 17 // SPDX-License-Identifier: AGPL3.0-or-later 18 19 package internal_api 20 21 import ( 22 "bytes" 23 internal_utils "c2ec/internal/utils" 24 "c2ec/pkg/config" 25 "c2ec/pkg/db" 26 "c2ec/pkg/provider" 27 "errors" 28 "fmt" 29 "log" 30 "net/http" 31 "strconv" 32 "time" 33 ) 34 35 const INCOMING_RESERVE_TRANSACTION_TYPE = "RESERVE" 36 37 // https://docs.taler.net/core/api-bank-wire.html#tsref-type-WireConfig 38 type WireConfig struct { 39 Name string `json:"name"` 40 Version string `json:"version"` 41 Currency string `json:"currency"` 42 Implementation string `json:"implementation"` 43 } 44 45 // https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferRequest 46 type TransferRequest struct { 47 RequestUid string `json:"request_uid"` 48 Amount string `json:"amount"` 49 ExchangeBaseUrl string `json:"exchange_base_url"` 50 Wtid string `json:"wtid"` 51 CreditAccount string `json:"credit_account"` 52 } 53 54 // https://docs.taler.net/core/api-bank-wire.html#tsref-type-TransferResponse 55 type TransferResponse struct { 56 Timestamp internal_utils.Timestamp `json:"timestamp"` 57 RowId int `json:"row_id"` 58 } 59 60 // https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingHistory 61 type IncomingHistory struct { 62 IncomingTransactions []IncomingReserveTransaction `json:"incoming_transactions"` 63 CreditAccount string `json:"credit_account"` 64 } 65 66 // type RESERVE | https://docs.taler.net/core/api-bank-wire.html#tsref-type-IncomingReserveTransaction 67 type IncomingReserveTransaction struct { 68 Type string `json:"type"` 69 RowId int `json:"row_id"` 70 Date internal_utils.Timestamp `json:"date"` 71 Amount string `json:"amount"` 72 DebitAccount string `json:"debit_account"` 73 ReservePub string `json:"reserve_pub"` 74 } 75 76 type OutgoingHistory struct { 77 OutgoingTransactions []*OutgoingBankTransaction `json:"outgoing_transactions"` 78 DebitAccount string `json:"debit_account"` 79 } 80 81 type OutgoingBankTransaction struct { 82 RowId uint64 `json:"row_id"` 83 Date internal_utils.Timestamp `json:"date"` 84 Amount string `json:"amount"` 85 CreditAccount string `json:"credit_account"` 86 Wtid internal_utils.ShortHashCode `json:"wtid"` 87 ExchangeBaseUrl string `json:"exchange_base_url"` 88 } 89 90 func NewIncomingReserveTransaction(w *db.Withdrawal) *IncomingReserveTransaction { 91 92 if w == nil { 93 internal_utils.LogWarn("wire-gateway", "the withdrawal was nil") 94 return nil 95 } 96 97 prvdr, err := db.DB.GetProviderByTerminal(w.TerminalId) 98 if err != nil { 99 internal_utils.LogError("wire-gateway", err) 100 return nil 101 } 102 103 client := provider.PROVIDER_CLIENTS[prvdr.Name] 104 if client == nil { 105 internal_utils.LogError("wire-gateway", errors.New("no provider client with name="+prvdr.Name)) 106 return nil 107 } 108 109 t := new(IncomingReserveTransaction) 110 a, err := internal_utils.ToAmount(w.Amount) 111 if err != nil { 112 internal_utils.LogError("wire-gateway", err) 113 return nil 114 } 115 t.Amount = internal_utils.FormatAmount(a, config.CONFIG.Server.CurrencyFractionDigits) 116 t.Date = internal_utils.Timestamp{ 117 Ts: int(w.RegistrationTs), 118 } 119 t.DebitAccount = client.FormatPayto(w) 120 t.ReservePub = internal_utils.FormatEddsaPubKey(w.ReservePubKey) 121 if w.ConfirmedRowId == nil { 122 internal_utils.LogError("wire-gateway", fmt.Errorf("expected non-nil confirmed_row_id for withdrawal_row_id=%d", w.WithdrawalRowId)) 123 return nil 124 } 125 t.RowId = int(*w.ConfirmedRowId) 126 t.Type = INCOMING_RESERVE_TRANSACTION_TYPE 127 return t 128 } 129 130 func NewOutgoingBankTransaction(tr *db.Transfer) *OutgoingBankTransaction { 131 t := new(OutgoingBankTransaction) 132 a, err := internal_utils.ToAmount(tr.Amount) 133 if err != nil { 134 internal_utils.LogError("wire-gateway", err) 135 return nil 136 } 137 t.Amount = internal_utils.FormatAmount(a, config.CONFIG.Server.CurrencyFractionDigits) 138 t.Date = internal_utils.Timestamp{ 139 Ts: int(tr.TransferTs), 140 } 141 t.CreditAccount = tr.CreditAccount 142 t.ExchangeBaseUrl = tr.ExchangeBaseUrl 143 if tr.TransferredRowId == nil { 144 internal_utils.LogError("wire-gateway", fmt.Errorf("expected non-nil transferred_row_id for row_id=%d", tr.RowId)) 145 return nil 146 } 147 t.RowId = uint64(*tr.TransferredRowId) 148 t.Wtid = internal_utils.ShortHashCode(tr.Wtid) 149 return t 150 } 151 152 func WireGatewayConfig(res http.ResponseWriter, req *http.Request) { 153 154 cfg := WireConfig{ 155 Name: "taler-wire-gateway", 156 Currency: config.CONFIG.Server.Currency, 157 Version: "0:0:1", 158 Implementation: "", 159 } 160 161 serializedCfg, err := internal_utils.NewJsonCodec[WireConfig]().EncodeToBytes(&cfg) 162 if err != nil { 163 log.Default().Printf("failed serializing config: %s", err.Error()) 164 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 165 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 166 return 167 } 168 169 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) 170 res.WriteHeader(internal_utils.HTTP_OK) 171 res.Write(serializedCfg) 172 } 173 174 func Transfer(res http.ResponseWriter, req *http.Request) { 175 176 auth := AuthenticateWirewatcher(req) 177 if !auth { 178 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) 179 res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) 180 return 181 } 182 183 jsonCodec := internal_utils.NewJsonCodec[TransferRequest]() 184 transfer, err := internal_utils.ReadStructFromBody[TransferRequest](req, jsonCodec) 185 if err != nil { 186 internal_utils.LogError("wire-gateway-api", err) 187 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) 188 res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) 189 return 190 } 191 192 if transfer.Amount == "" || transfer.CreditAccount == "" || transfer.RequestUid == "" { 193 internal_utils.LogError("wire-gateway-api", errors.New("invalid request")) 194 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) 195 res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) 196 return 197 } 198 199 paytoTargetType, tid, err := internal_utils.ParsePaytoUri(transfer.CreditAccount) 200 internal_utils.LogInfo("wire-gateway-api", fmt.Sprintf("parsed payto-target-type='%s'", paytoTargetType)) 201 if err != nil { 202 internal_utils.LogError("wire-gateway-api", err) 203 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_BAD_REQUEST) 204 res.WriteHeader(internal_utils.HTTP_BAD_REQUEST) 205 return 206 } 207 208 p, err := db.DB.GetTerminalProviderByPaytoTargetType(paytoTargetType) 209 if err != nil { 210 internal_utils.LogWarn("wire-gateway-api", "unable to find provider for provider-target-type="+paytoTargetType) 211 internal_utils.LogError("wire-gateway-api", err) 212 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 213 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 214 return 215 } 216 217 decodedRequestUid := bytes.NewBufferString(transfer.RequestUid).Bytes() 218 t, err := db.DB.GetTransferById(decodedRequestUid) 219 if err != nil { 220 internal_utils.LogWarn("wire-gateway-api", "failed retrieving transfer for requestUid="+transfer.RequestUid) 221 internal_utils.LogError("wire-gateway-api", err) 222 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 223 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 224 return 225 } 226 227 if t == nil { 228 229 // limitation: currently only full refunds are implemented. 230 // this means that we also check that no other transaction 231 // to the same recipient with this credit_account is present. 232 transfers, err := db.DB.GetTransfersByCreditAccount(transfer.CreditAccount) 233 if err != nil { 234 internal_utils.LogWarn("wire-gateway-api", "looking for transfers with the credit account failed") 235 internal_utils.LogError("wire-gateway-api", err) 236 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 237 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 238 return 239 } 240 241 if len(transfers) > 0 { 242 // when the withdrawal was already refunded we act like everything is 243 // ok, because the transfer was registered earlier and the customer 244 // will get their money back (or already have). The Exchange will 245 // not loose money on the other hand because the refund is done twice. 246 internal_utils.LogWarn("wire-gateway-api", "full refunds only limitation") 247 internal_utils.LogError("wire-gateway-api", fmt.Errorf("currently only full refunds are supported. Withdrawal %s already refunded", transfer.CreditAccount)) 248 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) 249 res.WriteHeader(internal_utils.HTTP_OK) 250 return 251 } 252 253 // no transfer for this request_id -> generate new 254 amount, err := internal_utils.ParseAmount(transfer.Amount, config.CONFIG.Server.CurrencyFractionDigits) 255 if err != nil { 256 internal_utils.LogWarn("wire-gateway-api", "failed parsing amount") 257 internal_utils.LogError("wire-gateway-api", err) 258 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 259 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 260 return 261 } 262 err = db.DB.AddTransfer( 263 decodedRequestUid, 264 amount, 265 transfer.ExchangeBaseUrl, 266 string(transfer.Wtid), 267 transfer.CreditAccount, 268 time.Now(), 269 ) 270 if err != nil { 271 internal_utils.LogWarn("wire-gateway-api", "failed adding new transfer entry to database") 272 internal_utils.LogError("wire-gateway-api", err) 273 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 274 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 275 return 276 } 277 } else { 278 279 // check that the wanted provider is configured. 280 refundClient := provider.PROVIDER_CLIENTS[p.Name] 281 if refundClient == nil { 282 internal_utils.LogError("wire-gateway-api", errors.New("client for provider "+p.Name+" not initialized")) 283 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 284 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 285 return 286 } 287 288 // the transfer is only processed if the body matches. 289 ta, err := internal_utils.ToAmount(t.Amount) 290 if err != nil { 291 internal_utils.LogError("wire-gateway-api", err) 292 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 293 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 294 return 295 } 296 if transfer.Amount != internal_utils.FormatAmount(ta, config.CONFIG.Server.CurrencyFractionDigits) || 297 transfer.ExchangeBaseUrl != t.ExchangeBaseUrl || 298 transfer.Wtid != t.Wtid || 299 transfer.CreditAccount != t.CreditAccount { 300 301 internal_utils.LogWarn("wire-gateway-api", "idempotency violation") 302 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_CONFLICT) 303 res.WriteHeader(internal_utils.HTTP_CONFLICT) 304 return 305 } 306 307 w, err := db.DB.GetWithdrawalByProviderTransactionId(tid) 308 if err != nil || w == nil { 309 internal_utils.LogWarn("wire-gateway-api", "unable to find withdrawal with given provider transaction id") 310 internal_utils.LogError("wire-gateway-api", err) 311 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 312 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 313 return 314 } 315 } 316 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) 317 } 318 319 // :query start: *Optional.* 320 // 321 // Row identifier to explicitly set the *starting point* of the query. 322 // 323 // :query delta: 324 // 325 // The *delta* value that determines the range of the query. 326 // 327 // :query long_poll_ms: *Optional.* 328 // 329 // If this parameter is specified and the result of the query would be empty, 330 // the bank will wait up to ``long_poll_ms`` milliseconds for new transactions 331 // that match the query to arrive and only then send the HTTP response. 332 // A client must never rely on this behavior, as the bank may return a response 333 // immediately or after waiting only a fraction of ``long_poll_ms``. 334 func HistoryIncoming(res http.ResponseWriter, req *http.Request) { 335 336 auth := AuthenticateWirewatcher(req) 337 if !auth { 338 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) 339 res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) 340 return 341 } 342 343 // read and validate request query parameters 344 timeOfReq := time.Now() 345 shouldStartLongPoll := true 346 var longPollMilli int 347 if longPollMilliPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 348 "long_poll_ms", strconv.Atoi, req, res, 349 ); accepted { 350 if longPollMilliPtr != nil { 351 longPollMilli = *longPollMilliPtr 352 } else { 353 // this means parameter was not given. 354 // no long polling (simple get) 355 shouldStartLongPoll = false 356 } 357 } 358 359 var start = 0 // read most recent entries by default 360 if startPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 361 "start", strconv.Atoi, req, res, 362 ); accepted { 363 if startPtr != nil { 364 start = *startPtr 365 } 366 } else { 367 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") 368 internal_utils.LogWarn("wire-gateway-api", "invalid parameter") 369 return 370 } 371 372 var delta = 0 373 if deltaPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 374 "delta", strconv.Atoi, req, res, 375 ); accepted { 376 if deltaPtr != nil { 377 delta = *deltaPtr 378 } 379 } else { 380 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, "application/json") 381 internal_utils.LogWarn("wire-gateway-api", "invalid parameter") 382 return 383 } 384 385 if delta == 0 { 386 delta = 10 387 } 388 389 if shouldStartLongPoll { 390 391 // this will just wait / block until the milliseconds are exceeded. 392 time.Sleep(time.Duration(longPollMilli) * time.Millisecond) 393 } 394 395 withdrawals, err := db.DB.GetConfirmedWithdrawals(start, delta, timeOfReq) 396 397 if err != nil { 398 internal_utils.LogError("wire-gateway-api", err) 399 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 400 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 401 return 402 } 403 404 if len(withdrawals) < 1 { 405 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) 406 res.WriteHeader(internal_utils.HTTP_NO_CONTENT) 407 return 408 } 409 410 transactions := make([]IncomingReserveTransaction, 0) 411 for _, w := range withdrawals { 412 if w.Amount.Val == 0 && w.Amount.Frac == 0 { 413 internal_utils.LogInfo("wire-gateway-api", "ignoring zero amount withdrawal") 414 continue 415 } 416 if w.ReservePubKey == nil || len(w.ReservePubKey) == 0 { 417 internal_utils.LogWarn("wire-gateway-api", "ignoring confirmed withdrawal with no reserve public key (probably a test transaction)") 418 continue 419 } 420 transaction := NewIncomingReserveTransaction(w) 421 if transaction != nil { 422 transactions = append(transactions, *transaction) 423 } 424 } 425 426 hist := IncomingHistory{ 427 IncomingTransactions: transactions, 428 CreditAccount: config.CONFIG.Server.CreditAccount, 429 } 430 431 encoder := internal_utils.NewJsonCodec[IncomingHistory]() 432 enc, err := encoder.EncodeToBytes(&hist) 433 if err != nil { 434 internal_utils.LogError("wire-gateway-api", err) 435 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 436 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 437 return 438 } 439 440 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) 441 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) 442 res.WriteHeader(internal_utils.HTTP_OK) 443 res.Write(enc) 444 } 445 446 func HistoryOutgoing(res http.ResponseWriter, req *http.Request) { 447 448 auth := AuthenticateWirewatcher(req) 449 if !auth { 450 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_UNAUTHORIZED) 451 res.WriteHeader(internal_utils.HTTP_UNAUTHORIZED) 452 return 453 } 454 455 // read and validate request query parameters 456 timeOfReq := time.Now() 457 shouldStartLongPoll := true 458 var longPollMilli int 459 if longPollMilliPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 460 "long_poll_ms", strconv.Atoi, req, res, 461 ); accepted { 462 } else { 463 if longPollMilliPtr != nil { 464 longPollMilli = *longPollMilliPtr 465 } else { 466 // this means parameter was not given. 467 // no long polling (simple get) 468 shouldStartLongPoll = false 469 } 470 } 471 472 var start int 473 if startPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 474 "start", strconv.Atoi, req, res, 475 ); accepted { 476 } else { 477 if startPtr != nil { 478 start = *startPtr 479 } 480 } 481 482 var delta int 483 if deltaPtr, accepted := internal_utils.AcceptOptionalParamOrWriteResponse( 484 "delta", strconv.Atoi, req, res, 485 ); accepted { 486 } else { 487 if deltaPtr != nil { 488 delta = *deltaPtr 489 } 490 } 491 492 if delta == 0 { 493 delta = 10 494 } 495 496 if shouldStartLongPoll { 497 498 // this will just wait / block until the milliseconds are exceeded. 499 time.Sleep(time.Duration(longPollMilli) * time.Millisecond) 500 } 501 502 transfers, err := db.DB.GetTransfers(start, delta, timeOfReq) 503 504 if err != nil { 505 internal_utils.LogError("wire-gateway-api", err) 506 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 507 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 508 return 509 } 510 511 filtered := make([]*db.Transfer, 0) 512 for _, t := range transfers { 513 if t.Status == 0 { 514 // only consider transfer which were successful 515 filtered = append(filtered, t) 516 } 517 } 518 519 if len(filtered) < 1 { 520 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NO_CONTENT) 521 res.WriteHeader(internal_utils.HTTP_NO_CONTENT) 522 return 523 } 524 525 transactions := make([]*OutgoingBankTransaction, len(filtered)) 526 for _, t := range filtered { 527 transactions = append(transactions, NewOutgoingBankTransaction(t)) 528 } 529 transactions = internal_utils.RemoveNulls(transactions) 530 531 outgoingHistory := OutgoingHistory{ 532 OutgoingTransactions: transactions, 533 DebitAccount: config.CONFIG.Server.CreditAccount, 534 } 535 encoder := internal_utils.NewJsonCodec[OutgoingHistory]() 536 enc, err := encoder.EncodeToBytes(&outgoingHistory) 537 if err != nil { 538 internal_utils.LogError("wire-gateway-api", err) 539 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 540 res.WriteHeader(internal_utils.HTTP_INTERNAL_SERVER_ERROR) 541 return 542 } 543 544 res.Header().Add(internal_utils.CONTENT_TYPE_HEADER, encoder.HttpApplicationContentHeader()) 545 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_OK) 546 res.WriteHeader(internal_utils.HTTP_OK) 547 res.Write(enc) 548 } 549 550 // This method is currently dead and implemented for API conformance 551 func AdminAddIncoming(res http.ResponseWriter, req *http.Request) { 552 553 // not implemented, because not used 554 internal_utils.SetLastResponseCodeForLogger(internal_utils.HTTP_NOT_IMPLEMENTED) 555 res.WriteHeader(internal_utils.HTTP_NOT_IMPLEMENTED) 556 }