-
Notifications
You must be signed in to change notification settings - Fork 0
/
tx_approve.go
431 lines (377 loc) · 14.2 KB
/
tx_approve.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
package serve
import (
"context"
"database/sql"
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/shantanu-hashcash/go/amount"
"github.com/shantanu-hashcash/go/clients/auroraclient"
"github.com/shantanu-hashcash/go/keypair"
"github.com/shantanu-hashcash/go/services/regulated-assets-approval-server/internal/serve/httperror"
"github.com/shantanu-hashcash/go/support/errors"
"github.com/shantanu-hashcash/go/support/http/httpdecode"
"github.com/shantanu-hashcash/go/support/log"
"github.com/shantanu-hashcash/go/txnbuild"
)
type txApproveHandler struct {
issuerKP *keypair.Full
assetCode string
auroraClient auroraclient.ClientInterface
networkPassphrase string
db *sqlx.DB
kycThreshold int64
baseURL string
}
type txApproveRequest struct {
Tx string `json:"tx" form:"tx"`
}
// validate performs some validations on the provided handler data.
func (h txApproveHandler) validate() error {
if h.issuerKP == nil {
return errors.New("issuer keypair cannot be nil")
}
if h.assetCode == "" {
return errors.New("asset code cannot be empty")
}
if h.auroraClient == nil {
return errors.New("aurora client cannot be nil")
}
if h.networkPassphrase == "" {
return errors.New("network passphrase cannot be empty")
}
if h.db == nil {
return errors.New("database cannot be nil")
}
if h.kycThreshold <= 0 {
return errors.New("kyc threshold cannot be less than or equal to zero")
}
if h.baseURL == "" {
return errors.New("base url cannot be empty")
}
return nil
}
func (h txApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := h.validate()
if err != nil {
log.Ctx(ctx).Error(errors.Wrap(err, "validating txApproveHandler"))
httperror.InternalServer.Render(w)
return
}
in := txApproveRequest{}
err = httpdecode.Decode(r, &in)
if err != nil {
log.Ctx(ctx).Error(errors.Wrap(err, "decoding txApproveRequest"))
httperror.BadRequest.Render(w)
return
}
txApproveResp, err := h.txApprove(ctx, in)
if err != nil {
log.Ctx(ctx).Error(errors.Wrap(err, "validating the input transaction for approval"))
httperror.InternalServer.Render(w)
return
}
txApproveResp.Render(w)
}
// validateInput validates if the input parameters contain a valid transaction
// and if the source account is not set in a way that would harm the issuer.
func (h txApproveHandler) validateInput(ctx context.Context, in txApproveRequest) (*txApprovalResponse, *txnbuild.Transaction) {
if in.Tx == "" {
log.Ctx(ctx).Error(`request is missing parameter "tx".`)
return NewRejectedTxApprovalResponse(`Missing parameter "tx".`), nil
}
genericTx, err := txnbuild.TransactionFromXDR(in.Tx)
if err != nil {
log.Ctx(ctx).Error(errors.Wrap(err, "parsing transaction xdr"))
return NewRejectedTxApprovalResponse(`Invalid parameter "tx".`), nil
}
tx, ok := genericTx.Transaction()
if !ok {
log.Ctx(ctx).Error(`invalid parameter "tx", generic transaction not given.`)
return NewRejectedTxApprovalResponse(`Invalid parameter "tx".`), nil
}
if tx.SourceAccount().AccountID == h.issuerKP.Address() {
log.Ctx(ctx).Errorf("transaction sourceAccount is the same as the server issuer account %s", h.issuerKP.Address())
return NewRejectedTxApprovalResponse("Transaction source account is invalid."), nil
}
// only AllowTrust operations can have the issuer as their source account
for _, op := range tx.Operations() {
if _, ok := op.(*txnbuild.AllowTrust); ok {
continue
}
if op.GetSourceAccount() == h.issuerKP.Address() {
log.Ctx(ctx).Error("transaction contains one or more unauthorized operations where source account is the issuer account")
return NewRejectedTxApprovalResponse("There are one or more unauthorized operations in the provided transaction."), nil
}
}
return nil, tx
}
// txApprove is called to validate the input transaction.
func (h txApproveHandler) txApprove(ctx context.Context, in txApproveRequest) (resp *txApprovalResponse, err error) {
defer func() {
log.Ctx(ctx).Debug("==== will log responses ====")
log.Ctx(ctx).Debugf("req: %+v", in)
log.Ctx(ctx).Debugf("resp: %+v", resp)
log.Ctx(ctx).Debugf("err: %+v", err)
log.Ctx(ctx).Debug("==== did log responses ====")
}()
rejectedResponse, tx := h.validateInput(ctx, in)
if rejectedResponse != nil {
return rejectedResponse, nil
}
txSuccessResp, err := h.handleSuccessResponseIfNeeded(ctx, tx)
if err != nil {
return nil, errors.Wrap(err, "checking if transaction in request was compliant")
}
if txSuccessResp != nil {
return txSuccessResp, nil
}
// validate the revisable transaction has one operation.
if len(tx.Operations()) != 1 {
return NewRejectedTxApprovalResponse("Please submit a transaction with exactly one operation of type payment."), nil
}
paymentOp, ok := tx.Operations()[0].(*txnbuild.Payment)
if !ok {
log.Ctx(ctx).Error("transaction does not contain a payment operation")
return NewRejectedTxApprovalResponse("There is one or more unauthorized operations in the provided transaction."), nil
}
paymentSource := paymentOp.SourceAccount
if paymentSource == "" {
paymentSource = tx.SourceAccount().AccountID
}
if paymentOp.Destination == h.issuerKP.Address() {
return NewRejectedTxApprovalResponse("Can't transfer asset to its issuer."), nil
}
// validate payment asset is the one supported by the issuer
issuerAddress := h.issuerKP.Address()
if paymentOp.Asset.GetCode() != h.assetCode || paymentOp.Asset.GetIssuer() != issuerAddress {
log.Ctx(ctx).Error(`the payment asset is not supported by this issuer`)
return NewRejectedTxApprovalResponse("The payment asset is not supported by this issuer."), nil
}
acc, err := h.auroraClient.AccountDetail(auroraclient.AccountRequest{AccountID: paymentSource})
if err != nil {
return nil, errors.Wrapf(err, "getting detail for payment source account %s", paymentSource)
}
// validate the sequence number
if tx.SourceAccount().Sequence != acc.Sequence+1 {
log.Ctx(ctx).Errorf(`invalid transaction sequence number tx.SourceAccount().Sequence: %d, accountSequence+1: %d`, tx.SourceAccount().Sequence, acc.Sequence+1)
return NewRejectedTxApprovalResponse("Invalid transaction sequence number."), nil
}
actionRequiredResponse, err := h.handleActionRequiredResponseIfNeeded(ctx, paymentSource, paymentOp)
if err != nil {
return nil, errors.Wrap(err, "handling KYC required payment")
}
if actionRequiredResponse != nil {
return actionRequiredResponse, nil
}
// build the transaction
revisedOperations := []txnbuild.Operation{
&txnbuild.AllowTrust{
Trustor: paymentSource,
Type: paymentOp.Asset,
Authorize: true,
SourceAccount: issuerAddress,
},
&txnbuild.AllowTrust{
Trustor: paymentOp.Destination,
Type: paymentOp.Asset,
Authorize: true,
SourceAccount: issuerAddress,
},
paymentOp,
&txnbuild.AllowTrust{
Trustor: paymentOp.Destination,
Type: paymentOp.Asset,
Authorize: false,
SourceAccount: issuerAddress,
},
&txnbuild.AllowTrust{
Trustor: paymentSource,
Type: paymentOp.Asset,
Authorize: false,
SourceAccount: issuerAddress,
},
}
revisedTx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{
SourceAccount: &acc,
IncrementSequenceNum: true,
Operations: revisedOperations,
BaseFee: 300,
Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(300)},
})
if err != nil {
return nil, errors.Wrap(err, "building transaction")
}
revisedTx, err = revisedTx.Sign(h.networkPassphrase, h.issuerKP)
if err != nil {
return nil, errors.Wrap(err, "signing transaction")
}
txe, err := revisedTx.Base64()
if err != nil {
return nil, errors.Wrap(err, "encoding revised transaction")
}
return NewRevisedTxApprovalResponse(txe), nil
}
// handleActionRequiredResponseIfNeeded validates and returns an action_required
// response if the payment requires KYC.
func (h txApproveHandler) handleActionRequiredResponseIfNeeded(ctx context.Context, hcnetAddress string, paymentOp *txnbuild.Payment) (*txApprovalResponse, error) {
paymentAmount, err := amount.ParseInt64(paymentOp.Amount)
if err != nil {
return nil, errors.Wrap(err, "parsing payment amount from string to Int64")
}
if paymentAmount <= h.kycThreshold {
return nil, nil
}
intendedCallbackID := uuid.New().String()
const q = `
WITH new_row AS (
INSERT INTO accounts_kyc_status (hcnet_address, callback_id)
VALUES ($1, $2)
ON CONFLICT(hcnet_address) DO NOTHING
RETURNING *
)
SELECT callback_id, approved_at, rejected_at, pending_at FROM new_row
UNION
SELECT callback_id, approved_at, rejected_at, pending_at
FROM accounts_kyc_status
WHERE hcnet_address = $1
`
var (
callbackID string
approvedAt, rejectedAt, pendingAt sql.NullTime
)
err = h.db.QueryRowContext(ctx, q, hcnetAddress, intendedCallbackID).Scan(&callbackID, &approvedAt, &rejectedAt, &pendingAt)
if err != nil {
return nil, errors.Wrap(err, "inserting new row into accounts_kyc_status table")
}
if approvedAt.Valid {
return nil, nil
}
kycThreshold, err := convertAmountToReadableString(h.kycThreshold)
if err != nil {
return nil, errors.Wrap(err, "converting kycThreshold to human readable string")
}
if rejectedAt.Valid {
return NewRejectedTxApprovalResponse(fmt.Sprintf("Your KYC was rejected and you're not authorized for operations above %s %s.", kycThreshold, h.assetCode)), nil
}
if pendingAt.Valid {
return NewPendingTxApprovalResponse(fmt.Sprintf("Your account could not be verified as approved nor rejected and was marked as pending. You will need staff authorization for operations above %s %s.", kycThreshold, h.assetCode)), nil
}
return NewActionRequiredTxApprovalResponse(
fmt.Sprintf(`Payments exceeding %s %s require KYC approval. Please provide an email address.`, kycThreshold, h.assetCode),
fmt.Sprintf("%s/kyc-status/%s", h.baseURL, callbackID),
[]string{"email_address"},
), nil
}
// handleSuccessResponseIfNeeded inspects the incoming transaction and returns a
// "success" response if it's already compliant with the SEP-8 authorization spec.
func (h txApproveHandler) handleSuccessResponseIfNeeded(ctx context.Context, tx *txnbuild.Transaction) (*txApprovalResponse, error) {
if len(tx.Operations()) != 5 {
return nil, nil
}
rejectedResp, paymentOp, paymentSource := validateTransactionOperationsForSuccess(ctx, tx, h.issuerKP.Address())
if rejectedResp != nil {
return rejectedResp, nil
}
if paymentOp.Destination == h.issuerKP.Address() {
return NewRejectedTxApprovalResponse("Can't transfer asset to its issuer."), nil
}
// pull current account details from the network then validate the tx sequence number
acc, err := h.auroraClient.AccountDetail(auroraclient.AccountRequest{AccountID: paymentSource})
if err != nil {
return nil, errors.Wrapf(err, "getting detail for payment source account %s", paymentSource)
}
if tx.SourceAccount().Sequence != acc.Sequence+1 {
log.Ctx(ctx).Errorf(`invalid transaction sequence number tx.SourceAccount().Sequence: %d, accountSequence+1: %d`, tx.SourceAccount().Sequence, acc.Sequence+1)
return NewRejectedTxApprovalResponse("Invalid transaction sequence number."), nil
}
kycRequiredResponse, err := h.handleActionRequiredResponseIfNeeded(ctx, paymentSource, paymentOp)
if err != nil {
return nil, errors.Wrap(err, "handling KYC required payment")
}
if kycRequiredResponse != nil {
return kycRequiredResponse, nil
}
// sign transaction with issuer's signature and encode it
tx, err = tx.Sign(h.networkPassphrase, h.issuerKP)
if err != nil {
return nil, errors.Wrap(err, "signing transaction")
}
txe, err := tx.Base64()
if err != nil {
return nil, errors.Wrap(err, "encoding revised transaction")
}
return NewSuccessTxApprovalResponse(txe, "Transaction is compliant and signed by the issuer."), nil
}
// validateTransactionOperationsForSuccess checks if the incoming transaction
// operations are compliant with the anchor's SEP-8 policy.
func validateTransactionOperationsForSuccess(ctx context.Context, tx *txnbuild.Transaction, issuerAddress string) (resp *txApprovalResponse, paymentOp *txnbuild.Payment, paymentSource string) {
if len(tx.Operations()) != 5 {
return NewRejectedTxApprovalResponse("Unsupported number of operations."), nil, ""
}
// extract the payment operation and payment source account.
paymentOp, ok := tx.Operations()[2].(*txnbuild.Payment)
if !ok {
log.Ctx(ctx).Error(`third operation is not of type payment`)
return NewRejectedTxApprovalResponse("There are one or more unexpected operations in the provided transaction."), nil, ""
}
paymentSource = paymentOp.SourceAccount
if paymentSource == "" {
paymentSource = tx.SourceAccount().AccountID
}
assetCode := paymentOp.Asset.GetCode()
operationsValid := func() bool {
op0, ok := tx.Operations()[0].(*txnbuild.AllowTrust)
if !ok ||
op0.Trustor != paymentSource ||
op0.Type.GetCode() != assetCode ||
!op0.Authorize ||
op0.SourceAccount != issuerAddress {
return false
}
op1, ok := tx.Operations()[1].(*txnbuild.AllowTrust)
if !ok ||
op1.Trustor != paymentOp.Destination ||
op1.Type.GetCode() != assetCode ||
!op1.Authorize ||
op1.SourceAccount != issuerAddress {
return false
}
op2, ok := tx.Operations()[2].(*txnbuild.Payment)
if !ok || op2 != paymentOp {
return false
}
op3, ok := tx.Operations()[3].(*txnbuild.AllowTrust)
if !ok ||
op3.Trustor != paymentOp.Destination ||
op3.Type.GetCode() != assetCode ||
op3.Authorize ||
op3.SourceAccount != issuerAddress {
return false
}
op4, ok := tx.Operations()[4].(*txnbuild.AllowTrust)
if !ok ||
op4.Trustor != paymentSource ||
op4.Type.GetCode() != assetCode ||
op4.Authorize ||
op4.SourceAccount != issuerAddress {
return false
}
return true
}()
if !operationsValid {
return NewRejectedTxApprovalResponse("There are one or more unexpected operations in the provided transaction."), nil, ""
}
return nil, paymentOp, paymentSource
}
func convertAmountToReadableString(threshold int64) (string, error) {
amountStr := amount.StringFromInt64(threshold)
amountFloat, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
return "", errors.Wrap(err, "converting threshold amount from string to float")
}
return fmt.Sprintf("%.2f", amountFloat), nil
}