Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c8a8456
feat: faucet github middleware with coolDown
Villaquiranm Feb 22, 2025
dd50e7b
add max balance middleware and tests
Feb 23, 2025
b054763
apply lint
Feb 23, 2025
118c08b
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Feb 24, 2025
07f6145
generalize cooldown limiter
Feb 24, 2025
ab43999
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 17, 2025
962ac2c
add some comments :)
Mar 17, 2025
e59b6a7
Update contribs/gnofaucet/gh.go
Villaquiranm Mar 17, 2025
a55637c
Update contribs/gnofaucet/serve.go
Villaquiranm Mar 17, 2025
6e9727a
add badger as database
Mar 19, 2025
62d710a
fix lint
Mar 19, 2025
6bab9b2
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 19, 2025
32d6d24
Update contribs/gnofaucet/cooldown.go
Villaquiranm Mar 20, 2025
bbd82c0
Update contribs/gnofaucet/cooldown.go
Villaquiranm Mar 20, 2025
64534bd
Update contribs/gnofaucet/cooldown.go
Villaquiranm Mar 20, 2025
dede43c
fix comments :)
Mar 20, 2025
022e923
return error on cooldown limiter
Mar 20, 2025
373f6e0
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 20, 2025
e5a461d
oups typo
Mar 20, 2025
439b1b7
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Mar 26, 2025
5a1dd62
change badger -> redis
Mar 31, 2025
72c7f4d
fixes
Apr 3, 2025
f984a7b
separate cmd for github and captcha
Apr 3, 2025
28182f6
remove check balance on account
Apr 3, 2025
703b16f
implement total claimable limit
Apr 3, 2025
426919a
improve tests
Apr 3, 2025
300b7f4
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Apr 3, 2025
909ad71
fix lint
Apr 3, 2025
82c3600
fix comments
Villaquiranm Apr 4, 2025
cb07372
separate test in different files
Villaquiranm Apr 4, 2025
a4b8827
update Readme
Villaquiranm Apr 4, 2025
1415777
document redis ?
Villaquiranm Apr 4, 2025
3ba49d9
take ugnots from string
Villaquiranm Apr 6, 2025
c9f2535
fix test and change default listen address
Villaquiranm Apr 7, 2025
360317f
Merge branch 'master' into feat/gh-middleware-with-cooldown
Villaquiranm Apr 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions contribs/gnofaucet/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# required
# These credentials are obtained when registering an application on GitHub at:
# https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authenticating-to-the-rest-api-with-an-oauth-app#registering-your-app
GH_CLIENT_SECRET=

# Redis credentials
# We use Redis for keeping a record across several instances of the faucet usage for each user
# Each user is identified by their GitHub login, and each time they claim tokens, the current total claimed amount is stored
# REDIS_ADDR: The address of the Redis server, e.g. "localhost:6379"
# REDIS_USER: The Redis username
# REDIS_PASSWORD: The Redis user's password
# For more information on how to set up Redis, see https://redis.io
REDIS_ADDR=
REDIS_USER=
REDIS_PASSWORD=
20 changes: 18 additions & 2 deletions contribs/gnofaucet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,25 @@ Make sure you have started gnoland

## Step2:

Start the faucet.
Start the faucet. This repository provides middleware for integrating GitHub OAuth authentication or reCAPTCHA verification into the Gno.land faucet. This ensures security by preventing abuse while enabling users to claim tokens securely.
#### Running Recapcha protected faucet:

./build/gnofaucet serve -chain-id dev -mnemonic "source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast"
./build/gnofaucet serve captcha -chain-id dev -mnemonic "source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast" --captcha-secret=<RECAPTCHA_SECRET>

| Flag | Type | Default | Description |
|----------------------|-----------|--------------|-------------|
| `--captcha-secret` | `string` | `""` (empty) | reCAPTCHA secret key. If empty, an errCaptchaMissing error is returned. |


#### Running Github Oauth protected faucet:

./build/gnofaucet serve github -chain-id dev -mnemonic "source bonus chronic canvas draft south burst lottery vacant surface solve popular case indicate oppose farm nothing bullet exhibit title speed wink action roast" --github-client-id=<CLIENT_ID> --cooldown-period=24h --max-claimable-limit=100000000 (100 gnot)

| Flag | Type | Default | Description |
|-----------------------|------------|--------------|-------------|
| `--github-client-id` | `string` | `""` (empty) | GitHub client ID for OAuth authentication. |
| `--cooldown-period` | `duration` | `24h` | Minimum required time between consecutive claims by the same user. |
| `--max-claimable-limit` | `int64` | `0` | Maximum number of tokens a user can claim over their lifetime. Zero means no limit |

By default, the faucet sends out 10,000,000ugnot (10gnot) per request.

Expand Down
51 changes: 51 additions & 0 deletions contribs/gnofaucet/captcha.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"context"
"flag"
"fmt"

"github.com/gnolang/gno/tm2/pkg/commands"
)

type captchaCfg struct {
rootCfg *serveCfg
captchaSecret string
}

var errCaptchaMissing = fmt.Errorf("captcha secret is required")

func (c *captchaCfg) RegisterFlags(fs *flag.FlagSet) {
fs.StringVar(
&c.captchaSecret,
"captcha-secret",
"",
"recaptcha secret key (if empty, captcha are disabled)",
)
}

func newCaptchaCmd(rootCfg *serveCfg) *commands.Command {
cfg := &captchaCfg{
rootCfg: rootCfg,
}

return commands.NewCommand(
commands.Metadata{
Name: "captcha",
ShortUsage: "captcha [flags]",
LongHelp: "applies captcha middleware to the gno.land faucet",
},
cfg,
func(ctx context.Context, args []string) error {
return execCaptcha(ctx, cfg, commands.NewDefaultIO())
},
)
}

func execCaptcha(ctx context.Context, cfg *captchaCfg, io commands.IO) error {
if cfg.captchaSecret == "" {
return errCaptchaMissing
}

return serveFaucet(ctx, cfg.rootCfg, io, getCaptchaMiddleware(cfg.captchaSecret))
}
72 changes: 72 additions & 0 deletions contribs/gnofaucet/captcha_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestServeCapcha(t *testing.T) {
t.Run("Serve captcha without captcha-secret", func(t *testing.T) {
cmd := newServeCmd()
args := []string{
"captcha",
}

// Run the command
cmdErr := cmd.ParseAndRun(context.Background(), args)
assert.ErrorIs(t, cmdErr, errCaptchaMissing)
})

t.Run("Serve captcha without chain-id", func(t *testing.T) {
cmd := newServeCmd()
args := []string{
"captcha",
"--captcha-secret",
"dummy-secret",
}

// Run the command
cmdErr := cmd.ParseAndRun(context.Background(), args)
assert.ErrorContains(t, cmdErr, "invalid chain ID")
})

t.Run("Serve captcha with invalid mnemonic", func(t *testing.T) {
cmd := newServeCmd()
args := []string{
"captcha",
"--captcha-secret",
"dummy-secret",
"--chain-id",
"dev",
}

// Run the command
cmdErr := cmd.ParseAndRun(context.Background(), args)
assert.ErrorContains(t, cmdErr, "invalid mnemonic")
})

t.Run("Serve captcha OK", func(t *testing.T) {
cmd := newServeCmd()
args := []string{
"captcha",
"--captcha-secret",
"dummy-secret",
"--chain-id",
"dev",
"--mnemonic",
defaultAccount_Seed,
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(time.Millisecond * 100)
cancel()
}()
// Run the command
cmdErr := cmd.ParseAndRun(ctx, args)
require.NoError(t, cmdErr)
})
}
87 changes: 87 additions & 0 deletions contribs/gnofaucet/cooldown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
)

// CooldownLimiter limits a specific user to one claim per cooldown period
// this limiter keeps track of which keys are on cooldown using a badger database (written to a local file)
type CooldownLimiter struct {
redis *redis.Client
cooldownTime time.Duration
maxlifeTimeAmount *int64
}

// NewCooldownLimiter initializes a Cooldown Limiter with a given duration
func NewCooldownLimiter(cooldown time.Duration, redis *redis.Client, maxlifeTimeAmount int64) *CooldownLimiter {
limiter := &CooldownLimiter{
redis: redis,
cooldownTime: cooldown,
}
if maxlifeTimeAmount > 0 {
limiter.maxlifeTimeAmount = &maxlifeTimeAmount
}

return limiter
}

// CheckCooldown checks if a key can make a claim or if it is still within the cooldown period
// also checks that the user will not exceed the max lifetime allowed amount
// Returns true if the key is not on cooldown, and marks the key as on cooldown
// Returns false if the key is on cooldown or if an error occurs
func (rl *CooldownLimiter) CheckCooldown(ctx context.Context, key string, amountClaimed int64) (bool, error) {
claimData, err := rl.getClaimsData(ctx, key)
if err != nil {
return false, fmt.Errorf("unable to check if key is on cooldown, %w", err)
}

Check warning on line 42 in contribs/gnofaucet/cooldown.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/cooldown.go#L41-L42

Added lines #L41 - L42 were not covered by tests
// Deny claim if within cooldown period
if claimData.LastClaimed.Add(rl.cooldownTime).After(time.Now()) {
return false, nil
}
// check that user will not exceed max lifetime allowed amount
if rl.maxlifeTimeAmount != nil && claimData.TotalClaimed+amountClaimed > *rl.maxlifeTimeAmount {
return false, nil
}

return true, rl.declareClaimedValue(ctx, key, amountClaimed, claimData)
}

func (rl *CooldownLimiter) getClaimsData(ctx context.Context, key string) (*claimData, error) {
storedData, err := rl.redis.Get(ctx, key).Result()
if err != nil {
// Here we return an empty claimData because is the first time the user is making a claim
// the total amount claimed is 0 and the lastClaimed is the default time value
if errors.Is(err, redis.Nil) {
return &claimData{}, nil
}
// Any other unexpected error is returned.
return nil, err

Check warning on line 64 in contribs/gnofaucet/cooldown.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/cooldown.go#L64

Added line #L64 was not covered by tests
}

claimData := &claimData{}
err = json.Unmarshal([]byte(storedData), claimData)
return claimData, err
}

func (rl *CooldownLimiter) declareClaimedValue(ctx context.Context, key string, amountClaimed int64, currentData *claimData) error {
currentData.LastClaimed = time.Now()
currentData.TotalClaimed += amountClaimed

data, err := json.Marshal(currentData)
if err != nil {
return fmt.Errorf("unable to marshal claim data, %w", err)
}

Check warning on line 79 in contribs/gnofaucet/cooldown.go

View check run for this annotation

Codecov / codecov/patch

contribs/gnofaucet/cooldown.go#L78-L79

Added lines #L78 - L79 were not covered by tests

return rl.redis.Set(ctx, key, data, 0).Err()
}

type claimData struct {
LastClaimed time.Time
TotalClaimed int64
}
44 changes: 44 additions & 0 deletions contribs/gnofaucet/cooldown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package main

import (
"context"
"testing"
"time"

"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)

func TestCooldownLimiter(t *testing.T) {
var tenGnots int64 = 10_000_000
redisServer := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{
Addr: redisServer.Addr(),
})

cooldownDuration := time.Second
limiter := NewCooldownLimiter(cooldownDuration, rdb, 0)
ctx := context.Background()
user := "testUser"

// First check should be allowed
allowed, err := limiter.CheckCooldown(ctx, user, tenGnots)
require.NoError(t, err)

if !allowed {
t.Errorf("Expected first CheckCooldown to return true, but got false")
}

allowed, err = limiter.CheckCooldown(ctx, user, tenGnots)
require.NoError(t, err)
// Second check immediately should be denied
if allowed {
t.Errorf("Expected second CheckCooldown to return false, but got true")
}

require.Eventually(t, func() bool {
allowed, err := limiter.CheckCooldown(ctx, user, tenGnots)
return err == nil && !allowed
}, 2*cooldownDuration, 10*time.Millisecond, "Expected CheckCooldown to return true after cooldown period")
}
Loading
Loading