Skip to content

Instantly share code, notes, and snippets.

@liamzebedee
Last active June 27, 2023 05:59
Show Gist options
  • Save liamzebedee/8684c4295b2c7ea4c4c63d1b1dd07ba9 to your computer and use it in GitHub Desktop.
Save liamzebedee/8684c4295b2c7ea4c4c63d1b1dd07ba9 to your computer and use it in GitHub Desktop.

niacin

The basics.

Zero-configuration deployment + upgradable Ethereum smart contracts. Some people call this chainops (like devops).

# Deploy a contract TakeMarket.sol.
niacin deploy TakeMarket

# This deploys TakeMarket with a proxy that makes it upgradeable.
# These details are stored in a deployment manifest.
cat manifest.json

# The manifest stores:
# - ABI's
# - deployment tx (eg. deploy block)
# - contract addresses
# - proxy addresses
# - git metadata (so you can revert easily)

# Run it again, and you get a great preview of the current deployment state.
niacin deploy TakeMarket
# ╔══════════════════════════╤═════════╤═══════════╤════════╤════════════════════════════════════════════╗
# ║ Contract                 │ Version │ Status    │ Action │ Proxy Address                              ║
# ╟──────────────────────────┼─────────┼───────────┼────────┼────────────────────────────────────────────╢
# ║ src/TakeMarket.sol       │ 1       │ unchanged │ none   │ 0xefc1aB2475ACb7E60499Efb171D173be19928a05 ║
# ╟──────────────────────────┼─────────┼───────────┼────────┼────────────────────────────────────────────╢
# ║ src/TakeMarketShares.sol │ 1       │ unchanged │ none   │ 0xD49a0e9A4CD5979aE36840f542D2d7f02C4817Be ║
# ╚══════════════════════════╧═════════╧═══════════╧════════╧════════════════════════════════════════════╝

# Or just deploy all contracts.
niacin deploy -a

# Deploy a new system to the same chain.
niacin deploy -a --manifest staging.json

# Deploy a new system to a different chain.
RPC_URL="https://polygon‑rpc.com" PRIVATE_KEY="0x" niacin deploy -a --manifest polygon.json

# Multichain is really easy with Niacin.
mkdir deployments/
RPC_URL="https://polygon‑rpc.com"      niacin deploy -a --manifest deployments/polygon.json
RPC_URL="https://arb1.arbitrum.io/rpc" niacin deploy -a --manifest deployments/arbitrum.json
RPC_URL="https://rpc.ankr.com/gnosis"  niacin deploy -a --manifest deployments/gnosis.json

Want to use your deployed contracts from frontends and subgraphs, without copy-pasting JSON files? Easy.

niacin generate-npm-pkg --manifest polygon.json > index.js
const deployments = require('./index')
> deployments
{
  TakeMarket: {
    version: 1,
    abi: [
      [Object], [Object],
      [Object], [Object],
      [Object], [Object],
      [Object], [Object],
      [Object], [Object],
      [Object]
    ],
    address: '0xa7AdbF0538C022C3a1805f16b3a6eF74bDD58A37',
    deployBlock: 161
  },
  TakeMarketShares: {
    version: 1,
    abi: [ [Object], [Object], [Object], [Object] ],
    address: '0x6166169180C5426902BE92e879feBEE0Ae280978',
    deployBlock: 163
  }
}

Most frontend build tools don't allow you to import JSON outside of the project directory. Don't worry! index.js is completely self-contained. You can put it anywhere.

Niacin also remembers to keep the deploy block, so you don't have to copy-paste that either. And your subgraphs index faster.

Can I get this working with third-party contracts? Yes.

Here's how you fetch the Curve 3pool's ABI's from Etherscan, and generate a Solidity and JS code for using them:

niacin add-vendor             --name Curve3Pool --fetch-from-etherscan https://optimistic.etherscan.io/address/0x1337BedC9D22ecbe766dF105c9623922A27963EC
niacin generate-sol-interface --name Curve3Pool > src/vendor/Curve3Pool.sol
niacin generate-npm-pkg                         > index.js
// SPDX-License-Identifier: UNLICENSED
// This file was autogenerated by Niacin, using abi-to-sol.
pragma solidity ^0.8.20;

interface Curve3Pool {
    event AddLiquidity(
        address indexed provider,
        uint256[3] token_amounts,
        uint256[3] fees,
        uint256 invariant,
        uint256 token_supply
    );
    event RemoveLiquidity(
        address indexed provider,
        uint256[3] token_amounts,
        uint256[3] fees,
        uint256 token_supply
    );

    // ...

    function get_virtual_price() external view returns (uint256);

    function calc_token_amount(
        uint256[3] memory _amounts,
        bool _is_deposit
    ) external view returns (uint256);

    function add_liquidity(
        uint256[3] memory _amounts,
        uint256 _min_mint_amount
    ) external returns (uint256);

    // ...
}
const deployments = require('./index')
> deployments
{
  // ...
  TakeMarketShares: {
    version: 1,
    abi: [ [Object], [Object], [Object], [Object] ],
    address: '0x6166169180C5426902BE92e879feBEE0Ae280978',
    deployBlock: 163
  },
  Curve3Pool: {
    abi: [
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object], [Object], [Object], [Object],
      [Object], [Object]
    ],
    address: '0x1337BedC9D22ecbe766dF105c9623922A27963EC'
  },

Niacin even comes with a built-in CLI for interacting with contracts, like seth/cast but easier:

# List the contracts you can call.
$ niacin call

Usage:
niacin call <contract> <method> [<arg0>] [<arg1>] ...

Available contracts:
- AddressProvider
- ProxyTakeMarket
- ProxyTakeMarketShares
- TakeMarket
- TakeMarketShares
- Curve3Pool
- WETH


# List the methods on a contract.
$ niacin call TakeMarket

Usage:
niacin call <contract> <method> [<arg0>] [<arg1>] ...

Available methods:
- getMessage()
- setHello(string)


# Call a write method.
$ niacin call TakeMarket setHello "heyheyheyyyyyy"
0x61780d2fd81bc1b72ea3473aa1f3a72ec5106d7dc014bd6a998934bb0d644c14

# Call a read method.
$ niacin call TakeMarket getMessage
heyheyheyyyyyy


# This even works with vendored dependencies:
$ niacin call WETH name
Wrapped Ether

How about contracts? Do I need to copy-paste addresses to lookup contracts in my system? No, you can now easily resolve their addresses on-chain using requireAddress(target). It is smart and caches entries, meaning no extra CALL's like a Beacon.

import {MixinResolver} from "@niacin/mixins/MixinResolver.sol";

contract TakeMarket is 
    MixinResolver,
{
    function getDependencies() public override pure returns (bytes32[] memory addresses) {
        bytes32[] memory requiredAddresses = new bytes32[](1);
        requiredAddresses[0] = bytes32("TakeMarketShares");
        return requiredAddresses;
    }

    function takeMarketShares() internal view returns (address) {
        return requireAddress(bytes32("TakeMarketShares"));
    }

    // ...

Initializers and configuration of contracts.

What about initializing contracts? And other sorts of scripting? Niacin supports that too:

module.exports = async function (niacin) {
    const { TakeMarket } = niacin.contracts

    await niacin.initialize({
        contract: TakeMarket,
        args: [1111]
    })

    const markets = ['1', '2', '3']
    for (const market of markets) {
        // Create a market.
        await niacin.runStep({
            contract: TakeMarket,
            read: 'getTakeSharesContract',
            readArgs: [market],
            stale: value => value == '0x0000000000000000000000000000000000000000',
            write: 'getOrCreateTakeSharesContract',
            writeArgs: [market],
        })
    }
}

These migration scripts are smart. Initializers/getters/setters only run when values are stale / have changed.

Unlike OpenZeppelin initializers, integrating them is super easy:

import {MixinResolver} from "@niacin/mixins/MixinResolver.sol";
import {MixinInitializable} from "@niacin/mixins/MixinInitializable.sol";

contract TakeMarket is 
    MixinResolver,
    MixinInitializable
{
    uint public counter;

    function initialize(uint _counter) public initializer {
        counter = _counter;
    }

Just annotate your initializer function with initializer, and it will only be able to be called by the deployer. No inheritance pains.

Autogenerated deployment UI's.

What's more? We have support for autogenerated deployment websites, with interactive contract UI's:

No need to connect wallet. Automatically connects an RPC provider based on the ChainList database of chain ID's and public RPC nodes.

How does this work?

Niacin is really quite simple - contracts are backed by upgradeable delegatecall proxies, each contract can inherit from MixinResolver, which gives it the ability to resolve other contract's addresses at runtime. Unlike other approaches (beacons), these addresses are loaded from a resolution cache in storage, which is more efficient. All contracts are registered onchain in the AddressProvider, which the mixinresolver calls out to when rebuilding its cache.

Some cool implementation details:

  • Using MixinResolver does not require passing it the addressProvider address. This is saved when the Proxy is created. The sharing of this storage is achieved very ergonomically using the store pattern.
  • The store pattern involves typing an area of storage using a WhateverStore struct. For example, ImplStore for the implementation storage. Then defining a mixin contract, the ImplStorage contract which allows us to read and write to this struct. Unlike most storage namespacing, we can modify the struct directly _implStore().x = y, unlike typical approaches which can only read/write single values ie. storagePut(key, val), storageGet(key). This is because _implStore() returns a struct with a modified slot. This is highly ergonomic.
  • Using this approach, both Proxy and MixinResolver (used by implementations) inherit from ImplStore and are able to share access to the _implStore().resolver value.
  • This seems to make Solidity inheritance a lot easier to deal with too, as it is a proper separation of concerns.

Contracts that require dynamic dependency resolution simply inherit from MixinResolver. This is how it is done in Synthetix:

contract MyContract is MixinResolver {
    constructor(address _resolver) MixinResolver(_resolver) {
        // ...
    }
}

We make an improvement here in not requiring initialisation of the MixinResolver:

contract MyContract is MixinResolver {
}

Instead, we can set the resolver in the proxy, and share this storage with the implementation as so:

// Implementation (through inheritance).
contract MixinResolver is ImplStorage {
    function requireAddress(bytes32 target) internal {
        AddressProvider provider = AddressProvider(_implStore().addressProvider)
        return provider.requireAddress(target)
    }
}

// Proxy.
contract Proxy is ImplStorage {
    constructor(address _addressProvider) {
        _implStore().addressProvider = _addressProvider;
    }
}
// This store is consumed by both the proxy and by implementations.
struct ImplStore {
    // An address provider which the implementation uses to resolve dependencies.
    address addressProvider;
    // The proxy for this implementation.
    address proxy;
    // The address cache for the implementation's dependencies.
    mapping(bytes32 => address) addressCache;
}

contract ImplStorage {
    bytes32 constant private STORE_SLOT = bytes32(uint(keccak256("eth.nakamofo.niacin.v1.impl")) - 1);

    function _implStore() internal pure returns (ImplStore storage store) {
        bytes32 s = STORE_SLOT;
        assembly {
            store.slot := s
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment