diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 5d9750a41d7..1e9642e80df 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -9,6 +9,8 @@ on: # any change to gnovm code can make examples fail - gnovm/** - examples/** + - misc/deployments/** + - contribs/gnogenesis/** workflow_dispatch: inputs: debug: @@ -67,6 +69,28 @@ jobs: modulepath: "examples" go-version: "1.24.x" + #deployments: + # name: Validate deployments + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # steps: + # - uses: actions/checkout@v5 + # - uses: actions/setup-go@v5 + # with: + # go-version: "1.23.x" + # - name: Run deployment generate & test + # run: | + # for dir in misc/deployments/*/; do + # if [ -f "$dir/Makefile" ] && make -C "$dir" -n generate >/dev/null 2>&1; then + # echo "=== $dir ===" + # make -C "$dir" generate + # if make -C "$dir" -n test >/dev/null 2>&1; then + # make -C "$dir" test + # fi + # make -C "$dir" clean + # fi + # done + mod-tidy: strategy: fail-fast: false 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()), diff --git a/examples/gno.land/r/gov/dao/v3/loader/loader.gno b/examples/gno.land/r/gov/dao/v3/loader/loader.gno index b82f6adba79..6e14649ae44 100644 --- a/examples/gno.land/r/gov/dao/v3/loader/loader.gno +++ b/examples/gno.land/r/gov/dao/v3/loader/loader.gno @@ -1,3 +1,23 @@ +// loader.gno initialises the govDAO v3 implementation and tier structure. +// +// It intentionally does NOT add any members or set AllowedDAOs. When the +// allowedDAOs list in the DAO proxy is empty, InAllowedDAOs() returns true +// for any caller (see r/gov/dao/proxy.gno), which lets a subsequent MsgRun +// bootstrap the member set and then lock things down. +// +// Bootstrap flow (official network genesis or local dev): +// +// 1. All packages — including this loader — are deployed via MsgAddPackage. +// The loader sets up tier entries and the DAO implementation. +// 2. A MsgRun executes a setup script (e.g. govdao_prop1.gno) which: +// a. Adds a temporary deployer as T1 member (for supermajority). +// b. Creates a governance proposal to register validators, votes YES, +// and executes it. +// c. Adds the real govDAO members directly via memberstore.Get(). +// d. Removes the temporary deployer. +// e. Calls dao.UpdateImpl to set AllowedDAOs, locking down access. +// +// See misc/deployments/ for concrete genesis generation examples. package loader import ( @@ -6,41 +26,15 @@ import ( "gno.land/r/gov/dao/v3/memberstore" ) -// this is only executed when loaded into genesis func init() { + // Create tier entries in the members tree (required before any SetMember). memberstore.Get().SetTier(memberstore.T1) - memberstore.Get().SetMember(memberstore.T1, address("g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj"), &memberstore.Member{InvitationPoints: 3}) // Jae - memberstore.Get().SetMember(memberstore.T1, address("g1manfred47kzduec920z88wfr64ylksmdcedlf5"), &memberstore.Member{InvitationPoints: 3}) // Manfred - memberstore.Get().SetMember(memberstore.T1, address("g1e6gxg5tvc55mwsn7t7dymmlasratv7mkv0rap2"), &memberstore.Member{InvitationPoints: 3}) // Milos - memberstore.Get().SetMember(memberstore.T1, address("g18amm3fc00t43dcxsys6udug0czyvqt9e7p23rd"), &memberstore.Member{InvitationPoints: 3}) // Marc - memberstore.Get().SetMember(memberstore.T1, address("g1mx4pum9976th863jgry4sdjzfwu03qan5w2v9j"), &memberstore.Member{InvitationPoints: 3}) // Ray - memberstore.Get().SetMember(memberstore.T1, address("g1m0rgan0rla00ygmdmp55f5m0unvsvknluyg2a4"), &memberstore.Member{InvitationPoints: 3}) // Morgan - memberstore.Get().SetMember(memberstore.T1, address("g1ker4vvggvsyatexxn3hkthp2hu80pkhrwmuczr"), &memberstore.Member{InvitationPoints: 3}) // Sergio - memberstore.Get().SetMember(memberstore.T1, address("g1aeddlftlfk27ret5rf750d7w5dume3kcsm8r8m"), &memberstore.Member{InvitationPoints: 3}) // Antoine - memberstore.Get().SetMember(memberstore.T1, address("g1hy6zry03hg5d8le9s2w4fxme6236hkgd928dun"), &memberstore.Member{InvitationPoints: 3}) // Jerónimo - memberstore.Get().SetMember(memberstore.T1, address("g1lckl8j2g3jyyuq6fx7pke3uz4kemht7lw4fg5l"), &memberstore.Member{InvitationPoints: 3}) // Danny - memberstore.Get().SetMember(memberstore.T1, address("g1778y2yphxs2wpuaflsy5y9qwcd4gttn4g5yjx5"), &memberstore.Member{InvitationPoints: 3}) // Michelle - memberstore.Get().SetMember(memberstore.T1, address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5"), &memberstore.Member{InvitationPoints: 3}) // Leon - memberstore.Get().SetTier(memberstore.T2) - memberstore.Get().SetMember(memberstore.T2, address("g1jazghxvvgz3egnr2fc8uf72z4g0l03596y9ls7"), &memberstore.Member{InvitationPoints: 2}) // Nemanja - memberstore.Get().SetMember(memberstore.T2, address("g1dfr24yhk5ztwtqn2a36m8f6ud8cx5hww4dkjfl"), &memberstore.Member{InvitationPoints: 2}) // Antonio - memberstore.Get().SetMember(memberstore.T2, address("g12vx7dn3dqq89mz550zwunvg4qw6epq73d9csay"), &memberstore.Member{InvitationPoints: 2}) // Dongwon - memberstore.Get().SetMember(memberstore.T2, address("g17n4y745s08awwq4e0a38lagsgtntna0749tnxe"), &memberstore.Member{InvitationPoints: 2}) // Jinwoo - memberstore.Get().SetMember(memberstore.T2, address("g1mpkp5lm8lwpm0pym4388836d009zfe4maxlqsq"), &memberstore.Member{InvitationPoints: 2}) // Alexis - memberstore.Get().SetMember(memberstore.T2, address("g197q5e9v00vuz256ly7fq7v3ekaun5cr7wmjgfh"), &memberstore.Member{InvitationPoints: 2}) // Salvo - memberstore.Get().SetTier(memberstore.T3) - memberstore.Get().SetMember(memberstore.T3, address("g1qynsu9dwj9lq0m5fkje7jh6qy3md80ztqnshhm"), &memberstore.Member{InvitationPoints: 1}) // Rémi - memberstore.Get().SetMember(memberstore.T3, address("g1mq7g0jszdmn4qdpc9tq94w0gyex37su892n80m"), &memberstore.Member{InvitationPoints: 1}) // Alan - memberstore.Get().SetMember(memberstore.T3, address("g14u5eaheavy0ux4dmpykg2gvxpvqvexm9cyg58a"), &memberstore.Member{InvitationPoints: 1}) // Norman - memberstore.Get().SetMember(memberstore.T3, address("g1sw5xklxjjuv0yvuxy5f5s3l3mnj0nqq626a9wr"), &memberstore.Member{InvitationPoints: 1}) // Albert - memberstore.Get().SetMember(memberstore.T3, address("g1ckae7tc5sez8ul3ssne75sk4muwgttp6ks2ky9"), &memberstore.Member{InvitationPoints: 1}) // ByeongJun - memberstore.Get().SetMember(memberstore.T3, address("g127l4gkhk0emwsx5tmxe96sp86c05h8vg5tufzq"), &memberstore.Member{InvitationPoints: 1}) // Maxwell - memberstore.Get().SetMember(memberstore.T3, address("g19p3yzr3cuhzqa02j0ce6kzvyjqfzwemw3vam0x"), &memberstore.Member{InvitationPoints: 1}) // Guilhem + // Set the DAO implementation. AllowedDAOs is intentionally left empty + // so that the genesis MsgRun can manipulate the memberstore directly. dao.UpdateImpl(cross, dao.UpdateRequest{ - DAO: impl.GetInstance(), - AllowedDAOs: []string{"gno.land/r/gov/dao/v3/impl"}, + DAO: impl.GetInstance(), }) } diff --git a/examples/gno.land/r/sys/names/verifier.gno b/examples/gno.land/r/sys/names/verifier.gno index 3f0bf81271b..0f075b7a0b5 100644 --- a/examples/gno.land/r/sys/names/verifier.gno +++ b/examples/gno.land/r/sys/names/verifier.gno @@ -5,7 +5,7 @@ package names import "gno.land/p/nt/ownable/v0" var ( - Ownable = ownable.NewWithAddressByPrevious("g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh") // govdao t1 multisig — dropped in genesis via Enable. + Ownable = ownable.NewWithAddressByPrevious("g1edq4dugw0sgat4zxcw9xardvuydqf6cgleuc8p") // genesis deployer — dropped in genesis via Enable. enabled = false ) diff --git a/examples/gno.land/r/sys/names/verifier_test.gno b/examples/gno.land/r/sys/names/verifier_test.gno index 9acaa4f96b8..8b55bb53e8c 100644 --- a/examples/gno.land/r/sys/names/verifier_test.gno +++ b/examples/gno.land/r/sys/names/verifier_test.gno @@ -30,7 +30,7 @@ func TestDefaultVerifier(t *testing.T) { } func TestEnable(t *testing.T) { - testing.SetRealm(testing.NewUserRealm("g1rp7cmetn27eqlpjpc4vuusf8kaj746tysc0qgh")) + testing.SetRealm(testing.NewUserRealm("g1edq4dugw0sgat4zxcw9xardvuydqf6cgleuc8p")) uassert.NotPanics(t, func() { Enable(cross) diff --git a/gno.land/cmd/gnoland/start.go b/gno.land/cmd/gnoland/start.go index 448e80c9a54..dba40f94fc6 100644 --- a/gno.land/cmd/gnoland/start.go +++ b/gno.land/cmd/gnoland/start.go @@ -55,8 +55,9 @@ type startCfg struct { dataDir string lazyInit bool - logLevel string - logFormat string + logLevel string + logFormat string + earlyStart bool } func newStartCmd(io commands.IO) *commands.Command { @@ -163,6 +164,13 @@ func (c *startCfg) RegisterFlags(fs *flag.FlagSet) { false, "flag indicating if lazy init is enabled. Generates the node secrets, configuration, and genesis.json", ) + + fs.BoolVar( + &c.earlyStart, + "x-early-start", + false, + "[experimental] start RPC and P2P before genesis time, deferring only consensus", + ) } func execStart(ctx context.Context, c *startCfg, io commands.IO) error { @@ -262,7 +270,11 @@ func execStart(ctx context.Context, c *startCfg, io commands.IO) error { } // Create a default node, with the given setup - gnoNode, err := node.DefaultNewNode(cfg, genesisPath, evsw, logger) + opts := []node.Option{} + if c.earlyStart { + opts = append(opts, node.WithEarlyStart()) + } + gnoNode, err := node.DefaultNewNode(cfg, genesisPath, evsw, logger, opts...) if err != nil { return fmt.Errorf("unable to create the Gnoland node, %w", err) } 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" }}