Skip to content

Commit

Permalink
Merge pull request #32 from rarimo/feature/join-program
Browse files Browse the repository at this point in the history
Feature: join rewards program
  • Loading branch information
violog authored Jun 20, 2024
2 parents ef0d78d + 5e9a4c4 commit 0678c90
Show file tree
Hide file tree
Showing 16 changed files with 344 additions and 64 deletions.
22 changes: 2 additions & 20 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ levels:
withdrawal_allowed: true

countries:
verification_key: "37bc75afc97f8bdcd21cda85ae7b2885b5f1205ae3d79942e56457230f1636a037cc7ebfe42998d66a3dd3446b9d29366271b4f2bd8e0d307db1d320b38fc02f"
countries:
- code: "UKR"
reserve_limit: 100000
Expand Down Expand Up @@ -97,23 +98,4 @@ root_verifier:
request_timeout: 10s

withdrawal:
point_price_urmo: 100

sbt_check:
networks:
- name: polygon
rpc: https://your-rpc
contract: 0x...
request_timeout: 5s
start_from_block: 48984542
block_window: 3
max_blocks_per_request: 5000
- name: ethereum
rpc: https://your-rpc
contract: 0x...
request_timeout: 5s
- name: disabled_sample
disabled: true
rpc: https://your-rpc
contract: 0x...
request_timeout: 5s
point_price_urmo: 1000000
15 changes: 15 additions & 0 deletions docs/spec/components/schemas/JoinProgram.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
allOf:
- $ref: '#/components/schemas/JoinProgramKey'
- type: object
x-go-is-request: true
required:
- attributes
properties:
attributes:
type: object
required:
- country
properties:
country:
type: string
example: "5589842"
13 changes: 13 additions & 0 deletions docs/spec/components/schemas/JoinProgramKey.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
type: object
required:
- id
- type
properties:
id:
type: string
description: Nullifier of the points owner
example: "0x123...abc"
pattern: '^0x[0-9a-fA-F]{64}$'
type:
type: string
enum: [ join_program ]
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
post:
tags:
- Points balance
summary: Join rewards program
description: Join rewards program
operationId: joinRewardsProgram
parameters:
- $ref: '#/components/parameters/pathNullifier'
requestBody:
required: true
content:
application/vnd.api+json:
schema:
type: object
required:
- data
properties:
data:
$ref: '#/components/schemas/JoinProgram'
responses:
200:
description: Success
content:
application/vnd.api+json:
schema:
type: object
required:
- data
properties:
data:
$ref: '#/components/schemas/PassportEventState'
400:
$ref: '#/components/responses/invalidParameter'
401:
$ref: '#/components/responses/invalidAuth'
404:
description: Balance not exists.
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/Errors'
429:
description: Passport already verified or event absent for user.
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/Errors'
500:
$ref: '#/components/responses/internalError'
5 changes: 5 additions & 0 deletions internal/assets/migrations/003_passport_proven.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- +migrate Up
ALTER TABLE balances ADD COLUMN is_passport_proven boolean NOT NULL DEFAULT FALSE;

-- +migrate Down
ALTER TABLE balances DROP COLUMN is_passport_proven;
19 changes: 10 additions & 9 deletions internal/data/balances.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ import (

const (
ColAmount = "amount"
ColReferredBy = "referred_by"
ColLevel = "level"
ColCountry = "country"
ColIsPassport = "is_passport_proven"
)

type Balance struct {
Nullifier string `db:"nullifier"`
Amount int64 `db:"amount"`
CreatedAt int32 `db:"created_at"`
UpdatedAt int32 `db:"updated_at"`
ReferredBy sql.NullString `db:"referred_by"`
Rank *int `db:"rank"`
Level int `db:"level"`
Country *string `db:"country"`
Nullifier string `db:"nullifier"`
Amount int64 `db:"amount"`
CreatedAt int32 `db:"created_at"`
UpdatedAt int32 `db:"updated_at"`
ReferredBy sql.NullString `db:"referred_by"`
Rank *int `db:"rank"`
Level int `db:"level"`
Country *string `db:"country"`
IsPassportProven bool `db:"is_passport_proven"`
}

type BalancesQ interface {
Expand Down
12 changes: 12 additions & 0 deletions internal/service/handlers/ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/rarimo/rarime-points-svc/internal/config"
"github.com/rarimo/rarime-points-svc/internal/data"
"github.com/rarimo/rarime-points-svc/internal/data/evtypes"
"github.com/rarimo/rarime-points-svc/internal/service/workers/countrier"
"github.com/rarimo/saver-grpc-lib/broadcaster"
zk "github.com/rarimo/zkverifier-kit"
"gitlab.com/distributed_lab/logan/v3"
Expand All @@ -28,6 +29,7 @@ const (
pointPriceCtxKey
verifierCtxKey
levelsCtxKey
countriesConfigCtxKey
)

func CtxLog(entry *logan.Entry) func(context.Context) context.Context {
Expand Down Expand Up @@ -149,3 +151,13 @@ func CtxLevels(levels config.Levels) func(context.Context) context.Context {
func Levels(r *http.Request) config.Levels {
return r.Context().Value(levelsCtxKey).(config.Levels)
}

func CtxCountriesConfig(config countrier.Config) func(context.Context) context.Context {
return func(ctx context.Context) context.Context {
return context.WithValue(ctx, countriesConfigCtxKey, config)
}
}

func CountriesConfig(r *http.Request) countrier.Config {
return r.Context().Value(countriesConfigCtxKey).(countrier.Config)
}
77 changes: 77 additions & 0 deletions internal/service/handlers/join_program.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package handlers

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"

"github.com/rarimo/rarime-points-svc/internal/data"
"github.com/rarimo/rarime-points-svc/internal/data/evtypes"
"github.com/rarimo/rarime-points-svc/internal/service/requests"
"gitlab.com/distributed_lab/ape"
"gitlab.com/distributed_lab/ape/problems"
)

func JoinProgram(w http.ResponseWriter, r *http.Request) {
req, err := requests.NewJoinProgram(r)
if err != nil {
Log(r).WithError(err).Debug("Bad request")
ape.RenderErr(w, problems.BadRequest(err)...)
return
}

gotSig := r.Header.Get("Signature")
wantSig := calculateCountrySignature(CountriesConfig(r).VerificationKey, req.Data.ID, req.Data.Attributes.Country)
if gotSig != wantSig {
Log(r).Warnf("Unauthorized access: HMAC signature mismatch: got %s, want %s", gotSig, wantSig)
ape.RenderErr(w, problems.Forbidden())
return
}

balance, errs := getAndVerifyBalanceEligibility(r, req.Data.ID, nil)
if len(errs) > 0 {
ape.RenderErr(w, errs...)
return
}

if balance.Country != nil {
Log(r).Debugf("Balance %s already joined rewards program", balance.Nullifier)
ape.RenderErr(w, problems.TooManyRequests())
return
}

err = EventsQ(r).Transaction(func() error {
return doPassportScanUpdates(r, *balance, req.Data.Attributes.Country, false)
})
if err != nil {
Log(r).WithError(err).Error("Failed to execute transaction")
ape.RenderErr(w, problems.InternalError())
return
}

event, err := EventsQ(r).FilterByNullifier(balance.Nullifier).
FilterByType(evtypes.TypePassportScan).
FilterByStatus(data.EventClaimed).Get()
if err != nil {
Log(r).WithError(err).Error("Failed to get claimed event")
ape.RenderErr(w, problems.InternalError())
return
}

ape.Render(w, newPassportEventStateResponse(req.Data.ID, event))
}

func calculateCountrySignature(key []byte, nullifier, country string) string {
bNull, err := hex.DecodeString(nullifier[2:])
if err != nil {
panic(fmt.Errorf("nullifier was not properly validated as hex: %w", err))
}

h := hmac.New(sha256.New, key)
msg := append(bNull, []byte(country)...)
h.Write(msg)

return hex.EncodeToString(h.Sum(nil))
}
71 changes: 50 additions & 21 deletions internal/service/handlers/verify_passport.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,42 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) {
return
}

countryCode, err := extractCountry(req.Data.Attributes.Proof)
if err != nil {
Log(r).WithError(err).Error("Critical: invalid country code provided, while the proof was valid")
ape.RenderErr(w, problems.InternalError())
return
}

if balance.Country != nil {
Log(r).Debugf("Balance %s already verified", balance.Nullifier)
ape.RenderErr(w, problems.TooManyRequests())
if balance.IsPassportProven {
Log(r).Debugf("Balance %s already verified", balance.Nullifier)
ape.RenderErr(w, problems.TooManyRequests())
return
}

if *balance.Country != countryCode {
ape.RenderErr(w, problems.BadRequest(validation.Errors{
"country": fmt.Errorf("country mismatch: got %s, joined program with %s", countryCode, *balance.Country),
})...)
return
}

err = BalancesQ(r).FilterByNullifier(balance.Nullifier).Update(map[string]any{
data.ColIsPassport: true,
})
if err != nil {
Log(r).WithError(err).Error("Failed to update balance")
ape.RenderErr(w, problems.InternalError())
return
}

ape.Render(w, newPassportEventStateResponse(req.Data.ID, nil))
return
}

err = EventsQ(r).Transaction(func() error {
return doPassportScanUpdates(r, *balance, req.Data.Attributes.Proof)
return doPassportScanUpdates(r, *balance, countryCode, true)
})
if err != nil {
Log(r).WithError(err).Error("Failed to execute transaction")
Expand All @@ -60,12 +88,15 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) {
return
}

ape.Render(w, newPassportEventStateResponse(req.Data.ID, event))
}

func newPassportEventStateResponse(id string, event *data.Event) resources.PassportEventStateResponse {
var res resources.PassportEventStateResponse
res.Data.ID = req.Data.ID
res.Data.ID = id
res.Data.Type = resources.PASSPORT_EVENT_STATE
res.Data.Attributes.Claimed = (event != nil)

ape.Render(w, res)
res.Data.Attributes.Claimed = event != nil
return res
}

// getAndVerifyBalanceEligibility provides shared logic to verify that the user
Expand All @@ -90,7 +121,7 @@ func getAndVerifyBalanceEligibility(
if errs = checkVerificationEligibility(r, balance); len(errs) > 0 {
return nil, errs
}
// for withdrawal
// for withdrawal and joining program
if proof == nil {
return balance, nil
}
Expand Down Expand Up @@ -123,8 +154,8 @@ func checkVerificationEligibility(r *http.Request, balance *data.Balance) (errs
// doPassportScanUpdates performs all the necessary updates when the passport
// scan proof is provided. This logic is shared between verification and
// withdrawal handlers.
func doPassportScanUpdates(r *http.Request, balance data.Balance, proof zkptypes.ZKProof) error {
country, err := updateBalanceCountry(r, balance, proof)
func doPassportScanUpdates(r *http.Request, balance data.Balance, countryCode string, proven bool) error {
country, err := updateBalanceCountry(r, balance, countryCode, proven)
if err != nil {
return fmt.Errorf("update balance country: %w", err)
}
Expand Down Expand Up @@ -175,8 +206,8 @@ func doPassportScanUpdates(r *http.Request, balance data.Balance, proof zkptypes
return nil
}

func updateBalanceCountry(r *http.Request, balance data.Balance, proof zkptypes.ZKProof) (*data.Country, error) {
country, err := getOrCreateCountry(CountriesQ(r), proof)
func updateBalanceCountry(r *http.Request, balance data.Balance, code string, proven bool) (*data.Country, error) {
country, err := getOrCreateCountry(CountriesQ(r), code)
if err != nil {
return nil, fmt.Errorf("get or create country: %w", err)
}
Expand All @@ -189,9 +220,12 @@ func updateBalanceCountry(r *http.Request, balance data.Balance, proof zkptypes.
return nil, errors.New("countries mismatch")
}

err = BalancesQ(r).FilterByNullifier(balance.Nullifier).Update(map[string]any{
data.ColCountry: country.Code,
})
toUpd := map[string]any{data.ColCountry: country.Code}
if proven {
toUpd[data.ColIsPassport] = true
}

err = BalancesQ(r).FilterByNullifier(balance.Nullifier).Update(toUpd)
if err != nil {
return nil, fmt.Errorf("update balance country: %w", err)
}
Expand Down Expand Up @@ -414,12 +448,7 @@ func addEventForReferrer(r *http.Request, evTypeRef *evtypes.EventConfig, balanc
return nil
}

func getOrCreateCountry(q data.CountriesQ, proof zkptypes.ZKProof) (*data.Country, error) {
code, err := extractCountry(proof)
if err != nil {
return nil, fmt.Errorf("extract country: %w", err)
}

func getOrCreateCountry(q data.CountriesQ, code string) (*data.Country, error) {
c, err := q.FilterByCodes(code).Get()
if err != nil {
return nil, fmt.Errorf("get country by code: %w", err)
Expand Down
Loading

0 comments on commit 0678c90

Please sign in to comment.