diff --git a/relayer/cmd/root.go b/relayer/cmd/root.go index 67e6a0402c..53258601aa 100644 --- a/relayer/cmd/root.go +++ b/relayer/cmd/root.go @@ -36,7 +36,6 @@ func init() { rootCmd.AddCommand(storeBeaconStateCmd()) rootCmd.AddCommand(importBeaconStateCmd()) rootCmd.AddCommand(listBeaconStateCmd()) - rootCmd.AddCommand(syncBeefyCommitmentCmd()) } func Execute() { diff --git a/relayer/cmd/run/beefy/command.go b/relayer/cmd/run/beefy/command.go index 59f4a461b5..cf4d6f648a 100644 --- a/relayer/cmd/run/beefy/command.go +++ b/relayer/cmd/run/beefy/command.go @@ -21,6 +21,7 @@ var ( privateKey string privateKeyFile string privateKeyID string + onDemand bool ) func Command() *cobra.Command { @@ -38,6 +39,8 @@ func Command() *cobra.Command { cmd.Flags().StringVar(&privateKeyFile, "ethereum.private-key-file", "", "The file from which to read the private key") cmd.Flags().StringVar(&privateKeyID, "ethereum.private-key-id", "", "The secret id to lookup the private key in AWS Secrets Manager") + cmd.Flags().BoolVarP(&onDemand, "on-demand", "", false, "Synchronize commitments on demand") + return cmd } @@ -66,11 +69,6 @@ func run(_ *cobra.Command, _ []string) error { return err } - relay, err := beefy.NewRelay(&config, keypair) - if err != nil { - return err - } - ctx, cancel := context.WithCancel(context.Background()) eg, ctx := errgroup.WithContext(ctx) @@ -90,11 +88,30 @@ func run(_ *cobra.Command, _ []string) error { return nil }) - err = relay.Start(ctx, eg) - if err != nil { - logrus.WithError(err).Fatal("Unhandled error") - cancel() - return err + if !onDemand { + relay, err := beefy.NewRelay(&config, keypair) + if err != nil { + return err + } + + err = relay.Start(ctx, eg) + if err != nil { + logrus.WithError(err).Fatal("Unhandled error") + cancel() + return err + } + } else { + relay, err := beefy.NewOnDemandRelay(&config, keypair) + if err != nil { + return err + } + + err = relay.Start(ctx) + if err != nil { + logrus.WithError(err).Fatal("Unhandled error") + cancel() + return err + } } err = eg.Wait() diff --git a/relayer/cmd/sync_beefy_commitment.go b/relayer/cmd/sync_beefy_commitment.go deleted file mode 100644 index 2698ebcb7c..0000000000 --- a/relayer/cmd/sync_beefy_commitment.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "log" - - "github.com/sirupsen/logrus" - "github.com/snowfork/snowbridge/relayer/chain/ethereum" - "github.com/snowfork/snowbridge/relayer/relays/beefy" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -func syncBeefyCommitmentCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "sync-latest-beefy-commitment", - Short: "Sync beefy commitment on demand", - Args: cobra.ExactArgs(0), - RunE: SyncBeefyCommitmentFn, - } - - cmd.Flags().String("config", "/tmp/snowbridge/beefy-relay.json", "Path to configuration file") - cmd.Flags().String("private-key", "", "Ethereum private key") - cmd.Flags().String("private-key-file", "", "The file from which to read the private key") - cmd.Flags().String("private-key-id", "", "The secret id to lookup the private key in AWS Secrets Manager") - - cmd.Flags().Uint64P("block-number", "b", 0, "Relay block number which contains a Parachain message") - cmd.MarkFlagRequired("block-number") - return cmd -} - -func SyncBeefyCommitmentFn(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - log.SetOutput(logrus.WithFields(logrus.Fields{"logger": "stdlib"}).WriterLevel(logrus.InfoLevel)) - logrus.SetLevel(logrus.DebugLevel) - - configFile, err := cmd.Flags().GetString("config") - viper.SetConfigFile(configFile) - if err := viper.ReadInConfig(); err != nil { - return err - } - - var config beefy.Config - err = viper.Unmarshal(&config) - if err != nil { - return err - } - privateKey, _ := cmd.Flags().GetString("private-key") - privateKeyFile, _ := cmd.Flags().GetString("private-key-file") - privateKeyID, _ := cmd.Flags().GetString("private-key-id") - keypair, err := ethereum.ResolvePrivateKey(privateKey, privateKeyFile, privateKeyID) - if err != nil { - return err - } - - relay, err := beefy.NewRelay(&config, keypair) - if err != nil { - return err - } - blockNumber, _ := cmd.Flags().GetUint64("block-number") - err = relay.OneShotSync(ctx, blockNumber) - return err -} diff --git a/relayer/config/config.go b/relayer/config/config.go index ea1de25fdc..bd5358a662 100644 --- a/relayer/config/config.go +++ b/relayer/config/config.go @@ -7,8 +7,7 @@ type PolkadotConfig struct { } type ParachainConfig struct { - Endpoint string `mapstructure:"endpoint"` - MaxWatchedExtrinsics int64 `mapstructure:"maxWatchedExtrinsics"` + Endpoint string `mapstructure:"endpoint"` } type EthereumConfig struct { @@ -27,9 +26,6 @@ func (p ParachainConfig) Validate() error { if p.Endpoint == "" { return errors.New("[endpoint] is not set") } - if p.MaxWatchedExtrinsics == 0 { - return errors.New("[maxWatchedExtrinsics] is not set") - } return nil } diff --git a/relayer/relays/beefy/config.go b/relayer/relays/beefy/config.go index d4aaf053e9..ab2853f03a 100644 --- a/relayer/relays/beefy/config.go +++ b/relayer/relays/beefy/config.go @@ -7,12 +7,14 @@ import ( ) type Config struct { - Source SourceConfig `mapstructure:"source"` - Sink SinkConfig `mapstructure:"sink"` + Source SourceConfig `mapstructure:"source"` + Sink SinkConfig `mapstructure:"sink"` + OnDemandSync OnDemandSyncConfig `mapstructure:"on-demand-sync"` } type SourceConfig struct { - Polkadot config.PolkadotConfig `mapstructure:"polkadot"` + Polkadot config.PolkadotConfig `mapstructure:"polkadot"` + BridgeHub config.ParachainConfig `mapstructure:"bridge-hub"` } type SinkConfig struct { @@ -23,6 +25,18 @@ type SinkConfig struct { type ContractsConfig struct { BeefyClient string `mapstructure:"BeefyClient"` + Gateway string `mapstructure:"Gateway"` +} + +type OnDemandSyncConfig struct { + // ID of the AssetHub channel + AssetHubChannelID string `mapstructure:"asset-hub-channel-id"` + // Maximum number of tokens available to consume + MaxTokens uint64 `mapstructure:"max-tokens"` + // Number of tokens added each `RefillPeriod` + RefillAmount uint64 `mapstructure:"refill-amount"` + // Period between token refills + RefillPeriod uint64 `mapstructure:"refill-period"` } func (c Config) Validate() error { @@ -40,5 +54,20 @@ func (c Config) Validate() error { if c.Sink.Contracts.BeefyClient == "" { return fmt.Errorf("sink contracts setting [BeefyClient] is not set") } + if c.Sink.Contracts.Gateway == "" { + return fmt.Errorf("sink contracts setting [Gateway] is not set") + } + if c.OnDemandSync.AssetHubChannelID == "" { + return fmt.Errorf("`on-demand-sync.asset-hub-channel-id` not set") + } + if c.OnDemandSync.MaxTokens == 0 { + return fmt.Errorf("`on-demand-sync.max-tokens` not set") + } + if c.OnDemandSync.RefillAmount == 0 { + return fmt.Errorf("`on-demand-sync.refill-amount` not set") + } + if c.OnDemandSync.RefillPeriod == 0 { + return fmt.Errorf("`on-demand-sync.refill-period` not set") + } return nil } diff --git a/relayer/relays/beefy/main.go b/relayer/relays/beefy/main.go index 14bf81b51a..09b08793f7 100644 --- a/relayer/relays/beefy/main.go +++ b/relayer/relays/beefy/main.go @@ -80,83 +80,3 @@ func (relay *Relay) Start(ctx context.Context, eg *errgroup.Group) error { return nil } - -func (relay *Relay) OneShotSync(ctx context.Context, blockNumber uint64) error { - // Initialize relaychainConn - err := relay.relaychainConn.Connect(ctx) - if err != nil { - return fmt.Errorf("create relaychain connection: %w", err) - } - - // Initialize ethereumConn - err = relay.ethereumConn.Connect(ctx) - if err != nil { - return fmt.Errorf("create ethereum connection: %w", err) - } - err = relay.ethereumWriter.initialize(ctx) - if err != nil { - return fmt.Errorf("initialize EthereumWriter: %w", err) - } - - state, err := relay.ethereumWriter.queryBeefyClientState(ctx) - if err != nil { - return fmt.Errorf("query beefy client state: %w", err) - } - // Ignore relay block already synced - if blockNumber <= state.LatestBeefyBlock { - log.WithFields(log.Fields{ - "validatorSetID": state.CurrentValidatorSetID, - "beefyBlock": state.LatestBeefyBlock, - "relayBlock": blockNumber, - }).Info("Relay block already synced, just ignore") - return nil - } - - // generate beefy update for that specific relay block - task, err := relay.polkadotListener.generateBeefyUpdate(blockNumber) - if err != nil { - return fmt.Errorf("fail to generate next beefy request: %w", err) - } - - // Ignore commitment earlier than LatestBeefyBlock which is outdated - if task.SignedCommitment.Commitment.BlockNumber <= uint32(state.LatestBeefyBlock) { - log.WithFields(log.Fields{ - "latestBeefyBlock": state.LatestBeefyBlock, - "currentValidatorSetID": state.CurrentValidatorSetID, - "nextValidatorSetID": state.NextValidatorSetID, - "blockNumberToSync": task.SignedCommitment.Commitment.BlockNumber, - }).Info("Commitment outdated, just ignore") - return nil - } - if task.SignedCommitment.Commitment.ValidatorSetID > state.NextValidatorSetID { - log.WithFields(log.Fields{ - "latestBeefyBlock": state.LatestBeefyBlock, - "currentValidatorSetID": state.CurrentValidatorSetID, - "nextValidatorSetID": state.NextValidatorSetID, - "validatorSetIDToSync": task.SignedCommitment.Commitment.ValidatorSetID, - }).Warn("Task unexpected, wait for mandatory updates to catch up first") - return nil - } - - // Submit the task - if task.SignedCommitment.Commitment.ValidatorSetID == state.CurrentValidatorSetID { - task.ValidatorsRoot = state.CurrentValidatorSetRoot - } else { - task.ValidatorsRoot = state.NextValidatorSetRoot - } - err = relay.ethereumWriter.submit(ctx, task) - if err != nil { - return fmt.Errorf("fail to submit beefy update: %w", err) - } - - updatedState, err := relay.ethereumWriter.queryBeefyClientState(ctx) - if err != nil { - return fmt.Errorf("query beefy client state: %w", err) - } - log.WithFields(log.Fields{ - "latestBeefyBlock": updatedState.LatestBeefyBlock, - "currentValidatorSetID": updatedState.CurrentValidatorSetID, - "nextValidatorSetID": updatedState.NextValidatorSetID, - }).Info("Sync beefy update success") - return nil -} diff --git a/relayer/relays/beefy/on-demand-sync.go b/relayer/relays/beefy/on-demand-sync.go new file mode 100644 index 0000000000..23aaba9c36 --- /dev/null +++ b/relayer/relays/beefy/on-demand-sync.go @@ -0,0 +1,282 @@ +package beefy + +import ( + "context" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/snowfork/go-substrate-rpc-client/v4/types" + "github.com/snowfork/snowbridge/relayer/chain/ethereum" + "github.com/snowfork/snowbridge/relayer/chain/parachain" + "github.com/snowfork/snowbridge/relayer/chain/relaychain" + "github.com/snowfork/snowbridge/relayer/contracts" + "github.com/snowfork/snowbridge/relayer/crypto/secp256k1" + + log "github.com/sirupsen/logrus" +) + +type OnDemandRelay struct { + config *Config + ethereumConn *ethereum.Connection + parachainConn *parachain.Connection + relaychainConn *relaychain.Connection + polkadotListener *PolkadotListener + ethereumWriter *EthereumWriter + gatewayContract *contracts.Gateway + assetHubChannelID [32]byte + tokenBucket *TokenBucket +} + +func NewOnDemandRelay(config *Config, ethereumKeypair *secp256k1.Keypair) (*OnDemandRelay, error) { + ethereumConn := ethereum.NewConnection(&config.Sink.Ethereum, ethereumKeypair) + relaychainConn := relaychain.NewConnection(config.Source.Polkadot.Endpoint) + parachainConn := parachain.NewConnection(config.Source.BridgeHub.Endpoint, nil) + + polkadotListener := NewPolkadotListener(&config.Source, relaychainConn) + ethereumWriter := NewEthereumWriter(&config.Sink, ethereumConn) + + assetHubChannelID, err := types.HexDecodeString(config.OnDemandSync.AssetHubChannelID) + if err != nil { + return nil, fmt.Errorf("hex decode assethub channel: %w", err) + } + + relay := OnDemandRelay{ + ethereumConn: ethereumConn, + parachainConn: parachainConn, + relaychainConn: relaychainConn, + polkadotListener: polkadotListener, + ethereumWriter: ethereumWriter, + gatewayContract: nil, + assetHubChannelID: *(*[32]byte)(assetHubChannelID), + tokenBucket: NewTokenBucket( + config.OnDemandSync.MaxTokens, + config.OnDemandSync.RefillAmount, + time.Duration(config.OnDemandSync.RefillPeriod)*time.Second, + ), + } + + return &relay, nil +} + +func (relay *OnDemandRelay) Start(ctx context.Context) error { + err := relay.ethereumConn.Connect(ctx) + if err != nil { + return fmt.Errorf("connect to ethereum: %w", err) + } + err = relay.relaychainConn.ConnectWithHeartBeat(ctx, 30*time.Second) + if err != nil { + return fmt.Errorf("connect to relaychain: %w", err) + } + err = relay.parachainConn.ConnectWithHeartBeat(ctx, 30*time.Second) + if err != nil { + return fmt.Errorf("connect to parachain: %w", err) + } + err = relay.ethereumWriter.initialize(ctx) + if err != nil { + return fmt.Errorf("initialize EthereumWriter: %w", err) + } + + gatewayAddress := common.HexToAddress(relay.config.Sink.Contracts.Gateway) + gatewayContract, err := contracts.NewGateway(gatewayAddress, relay.ethereumConn.Client()) + if err != nil { + return fmt.Errorf("create gateway client: %w", err) + } + relay.gatewayContract = gatewayContract + + relay.tokenBucket.Start(ctx) + + for { + sleep(ctx, time.Minute*1) + log.Info("Starting check") + + paraNonce, ethNonce, err := relay.queryNonces(ctx) + if err != nil { + log.WithError(err).Error("Query nonces") + continue + } + + log.WithFields(log.Fields{ + "paraNonce": paraNonce, + "ethNonce": ethNonce, + }).Info("Nonces checked") + + if paraNonce > ethNonce { + + // Check if we are rate-limited + if !relay.tokenBucket.TryConsume(1) { + log.Info("Rate-limit exceeded") + continue + } + + log.Info("Performing sync") + + beefyBlockHash, err := relay.relaychainConn.API().RPC.Beefy.GetFinalizedHead() + if err != nil { + log.WithError(err).Error("Fetch latest beefy block hash") + continue + } + + header, err := relay.relaychainConn.API().RPC.Chain.GetHeader(beefyBlockHash) + if err != nil { + log.WithError(err).Error("Fetch latest beefy block header") + continue + } + + err = relay.sync(ctx, uint64(header.Number)) + if err != nil { + log.WithError(err).Error("Sync failed") + continue + } + + log.Info("Sync completed") + + relay.waitUntilMessagesSynced(ctx, paraNonce) + } + } +} + +func (relay *OnDemandRelay) waitUntilMessagesSynced(ctx context.Context, paraNonce uint64) { + sleep(ctx, time.Minute*10) + for { + ethNonce, err := relay.fetchEthereumNonce(ctx) + if err != nil { + log.WithError(err).Error("fetch latest ethereum nonce") + sleep(ctx, time.Minute*1) + continue + } + + if ethNonce >= paraNonce { + return + } + } + +} + +func sleep(ctx context.Context, d time.Duration) { + select { + case <-ctx.Done(): + return + case <-time.After(d): + } +} + +func (relay *OnDemandRelay) queryNonces(ctx context.Context) (uint64, uint64, error) { + paraNonce, err := relay.fetchLatestParachainNonce(ctx) + if err != nil { + return 0, 0, fmt.Errorf("fetch latest parachain nonce: %w", err) + } + + ethNonce, err := relay.fetchEthereumNonce(ctx) + if err != nil { + return 0, 0, fmt.Errorf("fetch latest ethereum nonce: %w", err) + } + + return paraNonce, ethNonce, nil +} + +func (relay *OnDemandRelay) fetchLatestParachainNonce(_ context.Context) (uint64, error) { + paraNonceKey, err := types.CreateStorageKey( + relay.parachainConn.Metadata(), "EthereumOutboundQueue", "Nonce", + relay.assetHubChannelID[:], nil, + ) + if err != nil { + return 0, fmt.Errorf( + "create storage key for EthereumOutboundQueue.Nonce(%v): %w", + Hex(relay.assetHubChannelID[:]), err, + ) + } + var paraOutboundNonce uint64 + ok, err := relay.parachainConn.API().RPC.State.GetStorageLatest(paraNonceKey, ¶OutboundNonce) + if err != nil { + return 0, fmt.Errorf( + "fetch storage EthereumOutboundQueue.Nonce(%v): %w", + Hex(relay.assetHubChannelID[:]), err, + ) + } + if !ok { + paraOutboundNonce = 0 + } + + return paraOutboundNonce, nil +} + +func (relay *OnDemandRelay) fetchEthereumNonce(ctx context.Context) (uint64, error) { + opts := bind.CallOpts{ + Context: ctx, + } + ethInboundNonce, _, err := relay.gatewayContract.ChannelNoncesOf(&opts, relay.assetHubChannelID) + if err != nil { + return 0, fmt.Errorf( + "fetch Gateway.ChannelNoncesOf(%v): %w", + Hex(relay.assetHubChannelID[:]), err, + ) + } + + return ethInboundNonce, nil +} + +func (relay *OnDemandRelay) sync(ctx context.Context, blockNumber uint64) error { + state, err := relay.ethereumWriter.queryBeefyClientState(ctx) + if err != nil { + return fmt.Errorf("query beefy client state: %w", err) + } + // Ignore relay block already synced + if blockNumber <= state.LatestBeefyBlock { + log.WithFields(log.Fields{ + "validatorSetID": state.CurrentValidatorSetID, + "beefyBlock": state.LatestBeefyBlock, + "relayBlock": blockNumber, + }).Info("Relay block already synced, just ignore") + return nil + } + + // generate beefy update for that specific relay block + task, err := relay.polkadotListener.generateBeefyUpdate(blockNumber) + if err != nil { + return fmt.Errorf("fail to generate next beefy request: %w", err) + } + + // Ignore commitment earlier than LatestBeefyBlock which is outdated + if task.SignedCommitment.Commitment.BlockNumber <= uint32(state.LatestBeefyBlock) { + log.WithFields(log.Fields{ + "latestBeefyBlock": state.LatestBeefyBlock, + "currentValidatorSetID": state.CurrentValidatorSetID, + "nextValidatorSetID": state.NextValidatorSetID, + "blockNumberToSync": task.SignedCommitment.Commitment.BlockNumber, + }).Info("Commitment outdated, just ignore") + return nil + } + if task.SignedCommitment.Commitment.ValidatorSetID > state.NextValidatorSetID { + log.WithFields(log.Fields{ + "latestBeefyBlock": state.LatestBeefyBlock, + "currentValidatorSetID": state.CurrentValidatorSetID, + "nextValidatorSetID": state.NextValidatorSetID, + "validatorSetIDToSync": task.SignedCommitment.Commitment.ValidatorSetID, + }).Warn("Task unexpected, wait for mandatory updates to catch up first") + return nil + } + + // Submit the task + if task.SignedCommitment.Commitment.ValidatorSetID == state.CurrentValidatorSetID { + task.ValidatorsRoot = state.CurrentValidatorSetRoot + } else { + task.ValidatorsRoot = state.NextValidatorSetRoot + } + err = relay.ethereumWriter.submit(ctx, task) + if err != nil { + return fmt.Errorf("fail to submit beefy update: %w", err) + } + + updatedState, err := relay.ethereumWriter.queryBeefyClientState(ctx) + if err != nil { + return fmt.Errorf("query beefy client state: %w", err) + } + log.WithFields(log.Fields{ + "latestBeefyBlock": updatedState.LatestBeefyBlock, + "currentValidatorSetID": updatedState.CurrentValidatorSetID, + "nextValidatorSetID": updatedState.NextValidatorSetID, + }).Info("Sync beefy update success") + return nil +} diff --git a/relayer/relays/beefy/token-bucket.go b/relayer/relays/beefy/token-bucket.go new file mode 100644 index 0000000000..1202240c9e --- /dev/null +++ b/relayer/relays/beefy/token-bucket.go @@ -0,0 +1,63 @@ +package beefy + +import ( + "context" + "sync/atomic" + "time" +) + +type TokenBucket struct { + tokens atomic.Uint64 + // Maximum number of tokens available to consume + maxTokens uint64 + // The number of tokens added each refill period + refillAmount uint64 + // The refill period + refillPeriod time.Duration +} + +func NewTokenBucket(maxTokens, refillAmount uint64, refillPeriod time.Duration) *TokenBucket { + tb := &TokenBucket{ + maxTokens: maxTokens, + refillAmount: refillAmount, + refillPeriod: refillPeriod, + } + tb.tokens.Store(maxTokens) + return tb +} + +func (tb *TokenBucket) Start(ctx context.Context) { + go tb.refiller(ctx) +} + +func (tb *TokenBucket) refiller(ctx context.Context) { + ticker := time.NewTicker(tb.refillPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + currentTokens := tb.tokens.Load() + newTokens := currentTokens + tb.refillAmount + if newTokens > tb.maxTokens { + newTokens = tb.maxTokens + } + tb.tokens.Store(newTokens) + } + } +} + +func (tb *TokenBucket) TryConsume(tokens uint64) bool { + for { + currentTokens := tb.tokens.Load() + if currentTokens < tokens { + return false + } + + if tb.tokens.CompareAndSwap(currentTokens, currentTokens-tokens) { + return true + } + } +} diff --git a/web/packages/test/config/beefy-relay.json b/web/packages/test/config/beefy-relay.json index 5b2db16deb..d77b530760 100644 --- a/web/packages/test/config/beefy-relay.json +++ b/web/packages/test/config/beefy-relay.json @@ -11,7 +11,14 @@ }, "descendants-until-final": 3, "contracts": { - "BeefyClient": null + "BeefyClient": null, + "Gateway": null } + }, + "on-demand-sync": { + "asset-hub-channel-id": "0xc173fac324158e77fb5840738a1a541f633cbec8884c6a601c567d2b376a0539", + "max-tokens": 5, + "refill-amount": 1, + "refill-period": 3600 } } diff --git a/web/packages/test/scripts/start-relayer.sh b/web/packages/test/scripts/start-relayer.sh index ef08981fbc..a7d372add7 100755 --- a/web/packages/test/scripts/start-relayer.sh +++ b/web/packages/test/scripts/start-relayer.sh @@ -7,12 +7,16 @@ config_relayer() { # Configure beefy relay jq \ --arg k1 "$(address_for BeefyClient)" \ + --arg k2 "$(address_for GatewayProxy)" \ --arg eth_endpoint_ws $eth_endpoint_ws \ --arg eth_gas_limit $eth_gas_limit \ + --arg assetHubChannelID $ASSET_HUB_CHANNEL_ID \ ' .sink.contracts.BeefyClient = $k1 + .sink.contracts.Gateway = $k2 | .sink.ethereum.endpoint = $eth_endpoint_ws | .sink.ethereum."gas-limit" = $eth_gas_limit + | ."on-demand-sync"."asset-hub-channel-id" = assetHubChannelID ' \ config/beefy-relay.json >$output_dir/beefy-relay.json