Repository: onflow/fcl-js
Pull request #1481 (2022-11-08 23:43:14)

flow.json ingestion and contract placeholder configuration
Addresses: https://github.com/onflow/fcl-js/issues/1449

--------------------

## Overview
Removes the need for a developer to set placeholders in config manually. Instead it ingests the flow.json. Placeholders currently follow same format as before. 

New benefit is that now if your flow.json changes, your build will pick up on it since it's imported and remove the need to manually set contract placeholder addresses in config.

## Example
Example flow.json

```
{
  "contracts": {
    "HelloWorld": {
      "source": "./cadence/contracts/HelloWorld.cdc",
         "aliases": {
            "emulator": "0xf8d6e0586b0a20c7"
          }
      }
  }
}
```

Example config:

```
import flowJson from "./flow.json"
import * as fcl from "@onflow/fcl"

fcl.config().load(flowJson)
```

Example Cadence:

```
import HelloWorld from 0xHelloWorld

pub fun main(): String {
  return HelloWorld.greeting
}
```
TODO:
- [x] Create load function in config
- [x] Merge flow.json files and create placeholders in config
- [x] Support new style in Cadence file imports
- [x] Warn or error if private keys exist in flow.json
- [x] Warn if conflicts
- [x] Tests

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: def6be2fd7f78dcb8962d53518ed9885040ec677

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/onflow/fcl-js/new/chasefleming/flow-json?filename=.changeset/fast-knives-travel.md&value=---%0A%22%40onflow%2Fconfig%22%3A%20patch%0A%22%40onflow%2Fsdk%22%3A%20patch%0A---%0A%0ALoad%20flow.json%20for%20easier%20Cadence%20imports%20and%20less%20configuration%0A)


sideninja: Would it be possible to support the new import schema as well? 
`import HelloWorld` 
or
`import HelloWorld from "alice"`

Even if part of another PR but if we don't address this then we will continue having a disconnect between Go tools and JS tools.
chasefleming: @sideninja we definitely can, but would suggest a separate PR once new flow.json format is locked. This just keeps import placeholders as they currently function, but without having to manually add them. But we would be setup to ingest the new flow.json when it's ready and can do so in a separate PR. With the current format, we just don't have a reliable way of doing the  `from "{ACCOUNT}"`.
justinbarry: @chasefleming Can you add changeset files?

Patch:
diff --git a/.changeset/chatty-lizards-push.md b/.changeset/chatty-lizards-push.md
new file mode 100644
index 00000000..b7cdf3de
--- /dev/null
+++ b/.changeset/chatty-lizards-push.md
@@ -0,0 +1,5 @@
+---
+"@onflow/config": minor
+---
+
+Remove need to manually add contract placeholders in config by ingesting flow.json with config.load
diff --git a/packages/config/src/config.js b/packages/config/src/config.js
index ac74abd4..d963c67c 100644
--- a/packages/config/src/config.js
+++ b/packages/config/src/config.js
@@ -5,6 +5,9 @@ import {
   SUBSCRIBE,
   UNSUBSCRIBE,
 } from "@onflow/util-actor"
+import * as logger from "@onflow/util-logger"
+import {invariant} from "@onflow/util-invariant"
+import {getContracts, cleanNetwork, anyHasPrivateKeys} from "../utils/utils"
 
 const NAME = "config"
 const PUT = "PUT_CONFIG"
@@ -108,6 +111,54 @@ function resetConfig(oldConfig) {
   return clearConfig().then(config(oldConfig))
 }
 
+/**
+ * Takes in flow.json or array of flow.json files and creates contract placeholders
+ * @param {Object|Object[]} data
+ * @returns {void}
+ */
+async function load(data) {
+  const network = await get("flow.network")
+  const cleanedNetwork = cleanNetwork(network)
+  const { flowJSON } = data
+
+  invariant(
+    Boolean(flowJSON),
+    "config.load -- 'flowJSON' must be defined"
+  )
+
+  invariant(
+    cleanedNetwork,
+    `Flow Network Required -- In order for FCL to load your contracts please define "flow.network" to "emulator", "local", "testnet", or "mainnet" in your config. See more here: https://developers.flow.com/tools/fcl-js/reference/configure-fcl`
+  )
+
+  if (anyHasPrivateKeys(flowJSON)) {
+    const isEmulator = cleanedNetwork === 'emulator'
+
+    logger.log({
+      title: "Private Keys Detected",
+      message: `Private keys should be stored in a separate flow.json file for security. See more here: https://developers.flow.com/tools/flow-cli/security`,
+      level: isEmulator ? logger.LEVELS.warn : logger.LEVELS.error,
+    })
+
+    if (!isEmulator) return
+  }
+
+  for (const [key, value] of Object.entries(getContracts(flowJSON, cleanedNetwork))) {
+    const contractConfigKey = `0x${key}`
+    const existingContractConfigKey = await get(contractConfigKey)
+
+    if (existingContractConfigKey) {
+      logger.log({
+        title: "Contract Placeholder Conflict Detected",
+        message: `A generated contract placeholder from config.load and a placeholder you've set manually in config have the same name.`,
+        level: logger.LEVELS.warn,
+      })
+    } else {
+      put(contractConfigKey, value)
+    }
+  }
+}
+
 function config(values) {
   if (values != null && typeof values === "object") {
     Object.keys(values).map(d => put(d, values[d]))
@@ -123,6 +174,7 @@ function config(values) {
     where,
     subscribe,
     overload,
+    load,
   }
 }
 
@@ -135,6 +187,7 @@ config.delete = _delete
 config.where = where
 config.subscribe = subscribe
 config.overload = overload
+config.load = load
 
 export {config}
 
diff --git a/packages/config/utils/utils.js b/packages/config/utils/utils.js
new file mode 100644
index 00000000..5867006a
--- /dev/null
+++ b/packages/config/utils/utils.js
@@ -0,0 +1,109 @@
+const pipe = (...funcs) => v => {
+  return funcs.reduce((res, func) => {
+    return func(res)
+  }, v)
+}
+
+/**
+ * Object check.
+ * @param value
+ * @returns {boolean}
+ */
+const isObject = value => value && typeof value === 'object' && !Array.isArray(value)
+
+/**
+ * Deep merge multiple objects.
+ * @param {Object} target
+ * @param {Object[]} ...sources
+ * @returns {Object}
+ */
+const mergeDeep = (target, ...sources) => {
+  if (!sources.length) return target
+  const source = sources.shift()
+
+  if (isObject(target) && isObject(source)) {
+    for (const key in source) {
+      if (isObject(source[key])) {
+        if (!target[key]) Object.assign(target, { [key]: {} })
+        mergeDeep(target[key], source[key])
+      } else {
+        Object.assign(target, { [key]: source[key] })
+      }
+    }
+  }
+
+  return mergeDeep(target, ...sources)
+}
+
+/**
+ * Deep merge multiple Flow JSON.
+ * @param {Object|Object[]} value
+ * @returns {Object}
+ */
+const mergeFlowJSONs = value => Array.isArray(value) ? mergeDeep({}, ...value) : value
+
+/**
+ * Filter out contracts section of flow.json.
+ * @param {Object|Object[]} obj
+ * @returns {Object}
+ */
+const filterContracts = obj => obj.contracts ? obj.contracts : {}
+
+/**
+ * Gathers contract addresses by network
+ * @param {string} network emulator, testnet, mainnet
+ * @returns {Object} { "HelloWorld": "0x123" }
+ */
+const mapContractToNetworkAddress = network => contracts => {
+  return Object.entries(contracts).reduce((c, [key, value]) => {
+    const networkContractAlias = value?.aliases?.[network]
+    if (networkContractAlias) {
+      c[key] = networkContractAlias
+    }
+
+    return c
+  }, {})
+}
+
+/**
+ * Take in flow.json files and return contract to address mapping by network
+ * @param {Object|Object[]} jsons
+ * @param {string} network emulator, testnet, mainnet
+ * @returns {Object} { "HelloWorld": "0x123" }
+ */
+export const getContracts = (jsons, network) => {
+  return pipe(
+    mergeFlowJSONs,
+    filterContracts,
+    mapContractToNetworkAddress(network)
+  )(jsons)
+}
+
+/**
+ * Checks flow.json file for private keys
+ * @param {Object} flowJSON
+ * @returns {boolean}
+ */
+const hasPrivateKeys = flowJSON => {
+  return Object.entries(flowJSON?.accounts).reduce((hasPrivateKey, [key, value]) => {
+    if (hasPrivateKey) return true
+    return value?.hasOwnProperty("key")
+  }, false)
+}
+
+/**
+ * Take in flow.json or array of flow.json files and checks for private keys
+ * @param {Object|Object[]} value
+ * @returns {boolean}
+ */
+export const anyHasPrivateKeys = value => {
+  if (isObject(value)) return hasPrivateKeys(value)
+  return value.some(hasPrivateKeys)
+}
+
+/**
+ * Format network to always be 'emulator', 'testnet', or 'mainnet'
+ * @param {string} network 'local', 'emulator', 'testnet', 'mainnet'
+ * @returns {string} 'emulator', 'testnet', 'mainnet'
+ */
+export const cleanNetwork = network => network?.toLowerCase() === 'local' ? 'emulator' : network?.toLowerCase()
\ No newline at end of file


Test patch:
diff --git a/packages/config/utils/utils.test.js b/packages/config/utils/utils.test.js
new file mode 100644
index 00000000..4e422891
--- /dev/null
+++ b/packages/config/utils/utils.test.js
@@ -0,0 +1,121 @@
+import {getContracts, anyHasPrivateKeys} from "./utils"
+
+describe("getContracts", () => {
+  test("it should gather contract aliases for flow.json", () => {
+    const flowJSON = {
+      "networks": {
+        "emulator": "127.0.0.1:3569",
+        "mainnet": "access.mainnet.nodes.onflow.org:9000",
+        "testnet": "access.devnet.nodes.onflow.org:9000"
+      },
+      "contracts": {
+        "HelloWorld": {
+          "source": "./cadence/contracts/HelloWorld.cdc",
+          "aliases": {
+            "emulator": "0x123",
+            "testnet": "0x124",
+            "mainnet": "0x125"
+          }
+        },
+        "FooBar": {
+          "source": "./cadence/contracts/FooBar.cdc",
+          "aliases": {
+            "emulator": "0x223",
+            "testnet": "0x224",
+            "mainnet": "0x225"
+          }
+        }
+      },
+      "accounts": {
+        "emulator-account": {
+          "address": "f8d6e0586b0a20c7",
+          "key": "ba68d45a5acaa52f3cacf4ad3a64d9523e0ce0ae3addb1ee6805385b380b7646"
+        }
+      },
+      "deployments": {
+        "emulator": {
+          "emulator-account": ["HelloWorld"]
+        }
+      }
+    }
+
+    const emulatorMappings = {
+      "HelloWorld": "0x123",
+      "FooBar": "0x223"
+    }
+
+    const testnetMappings = {
+      "HelloWorld": "0x124",
+      "FooBar": "0x224"
+    }
+
+    const mainnetMappings = {
+      "HelloWorld": "0x125",
+      "FooBar": "0x225"
+    }
+
+    expect(getContracts(flowJSON, "emulator")).toEqual(emulatorMappings)
+    expect(getContracts(flowJSON, "testnet")).toEqual(testnetMappings)
+    expect(getContracts(flowJSON, "mainnet")).toEqual(mainnetMappings)
+
+    // Also takes array
+    expect(getContracts([flowJSON], "emulator")).toEqual(emulatorMappings)
+    expect(getContracts([flowJSON], "testnet")).toEqual(testnetMappings)
+    expect(getContracts([flowJSON], "mainnet")).toEqual(mainnetMappings)
+  })
+})
+
+describe("anyHasPrivateKeys", () => {
+  test("it should return true if private keys exist in account", () => {
+    const flowJSON = {
+      "accounts": {
+        "emulator-account": {
+          "address": "f8d6e0586b0a20c7",
+          "key": "ba68d45a5acaa52f3cacf4ad3a64d9523e0ce0ae3addb1ee6805385b380b7646"
+        }
+      }
+    }
+
+    const flowJSONTwo = {
+      "accounts": {
+        "testnet-account": {
+          "address": "f8d6e0586b0a20c7",
+          "key": "ba68d45a5acaa52f3cacf4ad3a64d9523e0ce0ae3addb1ee6805385b380b7646"
+        }
+      }
+    }
+
+    const flowJSONThree = {
+      "accounts": {
+        "emulator-account": {
+          "address": "f8d6e0586b0a20c7"
+        }
+      }
+    }
+
+    expect(anyHasPrivateKeys(flowJSON)).toBe(true)
+    expect(anyHasPrivateKeys([flowJSON, flowJSONTwo])).toBe(true)
+    expect(anyHasPrivateKeys([flowJSON, flowJSONTwo, flowJSONThree])).toBe(true)
+  })
+
+  test("it should return false if no private keys exist in account", () => {
+    const flowJSON = {
+      "accounts": {
+        "emulator-account": {
+          "address": "f8d6e0586b0a20c7"
+        }
+      }
+    }
+
+    const flowJSONTwo = {
+      "accounts": {
+        "emulator-account": {
+          "address": "f8d6e0586b0a20c7"
+        }
+      }
+    }
+
+    expect(anyHasPrivateKeys(flowJSON)).toBe(false)
+    expect(anyHasPrivateKeys([flowJSON, flowJSONTwo])).toBe(false)
+  })
+})
\ No newline at end of file