From c1befb39d544fd6b417f4a863d18655d57c7d159 Mon Sep 17 00:00:00 2001 From: Morgan Date: Fri, 13 Mar 2026 17:33:00 +0100 Subject: [PATCH 1/5] fix(gnofaucet): improve hCaptcha api compliance & observability (#5286) - Switch siteverify endpoint to the canonical `https://api.hcaptcha.com/siteverify` - Send `remoteip` (recommended by hCaptcha docs) to improve fraud signal accuracy; the value is the already-validated client IP resolved by `ipMiddleware`, threaded through via request context - Add optional `--captcha-sitekey` flag; when set, the sitekey is forwarded to hCaptcha's siteverify endpoint to prevent tokens issued for other sites from being accepted - Add `--log-level` flag to the `serve` command (default `info`), replacing the previously hardcoded `debug` level across all subcommands; consolidate the three duplicate logger constructions into a single `newLogger` method on `serveCfg` - Add structured `debug`-level logging around each hCaptcha verification call (request params and response fields including `error_codes`), observable in production by setting `--log-level debug` --- contribs/gnofaucet/captcha.go | 23 ++++++++++++---- contribs/gnofaucet/github.go | 24 +++++++---------- contribs/gnofaucet/middleware.go | 39 ++++++++++++++++++++++++--- contribs/gnofaucet/middleware_test.go | 37 ++++++++++++++++++++++--- contribs/gnofaucet/serve.go | 31 ++++++++++++++------- 5 files changed, 116 insertions(+), 38 deletions(-) diff --git a/contribs/gnofaucet/captcha.go b/contribs/gnofaucet/captcha.go index b02c3ab0478..0414943ae30 100644 --- a/contribs/gnofaucet/captcha.go +++ b/contribs/gnofaucet/captcha.go @@ -11,8 +11,9 @@ import ( ) type captchaCfg struct { - rootCfg *serveCfg - captchaSecret string + rootCfg *serveCfg + captchaSecret string + captchaSitekey string } var errCaptchaMissing = fmt.Errorf("captcha secret is required") @@ -22,7 +23,14 @@ func (c *captchaCfg) RegisterFlags(fs *flag.FlagSet) { &c.captchaSecret, "captcha-secret", "", - "hcaptcha secret key (if empty, captcha are disabled)", + "hcaptcha secret key (required)", + ) + + fs.StringVar( + &c.captchaSitekey, + "captcha-sitekey", + "", + "hcaptcha site key; when set, tokens issued for other site keys are rejected", ) } @@ -49,6 +57,11 @@ func execCaptcha(ctx context.Context, cfg *captchaCfg, io commands.IO) error { return errCaptchaMissing } + logger, err := cfg.rootCfg.newLogger(io) + if err != nil { + return err + } + // Start the IP throttler st := newIPThrottler(defaultRateLimitInterval, defaultCleanTimeout) st.start(ctx) @@ -59,13 +72,13 @@ func execCaptcha(ctx context.Context, cfg *captchaCfg, io commands.IO) error { } rpcMiddlewares := []faucet.Middleware{ - captchaMiddleware(cfg.captchaSecret), + captchaMiddleware(cfg.captchaSecret, cfg.captchaSitekey, logger), } return serveFaucet( ctx, cfg.rootCfg, - io, + logger, faucet.WithHTTPMiddlewares(httpMiddlewares), faucet.WithMiddlewares(rpcMiddlewares), ) diff --git a/contribs/gnofaucet/github.go b/contribs/gnofaucet/github.go index e344cc81099..f89880de031 100644 --- a/contribs/gnofaucet/github.go +++ b/contribs/gnofaucet/github.go @@ -12,12 +12,10 @@ import ( "github.com/Khan/genqlient/graphql" "github.com/gnolang/faucet" - "github.com/gnolang/gno/gno.land/pkg/log" "github.com/gnolang/gno/tm2/pkg/commands" "github.com/google/go-github/v74/github" "github.com/jferrl/go-githubauth" "github.com/redis/go-redis/v9" - "go.uber.org/zap/zapcore" "golang.org/x/oauth2" igh "github.com/gnolang/gno/contribs/gnofaucet/github" @@ -159,12 +157,10 @@ func execGithub(ctx context.Context, cfg *githubCfg, io commands.IO) error { rr := igh.NewRedisRewarder(rdb, rewarderCfg) - logger := log.ZapLoggerToSlog( - log.NewZapJSONLogger( - io.Out(), - zapcore.DebugLevel, - ), - ) + logger, err := cfg.rootCfg.newLogger(io) + if err != nil { + return err + } // Prepare the middlewares httpMiddlewares := []func(http.Handler) http.Handler{ @@ -177,7 +173,7 @@ func execGithub(ctx context.Context, cfg *githubCfg, io commands.IO) error { return serveFaucet( ctx, cfg.rootCfg, - io, + logger, faucet.WithHTTPMiddlewares(httpMiddlewares), faucet.WithMiddlewares(rpcMiddlewares), ) @@ -215,12 +211,10 @@ func execGHFetcher(ctx context.Context, cfg *ghFetcherCfg, io commands.IO) error return fmt.Errorf("unable to connect to redis, %w", err) } - logger := log.ZapLoggerToSlog( - log.NewZapJSONLogger( - io.Out(), - zapcore.DebugLevel, - ), - ) + logger, err := cfg.rootCfg.newLogger(io) + if err != nil { + return err + } appTokenSource, err := githubauth.NewApplicationTokenSource(appID, privKey) if err != nil { diff --git a/contribs/gnofaucet/middleware.go b/contribs/gnofaucet/middleware.go index df47a6e75b7..8204c68d5ca 100644 --- a/contribs/gnofaucet/middleware.go +++ b/contribs/gnofaucet/middleware.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net" "net/http" "net/netip" @@ -16,6 +17,11 @@ import ( "github.com/gnolang/faucet/spec" ) +// contextKey is an unexported type for context keys in this package. +type contextKey int + +const remoteIPContextKey contextKey = iota + // ipMiddleware returns the IP verification middleware, using the given subnet throttler func ipMiddleware(behindProxy bool, st *ipThrottler) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -69,15 +75,18 @@ func ipMiddleware(behindProxy bool, st *ipThrottler) func(next http.Handler) htt return } + // Store the resolved IP in the context for use by RPC middlewares + ctx := context.WithValue(r.Context(), remoteIPContextKey, hostAddr.String()) + // Continue with serving the faucet request - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(ctx)) }, ) } } // captchaMiddleware returns the captcha middleware, if any -func captchaMiddleware(secret string) faucet.Middleware { +func captchaMiddleware(secret, sitekey string, logger *slog.Logger) faucet.Middleware { return func(next faucet.HandlerFunc) faucet.HandlerFunc { return func(ctx context.Context, req *spec.BaseJSONRequest) *spec.BaseJSONResponse { // Parse the request meta to extract the captcha secret @@ -94,8 +103,11 @@ func captchaMiddleware(secret string) faucet.Middleware { ) } + // Extract the resolved client IP stored by ipMiddleware + remoteIP, _ := ctx.Value(remoteIPContextKey).(string) + // Verify the captcha response - if err := checkHcaptcha(secret, strings.TrimSpace(meta.Captcha)); err != nil { + if err := checkHcaptcha(secret, strings.TrimSpace(meta.Captcha), remoteIP, sitekey, logger); err != nil { return spec.NewJSONResponse( req.ID, nil, @@ -110,7 +122,7 @@ func captchaMiddleware(secret string) faucet.Middleware { } // checkHcaptcha checks the captcha challenge -func checkHcaptcha(secret, response string) error { +func checkHcaptcha(secret, response, remoteIP, sitekey string, logger *slog.Logger) error { // Create an HTTP client with a timeout client := &http.Client{ Timeout: time.Second * 10, @@ -120,6 +132,18 @@ func checkHcaptcha(secret, response string) error { form := url.Values{} form.Set("secret", secret) form.Set("response", response) + if remoteIP != "" { + form.Set("remoteip", remoteIP) + } + if sitekey != "" { + form.Set("sitekey", sitekey) + } + + logger.Debug("sending hcaptcha verification request", + slog.String("remoteip", remoteIP), + slog.Bool("secret_set", secret != ""), + slog.Bool("sitekey_set", sitekey != ""), + ) // Create the request req, err := http.NewRequest( @@ -151,6 +175,13 @@ func checkHcaptcha(secret, response string) error { return fmt.Errorf("failed to decode response, %w", err) } + logger.Debug("received hcaptcha verification response", + slog.String("remoteip", remoteIP), + slog.Bool("success", body.Success), + slog.String("hostname", body.Hostname), + slog.Any("error_codes", body.ErrorCodes), + ) + // Check if the hcaptcha verification was successful if !body.Success { return errInvalidCaptcha diff --git a/contribs/gnofaucet/middleware_test.go b/contribs/gnofaucet/middleware_test.go index 79b6c14bdcf..f778c8090e1 100644 --- a/contribs/gnofaucet/middleware_test.go +++ b/contribs/gnofaucet/middleware_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "io" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -12,6 +13,9 @@ import ( "github.com/stretchr/testify/require" ) +// discardLogger is a no-op logger for use in tests. +var discardLogger = slog.New(slog.NewTextHandler(io.Discard, nil)) + // hCaptcha test credentials — always pass verification without a real browser. // See: https://docs.hcaptcha.com/#integration-testing-test-keys const ( @@ -37,6 +41,8 @@ func TestCheckHcaptcha(t *testing.T) { assert.Equal(t, "test-secret", vals.Get("secret")) assert.Equal(t, "test-response", vals.Get("response")) + assert.Empty(t, vals.Get("remoteip")) + assert.Empty(t, vals.Get("sitekey")) // Verify no query params were used assert.Empty(t, r.URL.RawQuery) @@ -50,7 +56,30 @@ func TestCheckHcaptcha(t *testing.T) { siteVerifyURL = srv.URL defer func() { siteVerifyURL = orig }() - require.NoError(t, checkHcaptcha("test-secret", "test-response")) + require.NoError(t, checkHcaptcha("test-secret", "test-response", "", "", discardLogger)) + }) + + t.Run("success with remoteip and sitekey", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + vals, err := url.ParseQuery(string(body)) + require.NoError(t, err) + + assert.Equal(t, "1.2.3.4", vals.Get("remoteip")) + assert.Equal(t, "test-sitekey", vals.Get("sitekey")) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(SiteVerifyResponse{Success: true}) + })) + defer srv.Close() + + orig := siteVerifyURL + siteVerifyURL = srv.URL + defer func() { siteVerifyURL = orig }() + + require.NoError(t, checkHcaptcha("test-secret", "test-response", "1.2.3.4", "test-sitekey", discardLogger)) }) t.Run("verification failure", func(t *testing.T) { @@ -64,7 +93,7 @@ func TestCheckHcaptcha(t *testing.T) { siteVerifyURL = srv.URL defer func() { siteVerifyURL = orig }() - err := checkHcaptcha("test-secret", "bad-token") + err := checkHcaptcha("test-secret", "bad-token", "", "", discardLogger) assert.Equal(t, errInvalidCaptcha, err) }) @@ -78,7 +107,7 @@ func TestCheckHcaptcha(t *testing.T) { siteVerifyURL = srv.URL defer func() { siteVerifyURL = orig }() - err := checkHcaptcha("test-secret", "test-response") + err := checkHcaptcha("test-secret", "test-response", "", "", discardLogger) assert.ErrorContains(t, err, "unexpected status code") }) @@ -89,6 +118,6 @@ func TestCheckHcaptcha(t *testing.T) { t.Skip("skipping network test in short mode") } - require.NoError(t, checkHcaptcha(hcaptchaTestSecret, hcaptchaTestResponse)) + require.NoError(t, checkHcaptcha(hcaptchaTestSecret, hcaptchaTestResponse, "", "", discardLogger)) }) } diff --git a/contribs/gnofaucet/serve.go b/contribs/gnofaucet/serve.go index 2427b0f85b1..84f1881255b 100644 --- a/contribs/gnofaucet/serve.go +++ b/contribs/gnofaucet/serve.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "log/slog" "regexp" "strconv" "time" @@ -27,7 +28,7 @@ const ( ) // url & struct for verify captcha -var siteVerifyURL = "https://hcaptcha.com/siteverify" +var siteVerifyURL = "https://api.hcaptcha.com/siteverify" const ( ipv6Loopback = "::1" @@ -55,6 +56,7 @@ type serveCfg struct { remote string isBehindProxy bool + logLevel string } func newServeCmd() *commands.Command { @@ -127,6 +129,23 @@ func (c *serveCfg) RegisterFlags(fs *flag.FlagSet) { false, "use X-Forwarded-For IP for throttling", ) + + fs.StringVar( + &c.logLevel, + "log-level", + "info", + "log level (debug, info, warn, error)", + ) +} + +// newLogger constructs a JSON structured logger at the configured level. +func (c *serveCfg) newLogger(io commands.IO) (*slog.Logger, error) { + var level zapcore.Level + if err := level.UnmarshalText([]byte(c.logLevel)); err != nil { + return nil, fmt.Errorf("invalid log level %q: %w", c.logLevel, err) + } + + return log.ZapLoggerToSlog(log.NewZapJSONLogger(io.Out(), level)), nil } // generateFaucetConfig generates the Faucet configuration @@ -147,7 +166,7 @@ func (c *serveCfg) generateFaucetConfig() *config.Config { func serveFaucet( ctx context.Context, cfg *serveCfg, - io commands.IO, + logger *slog.Logger, opts ...faucet.Option, ) error { // Parse static gas values. @@ -178,14 +197,6 @@ func serveFaucet( return fmt.Errorf("unable to create TM2 client, %w", err) } - // Set up the logger - logger := log.ZapLoggerToSlog( - log.NewZapJSONLogger( - io.Out(), - zapcore.DebugLevel, - ), - ) - faucetOpts := []faucet.Option{ faucet.WithLogger(logger), faucet.WithConfig(cfg.generateFaucetConfig()), From 5d5f9213fa69f9091e79c8b59df32de66672da43 Mon Sep 17 00:00:00 2001 From: ltzmaxwell Date: Sun, 15 Mar 2026 18:54:21 +0800 Subject: [PATCH 2/5] fix(gnovm): proper gas consumption for mem allocation (#5091) alloc per-byte, first step to correct/improve gas assumption for memory allocation. --- gno.land/pkg/integration/testdata/gc.txtar | 6 ++--- gnovm/pkg/gnolang/alloc.go | 28 ++++++++++------------ gnovm/pkg/gnolang/garbage_collector.go | 8 +++---- gnovm/tests/files/gas/slice_alloc.gno | 2 +- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/gno.land/pkg/integration/testdata/gc.txtar b/gno.land/pkg/integration/testdata/gc.txtar index 2af7d95a789..5d07c6d0e17 100644 --- a/gno.land/pkg/integration/testdata/gc.txtar +++ b/gno.land/pkg/integration/testdata/gc.txtar @@ -6,8 +6,8 @@ loadpkg gno.land/r/gc $WORK/r/gc gnoland start -no-parallel -gnokey maketx call -pkgpath gno.land/r/gc -func Alloc -gas-fee 10000000ugnot -gas-wanted 300000000 -simulate skip -broadcast -chainid tendermint_test test1 -stdout 'GAS USED: 262693098' +gnokey maketx call -pkgpath gno.land/r/gc -func Alloc -gas-fee 10000000ugnot -gas-wanted 3000000000 -simulate skip -broadcast -chainid tendermint_test test1 +stdout 'GAS USED: 1048705180' -- r/gc/gc.gno -- package gc @@ -17,7 +17,7 @@ func gen() { } func Alloc(cur realm) { - for i := 0; i < 100; i++ { + for i := 0; i < 2; i++ { gen() gen() } diff --git a/gnovm/pkg/gnolang/alloc.go b/gnovm/pkg/gnolang/alloc.go index 6e07623d32d..b7900795b57 100644 --- a/gnovm/pkg/gnolang/alloc.go +++ b/gnovm/pkg/gnolang/alloc.go @@ -14,13 +14,8 @@ import ( type Allocator struct { maxBytes int64 bytes int64 - // `peakBytes` represents the maximum memory - // usage during a single transaction, and is used - // to calculate the corresponding gas usage. - // It increases monotonically. - peakBytes int64 - collect func() (left int64, ok bool) // gc callback - gasMeter store.GasMeter + collect func() (left int64, ok bool) // gc callback + gasMeter store.GasMeter } // for gonative, which doesn't consider the allocator. @@ -120,6 +115,13 @@ func (alloc *Allocator) Reset() *Allocator { return alloc } +// Recount adds size to bytes without charging gas. +// Used during GC re-walk to re-count surviving objects +// without double-charging for already-paid allocations. +func (alloc *Allocator) Recount(size int64) { + alloc.bytes += size +} + func (alloc *Allocator) Fork() *Allocator { if alloc == nil { return nil @@ -151,15 +153,11 @@ func (alloc *Allocator) Allocate(size int64) { } else { alloc.bytes += size } - // The value of `bytes` decreases during GC, and fees - // are only charged when it exceeds peakBytes (again). - if alloc.bytes > alloc.peakBytes { - if alloc.gasMeter != nil { - change := alloc.bytes - alloc.peakBytes - alloc.gasMeter.ConsumeGas(overflow.Mulp(change, GasCostPerByte), "memory allocation") - } - alloc.peakBytes = alloc.bytes + // Charge gas for every allocation unconditionally (cpu/throughput). + // This ensures repeated allocate-then-GC cycles are not free. + if alloc.gasMeter != nil { + alloc.gasMeter.ConsumeGas(overflow.Mulp(size, GasCostPerByte), "memory allocation (cpu)") } } diff --git a/gnovm/pkg/gnolang/garbage_collector.go b/gnovm/pkg/gnolang/garbage_collector.go index d3d5cb3e7e3..32cf3f9c48f 100644 --- a/gnovm/pkg/gnolang/garbage_collector.go +++ b/gnovm/pkg/gnolang/garbage_collector.go @@ -160,7 +160,7 @@ func GCVisitorFn(gcCycle int64, alloc *Allocator, visitCount *int64) Visitor { return true } - alloc.Allocate(size) + alloc.Recount(size) // bump before visiting associated, // this avoids infinite recursion. @@ -406,7 +406,7 @@ func (tv TypeValue) VisitAssociated(vis Visitor) (stop bool) { func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) { // vis receiver if fr.Receiver.IsDefined() { - alloc.Allocate(allocTypedValue) // alloc shallowly + alloc.Recount(allocTypedValue) // reclaim shallowly if v := fr.Receiver.V; v != nil { stop = vis(v) @@ -435,7 +435,7 @@ func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) { } for _, arg := range dfr.Args { - alloc.Allocate(allocTypedValue) + alloc.Recount(allocTypedValue) if arg.V != nil { stop = vis(arg.V) @@ -466,7 +466,7 @@ func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) { func (e *Exception) Visit(alloc *Allocator, vis Visitor) (stop bool) { // vis value - alloc.Allocate(allocTypedValue) + alloc.Recount(allocTypedValue) if v := e.Value.V; v != nil { stop = vis(v) } diff --git a/gnovm/tests/files/gas/slice_alloc.gno b/gnovm/tests/files/gas/slice_alloc.gno index e8c615844a8..c5f263b4629 100644 --- a/gnovm/tests/files/gas/slice_alloc.gno +++ b/gnovm/tests/files/gas/slice_alloc.gno @@ -11,4 +11,4 @@ func alloc(n int) { } // Gas: -// 500003015 +// 500003087 From c1a785ad79a67ab22dd8918c6d8d38b248ba1078 Mon Sep 17 00:00:00 2001 From: Alexis Colin Date: Mon, 16 Mar 2026 18:17:40 +0900 Subject: [PATCH 3/5] style(gnoweb): update SVG logo to be visible on Safari (#5255) Safari iOS does not re-evaluate CSS custom properties in SVG inline style attributes when variable values change dynamically. The logo fills were invisible in dark mode because `fill: var(--s-logo-hat)` set via inline style was never recalculated after the theme switch. **Fix: moved fills to CSS stylesheet rules (`b-gnome/b-logo blocks`) so the browser cascades correctly on theme change.** Tested on Safari iOS (iPhone) in dark mode: logo now renders correctly on initial load without requiring any repaint. IMG_4054 IMG_4053 --- gno.land/pkg/gnoweb/components/ui/gnome.html | 6 +++--- gno.land/pkg/gnoweb/components/ui/logo.html | 6 +++--- gno.land/pkg/gnoweb/frontend/css/02-tools.css | 4 ++++ gno.land/pkg/gnoweb/frontend/css/06-blocks.css | 12 ++++++++++++ gno.land/pkg/gnoweb/public/main.css | 2 +- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/gno.land/pkg/gnoweb/components/ui/gnome.html b/gno.land/pkg/gnoweb/components/ui/gnome.html index d97bb255565..fef7edf6e20 100644 --- a/gno.land/pkg/gnoweb/components/ui/gnome.html +++ b/gno.land/pkg/gnoweb/components/ui/gnome.html @@ -1,10 +1,10 @@ {{ define "ui/gnome" }} -Capture d’écran 2026-02-20 à 11 47
27 --------- Co-authored-by: Jae Kwon <53785+jaekwon@users.noreply.github.com> Co-authored-by: jaekwon --- .../pkg/gnoweb/components/layouts/footer.html | 36 +--- .../pkg/gnoweb/frontend/css/06-blocks.css | 159 +++--------------- gno.land/pkg/gnoweb/public/main.css | 5 +- 3 files changed, 34 insertions(+), 166 deletions(-) diff --git a/gno.land/pkg/gnoweb/components/layouts/footer.html b/gno.land/pkg/gnoweb/components/layouts/footer.html index 529f40ca8c4..191d644709b 100644 --- a/gno.land/pkg/gnoweb/components/layouts/footer.html +++ b/gno.land/pkg/gnoweb/components/layouts/footer.html @@ -1,8 +1,8 @@ {{ define "layouts/footer" }}