@etherisc/depeg-contracts
v1.2.0
Published
Etherisc's smart contracts for a depeg insurance for stable coins.
Downloads
317
Readme
Depeg Insurance Contracts
This repository holds the smart contracts for a depeg insurance for stable coins.
Address for Test Goerli ETH
0x0E458906446AfB6fB388b1a5E1cE6598d077dfB5
Explore Mumbai Setup
Preparation steps:
- Compile contracts
- Start the Brownie console connecting to Mumbai
brownie compile --all
brownie console --network=polygon-test
In the Brownie console run the following commands.
Given the Depeg product address (at the end of the commands) the function get_setup
"crawls" the relevant deployed contracts and report their current configuration.
The resulting configuration (JSON) is stored in variable setup
.
from scripts.util import contract_from_address, get_package
from scripts.deploy_depeg import get_setup, contract_from_address
gif = get_package('gif-contracts')
usd1 = contract_from_address(USD1, '0x5B70915CA7De9c21229AE5E18A64f6cF36dc11Fb')
usd2 = contract_from_address(USD2, '0x268131A1A7639F8F918d4a17A74C79209b2C645D')
dip = contract_from_address(DIP, '0x0c3EA45c5290FCCB528109b167A5e66734d2578c')
product_address='0x43687bd425E02E375Dee3D22903878CdbB2eD019'
(setup, product, feeder, riskpool, registry, staking, dip, usdt, usdc, instance_service) = get_setup(product_address)
setup
Explore Goerli Setup
Preparation steps:
- Compile contracts
- Start the Brownie console connecting to Mumbai
brownie compile --all
brownie console --network=goerli
In the Brownie console run the following commands.
Given the Depeg product address (at the end of the commands) the function get_setup
"crawls" the relevant deployed contracts and report their current configuration.
The resulting configuration (JSON) is stored in variable setup
.
from scripts.util import contract_from_address, get_package
from scripts.deploy_depeg import get_setup, contract_from_address
gif = get_package('gif-contracts')
usd1 = contract_from_address(USD1, '0x00B7cA1167bCc1CfEe30dF946B9C2Df7F36F2fB3')
usd2 = contract_from_address(USD2, '0x6baCCf5b80000fDc3B5925eB65253D0a73aeF882')
dip = contract_from_address(DIP, '0x47678b3aC8336dc3fE390d81ef80593D0c6b28B9')
product_address='0x33A7785D9EEB78e33A9587E9De075aB11A354387'
(setup, product, feeder, riskpool, registry, staking, dip, usdt, usdc, instance_service) = get_setup(product_address)
setup
Explore Mainnet Setup
Preparation steps:
- Compile contracts
- Start the Brownie console connecting to Mumbai
brownie compile --all
brownie console --network=mainnet
In the Brownie console run the following commands.
Given the Depeg product address (at the end of the commands) the function get_setup
"crawls" the relevant deployed contracts and report their current configuration.
The resulting configuration (JSON) is stored in variable setup
.
from scripts.util import contract_from_address, get_package
from scripts.deploy_depeg import get_setup, contract_from_address
gif = get_package('gif-contracts')
usd1 = contract_from_address(USD1, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48')
usd2 = contract_from_address(USD2, '0xdAC17F958D2ee523a2206206994597C13D831ec7')
dip = contract_from_address(DIP, '0xc719d010B63E5bbF2C0551872CD5316ED26AcD83')
product_address='0xD434aeB7bb2abf66b23A85eD12c2C8F366Df6766'
(setup, product, feeder, riskpool, registry, staking, dip, usdt, usdc, instance_service) = get_setup(product_address)
setup
Release new version
- switch to main branch
- merge all changes to main (develop -
git merge develop
) - increase npm package version (
npm version major|minor|patch
) - push changes to main (
git push && git push --tags
) - publish updated npm package (
npm publish
) - check updated package on https://www.npmjs.com/package/@etherisc/depeg-contracts
External dependencies
- Moralis, also see library support
- Etherisc backend services
- Chainlink USDC/USD price feed
GIF Object Lifecycles
Livecycle Combinations
Check test coverage of policy and bundle states
bundle/policy | active | expired | closed
--------------+---------------+---------------+---------------
active | lots of tests | can not claim | can not claim
locked | some tests | one test | one test
closed | gif prevents | gif prevents | some tests
burned | gif prevents | gif prevents | one test
Application Lifecycle
Product contract functions
- applyForPolicyWithBundleAndSignature (external)
- applyForPolicyWithBundle (external)
- _applyForPolicyWithBundle (internal)
Function _applyForPolicyWithBundle
internally calls _underwrite
and requires underwriting to succeed.
Underwriting via performs the following actions (GIF framework)
- Locking of collateral via pool controller
- Underwriting the application object via policy controller
- Creating an active policy via policy controller
- Collect premium via treasury module
- If premium collection successful: reflect collected premium in policy and riskpool book keeping
In short: A new active policy is created.
Unit tests
❯ find . | grep '.py$' | xargs grep -n applyForPolicyWithBundleAndSignature | cut -c-120
./tests/test_application_gasless.py:125: product.applyForPolicyWithBundleAndSignature(customer, protectedWallet,
./tests/test_application_gasless.py:129: product.applyForPolicyWithBundleAndSignature(customer2, protectedWallet,
./tests/test_application_gasless.py:133: product.applyForPolicyWithBundleAndSignature(customer, customer2, protec
./tests/test_application_gasless.py:137: product.applyForPolicyWithBundleAndSignature(customer, protectedWallet,
./tests/test_application_gasless.py:141: product.applyForPolicyWithBundleAndSignature(customer, protectedWallet,
./tests/test_application_gasless.py:145: product.applyForPolicyWithBundleAndSignature(customer, protectedWallet,
./tests/test_application_gasless.py:149: product.applyForPolicyWithBundleAndSignature(customer, protectedWallet,
./tests/test_application_gasless.py:153: product.applyForPolicyWithBundleAndSignature(customer, protectedWallet,
./tests/test_application_gasless.py:157: product.applyForPolicyWithBundleAndSignature(customer, protectedWallet,
./tests/test_application_gasless.py:197: tx = product.applyForPolicyWithBundleAndSignature(
❯ find . | grep '.py$' | xargs grep -n "applyForPolicyWithBundle(" | cut -c-120
./scripts/deploy_depeg.py:1459: tx = product.applyForPolicyWithBundle(wallet, sumInsured, duration, bundleId, {'from'
./scripts/setup.py:134: tx = product.applyForPolicyWithBundle(
./tests/test_product_20.py:241: tx = product20.applyForPolicyWithBundle(
./tests/test_product_20.py:307: product20.applyForPolicyWithBundle(
./tests/test_product_20.py:318: product20.applyForPolicyWithBundle(
./tests/test_product_20.py:329: product20.applyForPolicyWithBundle(
./tests/test_product_20.py:339: tx = product20.applyForPolicyWithBundle(
./tests/test_sandbox.py:121: tx = product.applyForPolicyWithBundle(
Policy Lifecyle
New (active) policies are created in the context of function _applyForPolicyWithBundle
Policies go through states Expired
and Closed
in slightly different ways that are dependent if there has been a depeg event or not.
In the case of normal expiry without a depeg event/claim, a policy should be explicitly closed to free the locked collateral in the risk pool.
This is achieved via the close
function that internally expires and closes the policy (via _expire
and _close
).
If there is a depeg event and a policy is allowed to claim, the following steps are performed in createDepegClaim
- A new claim is created for the policy (via
_newClaim
) - The policy is expired (via
_expire
)
In a depeg event policies that created a depeg claim can then be processed via the following steps
- Get relevant process ids via (
policiesToProcess
/getPolicyToProcess
) - Process individual policies (via
processPolicy
) or batch (viaprocessPolicies
which internally usesprocessPolicy
)
Inside processPolicy
the following actions are perfomed
- Confirm the claim (via _confirmClaim)
- A new payout is created (via _newPayout)
- The payout is processed (via _processPayout)
- The policy is closed (via _close)
Unit test for expriy/closing without depeg case:
❯ find . | grep '.py$' | xargs grep -n "close(" | cut -c-120
./tests/test_policy_lifecycle.py:558: product.close(process_id)
./tests/test_policy_lifecycle.py:566: tx = product.close(process_id)
./tests/test_policy_lifecycle.py:690: product.close(process_id)
./tests/test_policy_lifecycle.py:702: tx = product.close(process_id)
./tests/test_policy_lifecycle.py:813: product.close(process_id)
./tests/test_policy_lifecycle.py:824: tx = product.close(process_id)
Unit tests for depeg case
❯ find . | grep '.py$' | xargs grep -n "createDepegClaim" | cut -c-120
./tests/test_policy_lifecycle.py:262: tx = product.createDepegClaim(
./tests/test_policy_lifecycle.py:912: tx = product.createDepegClaim(
./tests/test_policy_lifecycle.py:1063: product.createDepegClaim(process_id1, {'from': protectedWallet})
./tests/test_policy_lifecycle.py:1064: product.createDepegClaim(process_id2, {'from': protectedWallet})
./tests/test_policy_lifecycle.py:1065: product.createDepegClaim(process_id3, {'from': protectedWallet})
./tests/test_product_20.py:537: tx = product20.createDepegClaim(
./tests/test_product_20.py:675: tx = product20.createDepegClaim(
Unit tests to process claims
❯ find . | grep '.py$' | xargs grep -n "policiesToProcess" | cut -c-120
./server_processor/product.py:134: 'policies_to_process': depeg_product.policiesToProcess(),
./tests/test_policy_lifecycle.py:247: assert product.policiesToProcess() == 0
./tests/test_policy_lifecycle.py:292: assert product.policiesToProcess() == 1
./tests/test_policy_lifecycle.py:398: assert product.policiesToProcess() == 0
❯ find . | grep '.py$' | xargs grep -n "getPolicyToProcess" | cut -c-120
./tests/test_policy_lifecycle.py:293: (pid, wallet) = product.getPolicyToProcess(0)
❯ find . | grep '.py$' | xargs grep -n "processPolicies" | cut -c-120
./tests/test_policy_lifecycle.py:376: tx = product.processPolicies([process_id])
./tests/test_policy_lifecycle.py:693: tx = product.processPolicies([process_id])
./tests/test_product_20.py:484: tx = product20.processPolicies([process_id])
./tests/test_product_20.py:621: tx = product20.processPolicies([process_id])
❯ find . | grep '.py$' | xargs grep -n "processPolicy" | cut -c-120
./tests/test_policy_lifecycle.py:842: tx = product.processPolicy(process_id1)
./tests/test_policy_lifecycle.py:849: tx = product.processPolicy(process_id2)
./tests/test_policy_lifecycle.py:865: product.processPolicy(process_id3)
❯ find . | grep '.py$' | xargs grep -n "policyIsAllowedToClaim" | cut -c-120
./tests/test_policy_lifecycle.py:659: assert product.policyIsAllowedToClaim(process_id) is False
./tests/test_policy_lifecycle.py:669: assert product.policyIsAllowedToClaim(process_id) is True
./tests/test_policy_lifecycle.py:677: assert product.policyIsAllowedToClaim(process_id) is True
./tests/test_policy_lifecycle.py:687: assert product.policyIsAllowedToClaim(process_id) is True
./tests/test_policy_lifecycle.py:700: assert product.policyIsAllowedToClaim(process_id) is False
./tests/test_policy_lifecycle.py:783: assert product.policyIsAllowedToClaim(process_id) is False
./tests/test_policy_lifecycle.py:793: assert product.policyIsAllowedToClaim(process_id) is True
./tests/test_policy_lifecycle.py:801: assert product.policyIsAllowedToClaim(process_id) is True
./tests/test_policy_lifecycle.py:810: assert product.policyIsAllowedToClaim(process_id) is True
./tests/test_policy_lifecycle.py:822: assert product.policyIsAllowedToClaim(process_id) is False
Bundle Lifecycle
- New (active) bundles are created in the context of the depeg riskpool function
createBundle
. - Existing (and active) bundles can be locked using riskpool function
lockBundle
(restricted to bundle owner). Locked bundles are no longer available to cover new policies. - Locked bundles can be unlocked using riskpool function
unlockBundle
(restricted to bundle owner). Unlocked bundles are active again and may cover new policies. - Active or locked bundles can be closed using riskpool function
closeBundle
by the bundle owner. An additional restriction for closing makes sure only bundles may be closed that no longer cover open policies. A policy can only be closed if there are no open claims or open payouts. - Closed bundles can be burned by the bundle owner unsing riskpool function
burnBundle
. For closed bundles no other state change is possible. Burning bundles will automatically and completely defund the bundle.
Unit tests that cover the lifecycle of bundles in the depeg riskpool
❯ find . | grep '.py$' | xargs grep -n "createBundle(" | cut -c-120
./scripts/setup.py:96: tx = riskpool.createBundle(
❯ find . | grep '.py$' | xargs grep -n "create_bundle(" | cut -c-120
./scripts/deploy_depeg.py:1334: return create_bundle(
./scripts/setup.py:55: return create_bundle(
./scripts/setup.py:70:def create_bundle(
./tests/test_application_create.py:45: bundleId = create_bundle(
./tests/test_application_create.py:162: bundleId = create_bundle(
./tests/test_application_gasless.py:177: bundleId = create_bundle(
./tests/test_bundle_create.py:55: bundleId = create_bundle(
./tests/test_bundle_create.py:190: bundleId1 = create_bundle(
./tests/test_bundle_create.py:208: bundleId2 = create_bundle(
...many more
❯ find . | grep '.py$' | xargs grep -n "lockBundle(" | cut -c-120
./tests/test_product_20.py:238: riskpool20.lockBundle(bundle_id, {'from': investor})
./tests/test_product_20.py:250: riskpool20.unlockBundle(bundle_id, {'from': investor})
./tests/test_product_20.py:274: riskpool20.lockBundle(bundle_id2, {'from': investor})
./tests/test_product_20.py:286: riskpool20.unlockBundle(bundle_id2, {'from': investor})
❯ ❯ find . | grep '.py$' | xargs grep -n "closeBundle(" | cut -c-120
./tests/test_policy_lifecycle.py:267: riskpool.closeBundle(bundle_id, {'from': investor})
./tests/test_policy_lifecycle.py:380: riskpool.closeBundle(bundle_id, {'from': investor})
./tests/test_policy_lifecycle.py:448: riskpool.closeBundle(bundle_id, {'from': investor})
./tests/test_policy_lifecycle.py:584: tx = riskpool.closeBundle(bundle_id, {'from': investor})
❯ find . | grep '.py$' | xargs grep -n "burnBundle(" | cut -c-120
./tests/test_policy_lifecycle.py:459: tx = riskpool.burnBundle(bundle_id, {'from': investor})
Product Considerations
What is insured?
A depeg policy covers the risk of a depeg of stable coin USD1 from the fiat currency USD.
- The insured buys a policy to insure the depeg risk of stable coin USD1.
- In a depeg event the loss amount is payed out in stable coin USD2.
- The risk that stable coin USD2 has depegged at the same time is not covered.
Policy parameters:
- Sum insured amount of X in USD1
- Maximum premium payment of amount Y in USD2 the policy holder is willing to pay
- Actual USD1 funds located at account address Z
Account/wallet requirements
Only funds at a specific address may be insured. Insurable accounts
- Externally owned account (EOA)
- Gnosis Safe multisig smart contract
Depeg event
The depeg definition is based on data provided by the Chainlink price feed for stable coin USD1. See Contract addresses for available price feeds. See this repository for some initial analysis of Chainlink price feeds for stable coins.
The following definition for a deterministic definition of a depeg event may be used.
A depeg event candidate is created when the USD1 price data falls below a trigger threshold.
When the USD1 price data recovers at or above a recovery threshold within 24h the depeg event candidate is considered a false alarm and no claims/payouts are created.
When the USD1 price data does not recover at or above the recovery threshold within 24 hours the depeg event candidate becomes an actual depeg event.
For the loss calculation the price data 24h after the initial depeg trigger is used.
An inherent risk with the above definition is its Chainlink price feed dependency.
Chainlink price feeds come with a so called 'Heartbeat' which indicates the maximum time between consecutive price updates even when the price does not change enough to trigger a price update.
As in the case of any off-chain dependency there is a risk that systems do not behave as intended in extraordinary circumstances.
For such cases we are considering the addition of a manual trigger for depeg events.
Such a trigger might only be used when a predefined set of conditions occur. For the usage of a manual trigger a number of restrictions will apply.
- The latest price update from the chainlink feed is long overdue
- Price data is broken, publically acknowledged by Chainlink
- Calling is restricted to accounts whitelisted by the product owner
Loss calculation
For the loss calculation the following points are considered.
- The loss is assesed by the %-loss of the USD1 exchange rate against the fiat currency USD.
- The sum insured might be reduced to the actual balance of the insured account at the time of the loss event
- The loss amount is calulated by the %-loss at the depeg claim event times the sum insured.
Payout handling
Payouts are made in an alternative stable coin USD2.
- The payout amount calculation is ensured by product management
- Actual payout execution may be triggered by the policy holder
- For a payout a fixed exchange rate between the payout stable coin USD2 and the fiat USD of 1.0 is used.
Risk Capital Considerations
Where does the risk capital come from?
The risk capital in USD2 to cover the depeg policies comes from risk investors. Participation makes sense for risk investors with the following believe system
- Depeg risk of USD1 over the coming 3 to 6 months is neglegible
- Availability of USD2 funds
- Interest to make some income by locking USD2 funds into a riskpool
How is the risk premium determined?
The risk investor defines the annual percentage return she/he is asking for covering the depeg risk. This annual percentage return asked by the risk investor directly translates into policy net premium amounts.
Risk investors might want to consider the following aspects before fixing their annual percentage return for the provided risk capital.
- The more policies covered, the more return (net premiums - claim payouts)
- The higher the policy premiums, the more return
- Setting the annual percentage return very high will likely lead to very few covered policies
- Setting the annual percentage return very low will likely lead to many covered policies with a low total net premium
- Finding the sweet spot for the annual percentage return will lead to the highest return on the risk capital.
How to invest risk capital for the depeg insurance
Investing risk captial can be achieved by providing risk captial to the depeg risk pool.
- Risk investors need to fix their risk parameters (e.g. annual percentage return) and provide risk capital to the risk pool in stable coin USD2.
- When investing in the risk pool the risk investor gets a risk bundle NFT that represents the invested USD2 capital.
- Once the risk bundle is created the risk capital needs to be unlocked by staking DIP token against the bundle.
- Only risk capital that is unlocked by DIP staking may be available to cover depeg policies and collect potential return on the risk capital.
Staking Considerations
Intro
At the highest level the holder of some staking token (DIP in this use case) locks some of her/his token in a smart contract for some time to support the ecosystem and to get some rewards (DIP tokens in this use case).
The sustainable source for such rewards in the GIF context comes from platform fees that are collected while selling insurance policies.
Comparison of validator and riskpool staking
To get into staking and delegated staking in the GIF context it might be helpful to compare the widely used validator staking with the GIF specific riskpool staking.
The context of the depeg product is even more specific in the sense that the current staking concept for the depeg insurance links staking with individual risk bundels that are funded by risk captial providers (ie the investors).
| Topic | "Classical" case | Depeg insurance | |-----------------|------------------|-----------------| | Staking target | Validator | Riskpool bundle | | Purpose | Needs staked token to be considered to validate TX | Needs staked token to be able to cover policies with locked risk capital | | More staking | Increases likelyhood to validate blocks and win rewards | Unlockes more of the risk capital to cover more policies | Reward source | TX fees and freshly minted token | Fraction of platform fees linked to riskpool bundle (converted to DIP token) | Payout event | For each validated block | When riskpool bundle is burned | Slashing | When validator misses slots or misbehaves | No slashing, GIF ensures that bundle owner cannot act in any malicious way
Staking and delegated staking
For the purpose of the depeg insurance the only difference between staking and delegated staking is who ownes the staked DIP token.
The envisioned process to stake or to do delegated staking is as follows.
- The risk capital investor creates a risk bundle and funds the bundle with USD2
- DIP token holders which might or might not be risk capital investors choose an active riskpool bundle and stake DIP to unlock risk capital invested in the bundle.
- The rate of staked DIP to unlocked USD2 is fixed. Therefore, the amount of DIP that can be staked to a riskpool bundle is capped by the amount of USD2 token associated with a riskpool bundle.
A likely scenario is to reserve a fraction of the staking volume to whitelisted DIP holders for a certain time to honor their early support and investment in the eco system.
Implications on bundle life cycle
The bundle life cycle needs a staking phase to unlock the provided risk capital to collateralize policies.
Such a staking phase can be defined by an additional bundle state Created
prior to the Active
state.
stateDiagram-v2
[*] --> Created
Created --> Active: Stakes collected
Locked --> Active: Accept new\npolicies again
Active --> Locked: No new policies
Active --> Closed: No active policies
Locked --> Closed: No active policies
Closed --> Burned: Return stakes\npayout rewards
Burned --> [*]
While in created state a bundle collects stakes that enable the provided capital to collateralize policies.
While a bundle is Created
state anybody (including the bundle owner) with DIP tokens may stake DIP on a bundle in state Created
.
Contract setup
Staking tracked and managed by a separate contract outside of any GIF instance. As staking provides utility to DIP it should be managed in a central place outside a specific GIF instance.
Potentially the staking contract(s) could live on mainnet and keep track of what address staked how much on which instance on what.
In this use case staking is defined for bundles only so far.
Staking using Liquidity Mining
Staking in the DIP ecosystem could be kick started with liquidity mining only. DIP holders could stake DIP on active bundles. Staked DIP would then earn some percentage of DIP per year on a pro rata base.
Would allow for staking/unstaking at any time. Bookkeeping of pro rata staking rewards would be simple to implement.
Such an approach would work without any fee collection book keeping and could kick start staking in the DIP ecosystgem.
Staking Contract
Staking contract on mainnet
Staker API
function stake(bytes32 instanceId, uint256 bundleId, uint256 amount)
function withdraw(bytes32 instanceId, uint256 bundleId, uint256 amount)
function balanceOf(address staker, bytes32 instanceId, uint256 bundleId)
Maintenance API
function syncBundleState(address instanceRegistryAddress)
Github actions
Only go through the process described here if an updated GIF is required for the deployment test.
Update Ganache DB for deployment test
Start a local ganache with db folder
rm -rf .github/workflows/ganache-gif/
ganache-cli \
--mnemonic "candy maple cake sugar pudding cream honey rich smooth crumble sweet treat" \
--chain.chainId 1234 \
--port 7545 \
--accounts 20 \
-h "0.0.0.0" \
--database.dbPath .github/workflows/ganache-gif/
In new shell run the following commands
brownie networks add Local ganache-ghaction host=http://localhost:7545 chainid=1234
cd /gif-contracts
rm -rf build/
brownie console --network=ganache-ghaction
Now paste this into the brownie console
from brownie import TestCoin
instanceOperator = accounts[0]
instanceWallet = accounts[1]
usdc = TestCoin.deploy({'from': instanceOperator})
from scripts.instance import GifInstance
instance = GifInstance(instanceOperator, instanceWallet)
print('registry {}\nerc20 {}'.format(
instance.getRegistry().address,
usdc.address))
Now shutdown above started Ganache chain (ctrl+c) and commit the new files to git.
Also save the values for registry and erc20 in scripts/test_deployment.py
.
Depeg monitor
The code for the depeg monitor is in server
. It exposes a FastAPI api at /docs
.
Deployment
The Dockerfile to run the depeg monitor is Dockerfile.depeg-monitor
. It requires these environment variables to be set:
APPLICATION_TITLE = "Depeg API Monitoring"
APPLICATION_VERSION = 1.0
APPLICATION_DESCRIPTION = "API Server to Monitor and access price feed data"
LOGGING_FORMAT="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{file}</cyan>:<cyan>{line}</cyan> <cyan>[{name}.{function}]</cyan> - <level>{message}</level>"
LOGGING_LEVEL="INFO"
# only for neworks that use fake price feeder like mumbai
FEEDER_INTERVAL=60
checker_interval=30
# web3 coordinates
PRODUCT_CONTRACT_ADDRESS="0xa93CE853aAD898cd04547EEceAf48dBA93A70b97"
# only on test chains
PRODUCT_OWNER_MNEMONIC=
PRODUCT_OWNER_OFFSET=
# on all chains
MONITOR_MNEMONIC=
NODE__NETWORK_ID=
WEB3_INFURA_PROJECT_ID=
Dokku deployment steps
Checking current setup on cloud server
dokku apps:list
dokku apps:report <app-name>
dokku domains:report <app-name>
dokku config:show <app-name>
Push/deploy current state form local repo
git push <remote-repo> <local-branch>:main
git push dokku-mainnet main:main
Set and check env variables on cloud server.
dokku config:set <app-name> name=value
dokku config:show <app-name>
dokku logs -t <app-name>
To remove an unused env variable use dokku config:unset <app-name> key1 [key2 ...]