feat: (wip) add account activation and gas quota lock

* This is a crude lock that restricts each account to the set gas quota.
This commit is contained in:
Mohamed Sohail 2023-03-08 06:49:09 +00:00
parent ec14328d49
commit 341a760f02
Signed by: kamikazechaser
GPG Key ID: 7DD45520C01CD85D
26 changed files with 334 additions and 52 deletions

View File

@ -45,7 +45,6 @@ func initSystemContainer(ctx context.Context, noncestore nonce.Noncestore) *cust
GiftableGasValue: big.NewInt(ko.MustInt64("system.giftable_gas_value")),
GiftableToken: w3.A(ko.MustString("system.giftable_token_address")),
GiftableTokenValue: big.NewInt(ko.MustInt64("system.giftable_token_value")),
LockPrefix: ko.MustString("system.lock_prefix"),
LockTimeout: 1 * time.Second,
PublicKey: ko.MustString("system.public_key"),
TokenDecimals: ko.MustInt("system.token_decimals"),

View File

@ -67,6 +67,7 @@ func main() {
Noncestore: redisNoncestore,
PgStore: pgStore,
Pub: jsPub,
RedisClient: redisPool.Client,
SystemContainer: systemContainer,
TaskerClient: taskerClient,
}

View File

@ -39,6 +39,7 @@ func initTasker(custodialContainer *custodial.Custodial, redisPool *redis.RedisP
taskerServer.RegisterHandlers(tasker.AccountRegisterTask, task.AccountRegisterOnChainProcessor(custodialContainer))
taskerServer.RegisterHandlers(tasker.AccountGiftGasTask, task.AccountGiftGasProcessor(custodialContainer))
taskerServer.RegisterHandlers(tasker.AccountGiftVoucherTask, task.GiftVoucherProcessor(custodialContainer))
taskerServer.RegisterHandlers(tasker.AccountActivateTask, task.AccountActivateProcessor(custodialContainer))
taskerServer.RegisterHandlers(tasker.AccountRefillGasTask, task.AccountRefillGasProcessor(custodialContainer))
taskerServer.RegisterHandlers(tasker.SignTransferTask, task.SignTransfer(custodialContainer))
taskerServer.RegisterHandlers(tasker.DispatchTxTask, task.DispatchTx(custodialContainer))

View File

@ -17,7 +17,6 @@ devnet = false
# All addresses MUST be checksumed
account_index_address = ""
lock_prefix = "lock:"
gas_faucet_address = ""
gas_refill_threshold = 2500000000000000
gas_refill_value = 10000000000000000

View File

@ -40,6 +40,21 @@ func HandleSignTransfer(c echo.Context) error {
return err
}
accountActive, gasQuota, err := cu.PgStore.GetAccountStatusByAddress(c.Request().Context(), req.From)
if !accountActive {
return c.JSON(http.StatusForbidden, ErrResp{
Ok: false,
Message: "Account pending activation. Try again later.",
})
}
if gasQuota < 1 {
return c.JSON(http.StatusForbidden, ErrResp{
Ok: false,
Message: "Out of gas, refill pending. Try again later.",
})
}
trackingId := uuid.NewString()
taskPayload, err := json.Marshal(task.TransferPayload{
TrackingId: trackingId,
@ -65,6 +80,11 @@ func HandleSignTransfer(c echo.Context) error {
return err
}
err = cu.PgStore.DecrGasQuota(c.Request().Context(), req.From)
if err != nil {
return err
}
return c.JSON(http.StatusOK, OkResp{
Ok: true,
Result: H{

View File

@ -7,6 +7,7 @@ import (
"github.com/bsm/redislock"
"github.com/celo-org/celo-blockchain/common"
"github.com/go-redis/redis/v8"
"github.com/grassrootseconomics/celoutils"
"github.com/grassrootseconomics/cic-custodial/internal/keystore"
"github.com/grassrootseconomics/cic-custodial/internal/nonce"
@ -26,7 +27,6 @@ type (
GiftableGasValue *big.Int
GiftableToken common.Address
GiftableTokenValue *big.Int
LockPrefix string
LockTimeout time.Duration
PrivateKey *ecdsa.PrivateKey
PublicKey string
@ -40,6 +40,7 @@ type (
Noncestore nonce.Noncestore
PgStore store.Store
Pub *pub.Pub
RedisClient *redis.Client
SystemContainer *SystemContainer
TaskerClient *tasker.TaskerClient
}

View File

@ -11,10 +11,15 @@ type Queries struct {
WriteKeyPair string `query:"write-key-pair"`
LoadKeyPair string `query:"load-key-pair"`
// Store
CreateOTX string `query:"create-otx"`
CreateDispatchStatus string `query:"create-dispatch-status"`
UpdateChainStatus string `query:"update-chain-status"`
GetTxStatusByTrackingId string `query:"get-tx-status-by-tracking-id"`
CreateOTX string `query:"create-otx"`
CreateDispatchStatus string `query:"create-dispatch-status"`
ActivateAccount string `query:"activate-account"`
UpdateChainStatus string `query:"update-chain-status"`
GetTxStatusByTrackingId string `query:"get-tx-status-by-tracking-id"`
GetAccountActivationQuorum string `query:"get-account-activation-quorum"`
GetAccountStatus string `query:"get-account-status-by-address"`
DecrGasQuota string `query:"decr-gas-quota"`
ResetGasQuota string `query:"reset-gas-quota"`
}
func LoadQueries(q goyesql.Queries) (*Queries, error) {

66
internal/store/account.go Normal file
View File

@ -0,0 +1,66 @@
package store
import (
"context"
)
func (s *PostgresStore) GetAccountStatusByAddress(ctx context.Context, publicAddress string) (bool, int, error) {
var (
accountActive bool
gasQuota int
)
if err := s.db.QueryRow(ctx, s.queries.GetAccountStatus, publicAddress).Scan(&accountActive, &gasQuota); err != nil {
return false, 0, err
}
return accountActive, gasQuota, nil
}
func (s *PostgresStore) GetAccountActivationQuorum(ctx context.Context, trackingId string) (int, error) {
var (
quorum int
)
if err := s.db.QueryRow(ctx, s.queries.GetAccountActivationQuorum, trackingId).Scan(&quorum); err != nil {
return 0, err
}
return quorum, nil
}
func (s *PostgresStore) DecrGasQuota(ctx context.Context, publicAddress string) error {
if _, err := s.db.Exec(
ctx,
s.queries.DecrGasQuota,
publicAddress,
); err != nil {
return err
}
return nil
}
func (s *PostgresStore) ResetGasQuota(ctx context.Context, publicAddress string) error {
if _, err := s.db.Exec(
ctx,
s.queries.ResetGasQuota,
publicAddress,
); err != nil {
return err
}
return nil
}
func (s *PostgresStore) ActivateAccount(ctx context.Context, publicAddress string) error {
if _, err := s.db.Exec(
ctx,
s.queries.ActivateAccount,
publicAddress,
); err != nil {
return err
}
return nil
}

View File

@ -1,18 +0,0 @@
package store
import (
"context"
)
func (s *PostgresStore) CreateDispatchStatus(ctx context.Context, dispatch DispatchStatus) error {
if _, err := s.db.Exec(
ctx,
s.queries.CreateDispatchStatus,
dispatch.OtxId,
dispatch.Status,
); err != nil {
return err
}
return nil
}

View File

@ -59,8 +59,20 @@ func (s *PostgresStore) GetTxStatusByTrackingId(ctx context.Context, trackingId
return txs, nil
}
func (s *PostgresStore) CreateDispatchStatus(ctx context.Context, dispatch DispatchStatus) error {
if _, err := s.db.Exec(
ctx,
s.queries.CreateDispatchStatus,
dispatch.OtxId,
dispatch.Status,
); err != nil {
return err
}
return nil
}
func (s *PostgresStore) UpdateOtxStatusFromChainEvent(ctx context.Context, chainEvent MinimalTxInfo) error {
var (
status = enum.SUCCESS
)

View File

@ -40,5 +40,10 @@ type (
CreateDispatchStatus(ctx context.Context, dispatch DispatchStatus) error
GetTxStatusByTrackingId(ctx context.Context, trackingId string) ([]*TxStatus, error)
UpdateOtxStatusFromChainEvent(ctx context.Context, chainEvent MinimalTxInfo) error
GetAccountStatusByAddress(ctx context.Context, publicAddress string) (bool, int, error)
GetAccountActivationQuorum(ctx context.Context, trackingId string) (int, error)
DecrGasQuota(ctx context.Context, publicAddress string) error
ResetGasQuota(ctx context.Context, publicAddress string) error
ActivateAccount(ctx context.Context, publicAddress string) error
}
)

View File

@ -23,7 +23,9 @@ func (s *Sub) handler(ctx context.Context, msg *nats.Msg) error {
switch msg.Subject {
case "CHAIN.gas":
//
if err := s.cu.PgStore.ResetGasQuota(ctx, checksum(chainEvent.To)); err != nil {
return err
}
}
return nil

31
internal/sub/util.go Normal file
View File

@ -0,0 +1,31 @@
package sub
import (
"encoding/hex"
"strconv"
"strings"
"golang.org/x/crypto/sha3"
)
// TODO: This should probably be used project wide
func checksum(address string) string {
address = strings.ToLower(address)
address = strings.Replace(address, "0x", "", 1)
sha := sha3.NewLegacyKeccak256()
sha.Write([]byte(address))
hash := sha.Sum(nil)
hashstr := hex.EncodeToString(hash)
result := []string{"0x"}
for i, v := range address {
res, _ := strconv.ParseInt(string(hashstr[i]), 16, 64)
if res > 7 {
result = append(result, strings.ToUpper(string(v)))
continue
}
result = append(result, string(v))
}
return strings.Join(result, "")
}

View File

@ -28,18 +28,23 @@ func NewTaskerClient(o TaskerClientOpts) *TaskerClient {
}
}
func (c *TaskerClient) CreateTask(ctx context.Context, taskName TaskName, queueName QueueName, task *Task) (*asynq.TaskInfo, error) {
func (c *TaskerClient) CreateTask(ctx context.Context, taskName TaskName, queueName QueueName, task *Task, extraOpts ...asynq.Option) (*asynq.TaskInfo, error) {
if task.Id == "" {
task.Id = uuid.NewString()
}
qTask := asynq.NewTask(
string(taskName),
task.Payload,
defaultOpts := []asynq.Option{
asynq.Queue(string(queueName)),
asynq.TaskID(task.Id),
asynq.Retention(taskRetention),
asynq.Timeout(taskTimeout),
}
taskOpts := append(defaultOpts, extraOpts...)
qTask := asynq.NewTask(
string(taskName),
task.Payload,
taskOpts...
)
taskInfo, err := c.Client.EnqueueContext(ctx, qTask)

View File

@ -0,0 +1,45 @@
package task
import (
"context"
"encoding/json"
"errors"
"github.com/grassrootseconomics/cic-custodial/internal/custodial"
"github.com/hibiken/asynq"
)
const (
requiredQuorum = 3
)
var (
ErrQuorumNotReached = errors.New("Account activation quorum not reached.")
)
func AccountActivateProcessor(cu *custodial.Custodial) func(context.Context, *asynq.Task) error {
return func(ctx context.Context, t *asynq.Task) error {
var (
payload AccountPayload
)
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return err
}
quorum, err := cu.PgStore.GetAccountActivationQuorum(ctx, payload.TrackingId)
if err != nil {
return err
}
if quorum < requiredQuorum {
return ErrQuorumNotReached
}
if err := cu.PgStore.ActivateAccount(ctx, payload.PublicKey); err != nil {
return err
}
return nil
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/celo-org/celo-blockchain/common/hexutil"
"github.com/grassrootseconomics/celoutils"
@ -16,6 +17,10 @@ import (
"github.com/hibiken/asynq"
)
const (
accountActivationCheckDelay = 5 * time.Second
)
func AccountGiftGasProcessor(cu *custodial.Custodial) func(context.Context, *asynq.Task) error {
return func(ctx context.Context, t *asynq.Task) error {
var (
@ -29,7 +34,7 @@ func AccountGiftGasProcessor(cu *custodial.Custodial) func(context.Context, *asy
lock, err := cu.LockProvider.Obtain(
ctx,
cu.SystemContainer.LockPrefix+cu.SystemContainer.PublicKey,
lockPrefix+cu.SystemContainer.PublicKey,
cu.SystemContainer.LockTimeout,
nil,
)
@ -105,6 +110,19 @@ func AccountGiftGasProcessor(cu *custodial.Custodial) func(context.Context, *asy
return err
}
_, err = cu.TaskerClient.CreateTask(
ctx,
tasker.AccountActivateTask,
tasker.DefaultPriority,
&tasker.Task{
Payload: t.Payload(),
},
asynq.ProcessIn(accountActivationCheckDelay),
)
if err != nil {
return err
}
eventPayload := &pub.EventPayload{
OtxId: id,
TrackingId: payload.TrackingId,

View File

@ -28,7 +28,7 @@ func GiftVoucherProcessor(cu *custodial.Custodial) func(context.Context, *asynq.
lock, err := cu.LockProvider.Obtain(
ctx,
cu.SystemContainer.LockPrefix+cu.SystemContainer.PublicKey,
lockPrefix+cu.SystemContainer.PublicKey,
cu.SystemContainer.LockTimeout,
nil,
)

View File

@ -3,10 +3,12 @@ package task
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"time"
"github.com/celo-org/celo-blockchain/common/hexutil"
"github.com/go-redis/redis/v8"
"github.com/grassrootseconomics/celoutils"
"github.com/grassrootseconomics/cic-custodial/internal/custodial"
"github.com/grassrootseconomics/cic-custodial/internal/pub"
@ -14,14 +16,17 @@ import (
"github.com/grassrootseconomics/cic-custodial/internal/tasker"
"github.com/grassrootseconomics/cic-custodial/pkg/enum"
"github.com/grassrootseconomics/w3-celo-patch"
"github.com/grassrootseconomics/w3-celo-patch/module/eth"
"github.com/hibiken/asynq"
)
const (
gasLockPrefix = "gas_lock:"
gasLockExpiry = 1 * time.Hour
)
func AccountRefillGasProcessor(cu *custodial.Custodial) func(context.Context, *asynq.Task) error {
return func(ctx context.Context, t *asynq.Task) error {
var (
balance big.Int
err error
payload AccountPayload
)
@ -30,20 +35,25 @@ func AccountRefillGasProcessor(cu *custodial.Custodial) func(context.Context, *a
return fmt.Errorf("account: failed %v: %w", err, asynq.SkipRetry)
}
if err := cu.CeloProvider.Client.CallCtx(
ctx,
eth.Balance(w3.A(payload.PublicKey), nil).Returns(&balance),
); err != nil {
// TODO: Check eth-faucet whether we can request for a topup before signing the tx.
_, gasQuota, err := cu.PgStore.GetAccountStatusByAddress(ctx, payload.PublicKey)
if err != nil {
return err
}
if belowThreshold := balance.Cmp(cu.SystemContainer.GasRefillThreshold); belowThreshold > 0 {
gasLock, err := cu.RedisClient.Get(ctx, gasLockPrefix+payload.PublicKey).Bool()
if !errors.Is(err, redis.Nil) {
return err
}
if gasQuota > 0 || gasLock {
return nil
}
// TODO: Use eth-faucet.
lock, err := cu.LockProvider.Obtain(
ctx,
cu.SystemContainer.LockPrefix+cu.SystemContainer.PublicKey,
lockPrefix+cu.SystemContainer.PublicKey,
cu.SystemContainer.LockTimeout,
nil,
)
@ -134,6 +144,10 @@ func AccountRefillGasProcessor(cu *custodial.Custodial) func(context.Context, *a
return err
}
if _, err := cu.RedisClient.SetEX(ctx, gasLockPrefix+payload.PublicKey, true, gasLockExpiry).Result(); err != nil {
return err
}
return nil
}
}

View File

@ -29,7 +29,7 @@ func AccountRegisterOnChainProcessor(cu *custodial.Custodial) func(context.Conte
lock, err := cu.LockProvider.Obtain(
ctx,
cu.SystemContainer.LockPrefix+cu.SystemContainer.PublicKey,
lockPrefix+cu.SystemContainer.PublicKey,
cu.SystemContainer.LockTimeout,
nil,
)

View File

@ -47,7 +47,7 @@ func SignTransfer(cu *custodial.Custodial) func(context.Context, *asynq.Task) er
lock, err := cu.LockProvider.Obtain(
ctx,
cu.SystemContainer.LockPrefix+payload.From,
lockPrefix+payload.From,
cu.SystemContainer.LockTimeout,
nil,
)
@ -136,7 +136,8 @@ func SignTransfer(cu *custodial.Custodial) func(context.Context, *asynq.Task) er
}
gasRefillPayload, err := json.Marshal(AccountPayload{
PublicKey: payload.From,
PublicKey: payload.From,
TrackingId: payload.TrackingId,
})
if err != nil {
return err

View File

@ -0,0 +1,5 @@
package task
const (
lockPrefix = "lock:"
)

View File

@ -20,6 +20,7 @@ const (
AccountGiftGasTask TaskName = "sys:gift_gas"
AccountGiftVoucherTask TaskName = "sys:gift_token"
AccountRefillGasTask TaskName = "sys:refill_gas"
AccountActivateTask TaskName = "sys:quorum_check"
SignTransferTask TaskName = "usr:sign_transfer"
DispatchTxTask TaskName = "rpc:dispatch"
)

View File

@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS keystore (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
active BOOLEAN DEFAULT true,
active BOOLEAN DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
);
CREATE INDEX IF NOT EXISTS public_key_idx ON keystore(public_key);

View File

@ -12,7 +12,7 @@ INSERT INTO otx_tx_type (value) VALUES
-- Origin tx table
CREATE TABLE IF NOT EXISTS otx_sign (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tracking_id TEXT NOT NULL,
tracking_id uuid NOT NULL,
"type" TEXT REFERENCES otx_tx_type(value) NOT NULL,
raw_tx TEXT NOT NULL,
tx_hash TEXT NOT NULL,
@ -24,8 +24,9 @@ CREATE TABLE IF NOT EXISTS otx_sign (
nonce int NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS tx_hash_idx ON otx_sign USING hash(tx_hash);
CREATE INDEX IF NOT EXISTS from_idx ON otx_sign USING hash("from");
CREATE INDEX IF NOT EXISTS tracking_id_idx ON otx_sign (tracking_id);
CREATE INDEX IF NOT EXISTS tx_hash_idx ON otx_sign (tx_hash);
CREATE INDEX IF NOT EXISTS from_idx ON otx_sign ("from");
-- Otx dispatch status enum table
-- Enforces referential integrity on the dispatch table

View File

@ -0,0 +1,28 @@
-- Gas quota meta table
CREATE TABLE IF NOT EXISTS gas_quota_meta (
default_quota INT NOT NULL
);
INSERT INTO gas_quota_meta (default_quota) VALUES (25);
-- Gas quota table
CREATE TABLE IF NOT EXISTS gas_quota (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
key_id INT REFERENCES keystore(id) NOT NULL,
quota INT NOT NULL DEFAULT 0
);
-- Gas quota trigger on keystore insert to default 0 quota
-- We wait for the event handler to correctly set the chain quota
create function insert_gas_quota()
returns trigger
as $$
begin
insert into gas_quota (key_id) values (new.id);
return new;
end;
$$ language plpgsql;
create trigger insert_gas_quota
after insert on keystore
for each row
execute procedure insert_gas_quota()

View File

@ -9,6 +9,11 @@ INSERT INTO keystore(public_key, private_key) VALUES($1, $2) RETURNING id
-- $1: public_key
SELECT private_key FROM keystore WHERE public_key=$1
--name: activate-account
-- Activate an account following successful quorum
-- $1: public_key
UPDATE keystore SET active = true WHERE public_key=$1
--name: create-otx
-- Create a new locally originating tx
-- $1: tracking_id
@ -51,7 +56,7 @@ INSERT INTO otx_dispatch(
UPDATE otx_dispatch SET "status" = $2, "block" = $3 WHERE otx_dispatch.id = (
SELECT otx_dispatch.id FROM otx_dispatch
INNER JOIN otx_sign ON otx_dispatch.otx_id = otx_sign.id
WHERE otx_sign.tx_hash = $1
WHERE otx_sign.tx_hash=$1
AND otx_dispatch.status = 'IN_NETWORK'
)
@ -60,6 +65,40 @@ UPDATE otx_dispatch SET "status" = $2, "block" = $3 WHERE otx_dispatch.id = (
-- $1: tracking_id
SELECT otx_sign.type, otx_sign.tx_hash, otx_sign.transfer_value, otx_sign.created_at, otx_dispatch.status FROM otx_sign
INNER JOIN otx_dispatch ON otx_sign.id = otx_dispatch.otx_id
WHERE otx_sign.tracking_id = $1
WHERE otx_sign.tracking_id=$1
-- TODO: Scroll by status type with cursor pagination
--name: get-account-activation-quorum
-- Gets quorum of required and confirmed system transactions for the account
-- $1: tracking_id
SELECT count(*) FROM otx_dispatch INNER JOIN
otx_sign ON otx_dispatch.otx_id = otx_sign.id
WHERE otx_sign.tracking_id=$1
AND otx_dispatch.status = 'SUCCESS'
--name: get-account-status-by-address
-- Gets current gas quota for an individual account by address
-- $1: public_key
SELECT keystore.active, gas_quota.quota FROM keystore
INNER JOIN gas_quota ON keystore.id = gas_quota.key_id
WHERE keystore.public_key=$1
--name: decr-gas-quota
-- Consumes a gas quota
-- $1: public_key
UPDATE gas_quota SET quota = quota - 1 WHERE key_id = (
SELECT id FROM keystore
WHERE public_key=$1
)
--name: reset-gas-quota
-- Resets the gas quota
-- 25 is the agreed upon quota
-- $1: public_key
UPDATE gas_quota SET quota = gas_quota_meta.default_quota
FROM gas_quota_meta
WHERE key_id = (
SELECT id FROM keystore
WHERE public_key=$1
)