diff --git a/e2e/app/eoa/eoa.go b/e2e/app/eoa/eoa.go index 71e664a5d..d1e47b6c1 100644 --- a/e2e/app/eoa/eoa.go +++ b/e2e/app/eoa/eoa.go @@ -43,6 +43,8 @@ const ( RoleTester Role = "tester" // RoleXCaller is used for testing xcalls on a network. RoleXCaller Role = "xcaller" + // RolveSolver is the allowed solver for solve inbox / outboxes. + RoleSolver Role = "solver" ) func AllRoles() []Role { @@ -57,6 +59,7 @@ func AllRoles() []Role { RoleUpgrader, RoleTester, RoleXCaller, + // RoleSolver TODO: omitting solver for now. It is just used in devnet and not set for other networks. } } diff --git a/e2e/app/eoa/static.go b/e2e/app/eoa/static.go index 27ce13ab6..9f6fdac4f 100644 --- a/e2e/app/eoa/static.go +++ b/e2e/app/eoa/static.go @@ -15,6 +15,7 @@ var statics = map[netconf.ID][]Account{ wellKnown(anvil.DevPrivateKey8(), RoleHot), wellKnown(anvil.DevPrivateKey9(), RoleCold), wellKnown(anvil.DevPrivateKey10(), RoleXCaller), + wellKnown(anvil.DevPrivateKey4(), RoleSolver), ), netconf.Staging: flatten( remote("0x64Bf40F5E6C4DE0dfe8fE6837F6339455657A2F5", RoleCold), // we use shared-cold diff --git a/e2e/app/run.go b/e2e/app/run.go index a55e0793f..29f9659ec 100644 --- a/e2e/app/run.go +++ b/e2e/app/run.go @@ -8,6 +8,7 @@ import ( "github.com/omni-network/omni/e2e/app/eoa" "github.com/omni-network/omni/e2e/netman" "github.com/omni-network/omni/e2e/netman/pingpong" + "github.com/omni-network/omni/e2e/solve" "github.com/omni-network/omni/e2e/types" "github.com/omni-network/omni/halo/genutil/evm/predeploys" "github.com/omni-network/omni/lib/contracts" @@ -121,6 +122,12 @@ func Deploy(ctx context.Context, def Definition, cfg DeployConfig) (*pingpong.XD return nil, err } + if def.Manifest.DeploySolve { + if err := solve.DeployContracts(ctx, NetworkFromDef(def), def.Backends()); err != nil { + return nil, errors.Wrap(err, "deploy solve contracts") + } + } + err = waitForSupportedChains(ctx, def) if err != nil { return nil, err diff --git a/e2e/manifests/devnetsolve.toml b/e2e/manifests/devnetsolve.toml new file mode 100644 index 000000000..80aec2497 --- /dev/null +++ b/e2e/manifests/devnetsolve.toml @@ -0,0 +1,11 @@ +# DevnetSolve is a simple devnet with solve contracts deployed +network = "devnet" +anvil_chains = ["mock_l1", "mock_l2"] +deploy_solve = true + +prometheus = true + +[node.validator01] + +[node.fullnode01] +mode="archive" diff --git a/e2e/solve/deploy.go b/e2e/solve/deploy.go new file mode 100644 index 000000000..53636e72c --- /dev/null +++ b/e2e/solve/deploy.go @@ -0,0 +1,45 @@ +package solve + +import ( + "context" + + "github.com/omni-network/omni/lib/contracts/solveinbox" + "github.com/omni-network/omni/lib/contracts/solveoutbox" + "github.com/omni-network/omni/lib/errors" + "github.com/omni-network/omni/lib/ethclient/ethbackend" + "github.com/omni-network/omni/lib/log" + "github.com/omni-network/omni/lib/netconf" +) + +// DeployContracts deploys solve inbox / outbox contracts. +func DeployContracts(ctx context.Context, network netconf.Network, backends ethbackend.Backends) error { + if network.ID != netconf.Devnet { + log.Warn(ctx, "Skipping solve deploy", nil) + return nil + } + + log.Info(ctx, "Deploying solve contracts") + + for _, chain := range network.EVMChains() { + backend, err := backends.Backend(chain.ID) + if err != nil { + return errors.Wrap(err, "get backend", "chain", chain.Name) + } + + addr, _, err := solveinbox.DeployIfNeeded(ctx, network.ID, backend) + if err != nil { + return errors.Wrap(err, "deploy solve inbox") + } + + log.Info(ctx, "SolveInbox deployed", "addr", addr.Hex(), "chain", chain.Name) + + addr, _, err = solveoutbox.DeployIfNeeded(ctx, network.ID, backend) + if err != nil { + return errors.Wrap(err, "deploy solve outbox") + } + + log.Info(ctx, "SolveOutbox deployed", "addr", addr.Hex(), "chain", chain.Name) + } + + return nil +} diff --git a/e2e/types/manifest.go b/e2e/types/manifest.go index ac67bcdd9..563df22af 100644 --- a/e2e/types/manifest.go +++ b/e2e/types/manifest.go @@ -91,6 +91,9 @@ type Manifest struct { // PingPongN defines the number of ping pong messages to send. Defaults 3 if 0. PingPongN uint64 `toml:"pingpong_n"` + // DeploySolve defines whether to deploy the solve contracts + DeploySolve bool `toml:"deploy_solve"` + // Keys contains long-lived private keys (address by type) by node name. Keys map[string]map[key.Type]string `toml:"keys"` diff --git a/lib/contracts/address.go b/lib/contracts/address.go index 58cb5a2ee..71ce2a917 100644 --- a/lib/contracts/address.go +++ b/lib/contracts/address.go @@ -84,15 +84,19 @@ type Addresses struct { L1Bridge common.Address Portal common.Address Token common.Address + SolveOutbox common.Address + SolveInbox common.Address } type Salts struct { - AVS string - GasPump string - GasStation string - L1Bridge string - Portal string - Token string + AVS string + GasPump string + GasStation string + L1Bridge string + Portal string + Token string + SolveOutbox string + SolveInbox string } type cache[T any] struct { @@ -135,6 +139,8 @@ func GetAddresses(ctx context.Context, network netconf.ID) (Addresses, error) { Token: token(network, ver), GasPump: gasPump(network, ver), GasStation: gasStation(network, ver), + SolveInbox: solveInbox(network, ver), + SolveOutbox: solveOutbox(network, ver), } addrsCache.cache[network] = addrs @@ -158,12 +164,14 @@ func GetSalts(ctx context.Context, network netconf.ID) (Salts, error) { } salts = Salts{ - AVS: avsSalt(network), - Portal: portalSalt(network, ver), - L1Bridge: l1BridgeSalt(network, ver), - Token: tokenSalt(network, ver), - GasPump: gasPumpSalt(network, ver), - GasStation: gasStationSalt(network, ver), + AVS: avsSalt(network), + Portal: portalSalt(network, ver), + L1Bridge: l1BridgeSalt(network, ver), + Token: tokenSalt(network, ver), + GasPump: gasPumpSalt(network, ver), + GasStation: gasStationSalt(network, ver), + SolveInbox: solveInboxSalt(network, ver), + SolveOutbox: solveOutboxSalt(network, ver), } saltsCache.cache[network] = salts @@ -216,6 +224,14 @@ func gasStation(network netconf.ID, version string) common.Address { return create3.Address(create3Factory(network), gasStationSalt(network, version), eoa.MustAddress(network, eoa.RoleDeployer)) } +func solveInbox(network netconf.ID, version string) common.Address { + return create3.Address(create3Factory(network), solveInboxSalt(network, version), eoa.MustAddress(network, eoa.RoleDeployer)) +} + +func solveOutbox(network netconf.ID, version string) common.Address { + return create3.Address(create3Factory(network), solveOutboxSalt(network, version), eoa.MustAddress(network, eoa.RoleDeployer)) +} + // // Salts. // @@ -245,6 +261,14 @@ func gasStationSalt(network netconf.ID, version string) string { return salt(network, "gas-station-"+version) } +func solveInboxSalt(network netconf.ID, version string) string { + return salt(network, "solve-inbox-"+version) +} + +func solveOutboxSalt(network netconf.ID, version string) string { + return salt(network, "solve-outbox-"+version) +} + // // Utils. // diff --git a/lib/contracts/address_test.go b/lib/contracts/address_test.go index 2c370c68d..31c26352b 100644 --- a/lib/contracts/address_test.go +++ b/lib/contracts/address_test.go @@ -32,13 +32,15 @@ func TestContractAddressReference(t *testing.T) { require.NoError(t, err) addrsJSON := map[string]common.Address{ - "create3": addrs.Create3Factory, - "portal": addrs.Portal, - "avs": addrs.AVS, - "l1bridge": addrs.L1Bridge, - "token": addrs.Token, - "gaspump": addrs.GasPump, - "gasstation": addrs.GasStation, + "create3": addrs.Create3Factory, + "portal": addrs.Portal, + "avs": addrs.AVS, + "l1bridge": addrs.L1Bridge, + "token": addrs.Token, + "gaspump": addrs.GasPump, + "gasstation": addrs.GasStation, + "solveinbox": addrs.SolveInbox, + "solveoutbox": addrs.SolveOutbox, } for name, addr := range addrsJSON { diff --git a/lib/contracts/solveinbox/deploy.go b/lib/contracts/solveinbox/deploy.go new file mode 100644 index 000000000..b8d4223a2 --- /dev/null +++ b/lib/contracts/solveinbox/deploy.go @@ -0,0 +1,193 @@ +package solveinbox + +import ( + "context" + + "github.com/omni-network/omni/contracts/bindings" + "github.com/omni-network/omni/e2e/app/eoa" + "github.com/omni-network/omni/lib/contracts" + "github.com/omni-network/omni/lib/create3" + "github.com/omni-network/omni/lib/errors" + "github.com/omni-network/omni/lib/ethclient/ethbackend" + "github.com/omni-network/omni/lib/netconf" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +type DeploymentConfig struct { + Create3Factory common.Address + Create3Salt string + ProxyAdminOwner common.Address + Owner common.Address + Solver common.Address + Portal common.Address + Outbox common.Address + Deployer common.Address + ExpectedAddr common.Address +} + +func (cfg DeploymentConfig) Validate() error { + if (cfg.Create3Factory == common.Address{}) { + return errors.New("create3 factory is zero") + } + if cfg.Create3Salt == "" { + return errors.New("create3 salt is empty") + } + if (cfg.ProxyAdminOwner == common.Address{}) { + return errors.New("proxy admin is zero") + } + if contracts.IsEmptyAddress(cfg.Deployer) { + return errors.New("deployer is not set") + } + if contracts.IsEmptyAddress(cfg.Owner) { + return errors.New("owner is not set") + } + if (cfg.Outbox == common.Address{}) { + return errors.New("outbox is zero") + } + if (cfg.Portal == common.Address{}) { + return errors.New("portal is zero") + } + if (cfg.Solver == common.Address{}) { + return errors.New("solver is zero") + } + if (cfg.ExpectedAddr == common.Address{}) { + return errors.New("expected address is zero") + } + + return nil +} + +// isDeployed returns true if the SolveInbox contract is already deployed to its expected address. +func isDeployed(ctx context.Context, network netconf.ID, backend *ethbackend.Backend) (bool, common.Address, error) { + addrs, err := contracts.GetAddresses(ctx, network) + if err != nil { + return false, common.Address{}, errors.Wrap(err, "get addresses") + } + + addr := addrs.SolveInbox + + code, err := backend.CodeAt(ctx, addr, nil) + if err != nil { + return false, addr, errors.Wrap(err, "code at", "address", addr) + } + + if len(code) == 0 { + return false, addr, nil + } + + return true, addr, nil +} + +// DeployIfNeeded deploys a new SolveInbox contract if it is not already deployed. +// If the contract is already deployed, the receipt is nil. +func DeployIfNeeded(ctx context.Context, network netconf.ID, backend *ethbackend.Backend) (common.Address, *ethtypes.Receipt, error) { + deployed, addr, err := isDeployed(ctx, network, backend) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "is deployed") + } + if deployed { + return addr, nil, nil + } + + return Deploy(ctx, network, backend) +} + +// Deploy deploys a new SolveInbox contract and returns the address and receipt. +func Deploy(ctx context.Context, network netconf.ID, backend *ethbackend.Backend) (common.Address, *ethtypes.Receipt, error) { + addrs, err := contracts.GetAddresses(ctx, network) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "get addresses") + } + + salts, err := contracts.GetSalts(ctx, network) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "get salts") + } + + cfg := DeploymentConfig{ + Create3Factory: addrs.Create3Factory, + Create3Salt: salts.SolveInbox, + Owner: eoa.MustAddress(network, eoa.RoleManager), + Deployer: eoa.MustAddress(network, eoa.RoleDeployer), + ProxyAdminOwner: eoa.MustAddress(network, eoa.RoleUpgrader), + Solver: eoa.MustAddress(network, eoa.RoleSolver), + Portal: addrs.Portal, + Outbox: addrs.SolveOutbox, + ExpectedAddr: addrs.SolveInbox, + } + + return deploy(ctx, cfg, backend) +} + +func deploy(ctx context.Context, cfg DeploymentConfig, backend *ethbackend.Backend) (common.Address, *ethtypes.Receipt, error) { + if err := cfg.Validate(); err != nil { + return common.Address{}, nil, errors.Wrap(err, "validate config") + } + + txOpts, err := backend.BindOpts(ctx, cfg.Deployer) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "bind opts") + } + + factory, err := bindings.NewCreate3(cfg.Create3Factory, backend) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "new create3") + } + + salt := create3.HashSalt(cfg.Create3Salt) + + addr, err := factory.GetDeployed(nil, txOpts.From, salt) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "get deployed") + } else if (cfg.ExpectedAddr != common.Address{}) && addr != cfg.ExpectedAddr { + return common.Address{}, nil, errors.New("unexpected address", "expected", cfg.ExpectedAddr, "actual", addr) + } + + impl, tx, _, err := bindings.DeploySolveInbox(txOpts, backend) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "deploy impl") + } + + _, err = backend.WaitMined(ctx, tx) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "wait mined impl") + } + + initCode, err := packInitCode(cfg, impl) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "pack init code") + } + + tx, err = factory.DeployWithRetry(txOpts, salt, initCode) //nolint:contextcheck // Context is txOpts + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "deploy proxy") + } + + receipt, err := backend.WaitMined(ctx, tx) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "wait mined proxy") + } + + return addr, receipt, nil +} + +func packInitCode(cfg DeploymentConfig, impl common.Address) ([]byte, error) { + inboxAbi, err := bindings.SolveInboxMetaData.GetAbi() + if err != nil { + return nil, errors.Wrap(err, "get abi") + } + + proxyAbi, err := bindings.TransparentUpgradeableProxyMetaData.GetAbi() + if err != nil { + return nil, errors.Wrap(err, "get proxy abi") + } + + initializer, err := inboxAbi.Pack("initialize", cfg.Owner, cfg.Solver, cfg.Portal, cfg.Outbox) + if err != nil { + return nil, errors.Wrap(err, "encode initializer") + } + + return contracts.PackInitCode(proxyAbi, bindings.TransparentUpgradeableProxyBin, impl, cfg.ProxyAdminOwner, initializer) +} diff --git a/lib/contracts/solveoutbox/deploy.go b/lib/contracts/solveoutbox/deploy.go new file mode 100644 index 000000000..82e7305f3 --- /dev/null +++ b/lib/contracts/solveoutbox/deploy.go @@ -0,0 +1,193 @@ +package solveoutbox + +import ( + "context" + + "github.com/omni-network/omni/contracts/bindings" + "github.com/omni-network/omni/e2e/app/eoa" + "github.com/omni-network/omni/lib/contracts" + "github.com/omni-network/omni/lib/create3" + "github.com/omni-network/omni/lib/errors" + "github.com/omni-network/omni/lib/ethclient/ethbackend" + "github.com/omni-network/omni/lib/netconf" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +type DeploymentConfig struct { + Create3Factory common.Address + Create3Salt string + ProxyAdminOwner common.Address + Owner common.Address + Solver common.Address + Portal common.Address + Inbox common.Address + Deployer common.Address + ExpectedAddr common.Address +} + +func (cfg DeploymentConfig) Validate() error { + if (cfg.Create3Factory == common.Address{}) { + return errors.New("create3 factory is zero") + } + if cfg.Create3Salt == "" { + return errors.New("create3 salt is empty") + } + if (cfg.ProxyAdminOwner == common.Address{}) { + return errors.New("proxy admin is zero") + } + if contracts.IsEmptyAddress(cfg.Deployer) { + return errors.New("deployer is not set") + } + if contracts.IsEmptyAddress(cfg.Owner) { + return errors.New("owner is not set") + } + if (cfg.Inbox == common.Address{}) { + return errors.New("inbox is zero") + } + if (cfg.Portal == common.Address{}) { + return errors.New("portal is zero") + } + if (cfg.Solver == common.Address{}) { + return errors.New("solver is zero") + } + if (cfg.ExpectedAddr == common.Address{}) { + return errors.New("expected address is zero") + } + + return nil +} + +// isDeployed returns true if the SolveOutbox contract is already deployed to its expected address. +func isDeployed(ctx context.Context, network netconf.ID, backend *ethbackend.Backend) (bool, common.Address, error) { + addrs, err := contracts.GetAddresses(ctx, network) + if err != nil { + return false, common.Address{}, errors.Wrap(err, "get addresses") + } + + addr := addrs.SolveOutbox + + code, err := backend.CodeAt(ctx, addr, nil) + if err != nil { + return false, addr, errors.Wrap(err, "code at", "address", addr) + } + + if len(code) == 0 { + return false, addr, nil + } + + return true, addr, nil +} + +// DeployIfNeeded deploys a new SolveOutbox contract if it is not already deployed. +// If the contract is already deployed, the receipt is nil. +func DeployIfNeeded(ctx context.Context, network netconf.ID, backend *ethbackend.Backend) (common.Address, *ethtypes.Receipt, error) { + deployed, addr, err := isDeployed(ctx, network, backend) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "is deployed") + } + if deployed { + return addr, nil, nil + } + + return Deploy(ctx, network, backend) +} + +// Deploy deploys a new SolveOutbox contract and returns the address and receipt. +func Deploy(ctx context.Context, network netconf.ID, backend *ethbackend.Backend) (common.Address, *ethtypes.Receipt, error) { + addrs, err := contracts.GetAddresses(ctx, network) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "get addresses") + } + + salts, err := contracts.GetSalts(ctx, network) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "get salts") + } + + cfg := DeploymentConfig{ + Create3Factory: addrs.Create3Factory, + Create3Salt: salts.SolveOutbox, + Owner: eoa.MustAddress(network, eoa.RoleManager), + Deployer: eoa.MustAddress(network, eoa.RoleDeployer), + ProxyAdminOwner: eoa.MustAddress(network, eoa.RoleUpgrader), + Solver: eoa.MustAddress(network, eoa.RoleSolver), + Portal: addrs.Portal, + Inbox: addrs.SolveInbox, + ExpectedAddr: addrs.SolveOutbox, + } + + return deploy(ctx, cfg, backend) +} + +func deploy(ctx context.Context, cfg DeploymentConfig, backend *ethbackend.Backend) (common.Address, *ethtypes.Receipt, error) { + if err := cfg.Validate(); err != nil { + return common.Address{}, nil, errors.Wrap(err, "validate config") + } + + txOpts, err := backend.BindOpts(ctx, cfg.Deployer) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "bind opts") + } + + factory, err := bindings.NewCreate3(cfg.Create3Factory, backend) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "new create3") + } + + salt := create3.HashSalt(cfg.Create3Salt) + + addr, err := factory.GetDeployed(nil, txOpts.From, salt) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "get deployed") + } else if (cfg.ExpectedAddr != common.Address{}) && addr != cfg.ExpectedAddr { + return common.Address{}, nil, errors.New("unexpected address", "expected", cfg.ExpectedAddr, "actual", addr) + } + + impl, tx, _, err := bindings.DeploySolveOutbox(txOpts, backend) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "deploy impl") + } + + _, err = backend.WaitMined(ctx, tx) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "wait mined impl") + } + + initCode, err := packInitCode(cfg, impl) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "pack init code") + } + + tx, err = factory.DeployWithRetry(txOpts, salt, initCode) //nolint:contextcheck // Context is txOpts + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "deploy proxy") + } + + receipt, err := backend.WaitMined(ctx, tx) + if err != nil { + return common.Address{}, nil, errors.Wrap(err, "wait mined proxy") + } + + return addr, receipt, nil +} + +func packInitCode(cfg DeploymentConfig, impl common.Address) ([]byte, error) { + outboxAbi, err := bindings.SolveOutboxMetaData.GetAbi() + if err != nil { + return nil, errors.Wrap(err, "get abi") + } + + proxyAbi, err := bindings.TransparentUpgradeableProxyMetaData.GetAbi() + if err != nil { + return nil, errors.Wrap(err, "get proxy abi") + } + + initializer, err := outboxAbi.Pack("initialize", cfg.Owner, cfg.Solver, cfg.Portal, cfg.Inbox) + if err != nil { + return nil, errors.Wrap(err, "encode initializer") + } + + return contracts.PackInitCode(proxyAbi, bindings.TransparentUpgradeableProxyBin, impl, cfg.ProxyAdminOwner, initializer) +} diff --git a/lib/contracts/testdata/TestContractAddressReference.golden b/lib/contracts/testdata/TestContractAddressReference.golden index 79a6eadda..e1db49234 100644 --- a/lib/contracts/testdata/TestContractAddressReference.golden +++ b/lib/contracts/testdata/TestContractAddressReference.golden @@ -6,6 +6,8 @@ "gasstation": "0x87d948ada3fef75236adc0961044b57add66e7a8", "l1bridge": "0x96183c6b4ce669007d0de43f1e7eb9a4494271d8", "portal": "0xb835dc695c6bfc8373c0d56973b5d9e9b083e97b", + "solveinbox": "0x283fa39ae3d1cd8f778bd0e3965be15ba4005ac0", + "solveoutbox": "0x23a4a314320ec6e23526c07cf42dfb35050ea952", "token": "0xac5e38c77804058a1c7df7e8363af1be2132276a" }, "mainnet": { @@ -15,6 +17,8 @@ "gasstation": "0x5a19272100949f9e8a4ad9d98f8c1070232de626", "l1bridge": "0xbbb3f5bcb1c8b0ee932efaba2fdee566b83053a5", "portal": "0x5e9a8aa213c912bf54c86bf64adb8ed6a79c04d1", + "solveinbox": "0x1bf09c6414d1f581070eddfaf317e566543e0199", + "solveoutbox": "0xf26f33188de6d8efe1fd791de2a95408a5113864", "token": "0x36e66fbbce51e4cd5bd3c62b637eb411b18949d4" }, "omega": { @@ -24,6 +28,8 @@ "gasstation": "0x3266c030c1e302264063cf44f6df8bbaaf3d9767", "l1bridge": "0x084ef227534a6ad2de4c4e54db19f1c457a57a27", "portal": "0xcb60a0451831e4865bc49f41f9c67665fc9b75c3", + "solveinbox": "0x57f3e823041a27809ae5f6e98cfa062d18da613c", + "solveoutbox": "0x3fdd17daa9fac84e5d2d965be38eb9136209d1a2", "token": "0xd036c60f46ff51dd7fbf6a819b5b171c8a076b07" } } \ No newline at end of file