]> git.ardis.lu - solc-metadata/commitdiff
Initial commit
authorArdis Lu <ardisglu@gmail.com>
Sat, 29 Apr 2023 08:23:11 +0000 (01:23 -0700)
committerArdis Lu <ardisglu@gmail.com>
Sat, 29 Apr 2023 08:23:11 +0000 (01:23 -0700)
README.md [new file with mode: 0644]
index.html [new file with mode: 0644]
index.js [new file with mode: 0644]

diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..1356e89
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# solc-metadata
+
+This is the minimum JavaScript required to fetch, extract, decode, and use the [metadata](https://docs.soliditylang.org/en/latest/metadata.html) that the Solidity compiler ([solc](https://github.com/ethereum/solidity/releases)) appends to the end of a smart contract's bytecode.
+
+## Fetch
+
+By default, the Solidity compiler appends metadata to the end of a smart contract's runtime bytecode. A smart contract's runtime bytecode may be fetched from any Ethereum node using the standard JSON-RPC method [`eth_getCode`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getcode).
+
+Note that the runtime bytecode is different from the *creation* bytecode. The creation bytecode is the code that's sent to the transaction that creates the smart contract. The creation bytecode includes extra operations required to deploy the runtime bytecode (i.e. the constructor logic and arguments). Consequently, the runtime bytecode will look different from the creation bytecode.
+
+## Extract
+
+After the runtime bytecode is fetched, the metadata may be extracted by reading the last ~53 or so bytes from the end of the runtime bytecode. The exact length may vary. See the [`solc` docs](https://docs.soliditylang.org/en/v0.8.19/metadata.html#encoding-of-the-metadata-hash-in-the-bytecode) for details about how this metadata is encoded and how exactly to extract the metadata.
+
+## Decode
+
+The metadata in the runtime bytecode is [CBOR (Concise Binary Object Representation)](https://www.rfc-editor.org/rfc/rfc8949.html) encoded. A minimal and limited CBOR decoder is implemented to convert the CBOR into JSON.
+
+## Use
+
+The `ipfs` value in the decoded metadata is not immediately usable. It's a [multihash](https://github.com/multiformats/multihash) of the `sha256` hash of the full JSON metadata file. The multihash must be converted into a [CID](https://docs.ipfs.tech/concepts/content-addressing/) to be usable. Minimal transformation functions are implemented to convert the multihash into both CIDv0 and CIDv1.
+
+Once the CID is calculated, you may finally use the CID to retrieve the full JSON metadata from IPFS (assuming someone has pinned the JSON file in IPFS).
diff --git a/index.html b/index.html
new file mode 100644 (file)
index 0000000..a6393fb
--- /dev/null
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <link rel="icon" href="data:,">
+  <title>solc-metadata</title>
+  <style>
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
+    }
+
+    header {
+      display: flex;
+      justify-content: center;
+    }
+
+    main {
+      inline-size: min(720px, 100% - 16px);
+      margin-inline: auto;
+    }
+
+    form {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+    }
+
+    form * {
+      inline-size: fit-content;
+    }
+  </style>
+</head>
+
+<body>
+  <header>
+    <h1>solc-metadata</h1>
+  </header>
+
+  <main>
+    <form>
+      <label for="address">Smart contract address</label>
+      <input name="address" id="address" value="0x63c1735a6b7419ae4c7b87c486ac7c393d983980" required data-1p-ignore data-lpignore>
+
+      <label for="rpc">JSON-RPC node</label>
+      <input type="url" name="rpc" id="rpc" required value="https://eth.ardis.lu/v1/sepolia">
+
+      <label for="ipfs">IPFS gateway</label>
+      <input type="url" name="ipfs" id="ipfs" required value="https://cf-ipfs.com">
+
+      <button>Submit</button>
+
+      <label for="result">Full JSON metadata</label>
+      <output name="result" id="result"></output>
+    </form>
+  </main>
+
+  <script type="module">
+    import { fetchBytecode, extractCBOR, decodeCBOR, calculateCID, fetchJSONMetadata } from '/index.js';
+
+    document.querySelector('form').addEventListener('submit', async e => {
+      e.preventDefault();
+
+      const data = new FormData(e.target);
+      const address = data.get('address');
+      const rpc = new URL(data.get('rpc'));
+      const ipfs = new URL(data.get('ipfs'));
+      const result = e.target.elements['result'];
+
+      const bytecode = await fetchBytecode(address, rpc);
+      const cbor = extractCBOR(bytecode);
+      const metadata = decodeCBOR(cbor);
+      const { cidV0, cidV1 } = calculateCID(metadata);
+      const json = await fetchJSONMetadata(cidV1, ipfs);
+
+      result.textContent = JSON.stringify(json);
+    })
+  </script>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/index.js b/index.js
new file mode 100644 (file)
index 0000000..db53655
--- /dev/null
+++ b/index.js
@@ -0,0 +1,173 @@
+async function fetchBytecode(address, rpc) {
+  // Will work on all blockchains that support the standard Ethereum JSON-RPC method 'eth_getCode'
+  return fetch(rpc, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      jsonrpc: '2.0',
+      id: '1',
+      method: 'eth_getCode',
+      params: [
+        address,
+        'latest'
+      ]
+    })
+  })
+    .then(r => r.json())
+    .then(obj => obj.result);
+}
+
+function extractCBOR(bytecode) {
+  // The length of the CBOR will always be stored as the last two bytes of the runtime bytecode
+  const cborLen = parseInt(bytecode.slice(-4), 16);
+
+  // Derive the CBOR by counting backwards from the end (minus the last two bytes)
+  const cbor = bytecode.substring(bytecode.length - 4 - cborLen * 2, bytecode.length - 4);
+
+  // Convert to a byte array for easier use in subsequent processing
+  const byteArray = cbor.match(/.{2}/g).map(v => parseInt(v, 16));
+
+  return byteArray;
+}
+
+function decodeCBOR(cbor) {
+  const decoder = new TextDecoder();
+
+  // Assume the first byte initializes a map structure. The size is not used because it's not required for objects in JavaScript.
+  cbor.shift();
+  const output = {};
+
+  let isKey = true; // Alternate between key and property after each structure is processed
+  let lastStructure = null; // To set the property for the last key that was set
+  while (cbor.length > 0) {
+    const byte = cbor.shift();
+
+    // IMPORTANT: NOT ALL TYPES ARE REPRESENTED IN THIS JUMP TABLE
+    // Assumes that solc will only use the below types in the bytecode metadata
+    let structure;
+    if (byte >= 0x40 && byte <= 0x57) { // bytes
+      const len = byte - 0x40;
+      const bytes = cbor.slice(0, len);
+      cbor = cbor.slice(len);
+      structure = new Uint8Array(bytes);
+    }
+    else if (byte === 0x58) { // bytes_1n
+      const len = cbor.shift();
+      const bytes = cbor.slice(0, len);
+      cbor = cbor.slice(len);
+      structure = new Uint8Array(bytes);
+    }
+    else if (byte >= 0x60 && byte <= 0x77) { // UTF-8
+      const len = byte - 0x60;
+      const bytes = cbor.slice(0, len);
+      cbor = cbor.slice(len);
+      structure = decoder.decode(new Uint8Array(bytes));
+    }
+
+    if (isKey) {
+      output[structure] = null;
+      lastStructure = structuredClone(structure);
+    }
+    else {
+      output[lastStructure] = structure;
+    }
+    isKey = !isKey;
+  }
+
+  // The output should look like this:
+  // {
+  //   ipfs: <multihash>,
+  //   solc: <version>
+  // }
+  return output;
+}
+
+// The IPFS v0 CID is the base58btc-encoded multihash, where the multihash is of the sha256 hash of the dag-pb representation of the metadata
+function solcMultihashToCIDv0(byteArray) {
+  const output = [];
+
+  // Base58 conversion logic is copied from https://github.com/cryptocoinjs/base-x
+  const base = 58;
+  const size = 46; // v0 CIDs are always 46 characters
+  let length = 0;
+  let pointer = 0;
+  while (pointer < byteArray.length) {
+    let carry = byteArray[pointer];
+    let i = 0;
+    for (let it1 = size - 1; (carry !== 0 || i < length) && (it1 !== -1); it1--, i++) {
+      carry += (256 * output[it1]) >>> 0;
+      output[it1] = (carry % base) >>> 0;
+      carry = (carry / base) >>> 0;
+    }
+    length = i;
+    pointer++;
+  }
+
+  // The base58btc alphabet is from the Bitcoin client source code: https://github.com/bitcoin/bitcoin/blob/master/src/base58.cpp
+  const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+  return output.map(b => alphabet.charAt(b)).join('');
+}
+
+// The IPFS v1 CID is a self-describing format that may encode many combinations of hash and content
+function solcMultihashToCIDv1(byteArray) {
+  // The CIDv1 format includes multiple parts so the format can be flexible and self-describing for many combinations
+  // of encoding, version, hash function, length, etc. However, all these parts are already chosen by solc and then
+  // hardcoded into an immutable smart contract. So these parts will be the same for pretty much every smart contract
+  // metadata file. For efficiency and to save a lot of boilerplate code, the prefix and initial parts are hardcoded
+  // to set the values as follows:
+  // <multibase-prefix>: b (base32)
+  // <multicodec-cidv1>: 0x01 (CID v1)
+  // <multicodec-content-type>: 0x70 (dag-pb)
+  // Reference: https://github.com/multiformats/cid
+  const prefix = 'b';
+  byteArray = [0x01, 0x70, ...byteArray];
+
+  // Encoding logic is copied from https://github.com/multiformats/js-multiformats/
+  const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; // RFC4648 base32
+  const bitsPerChar = 5;
+  const mask = (1 << bitsPerChar) - 1;
+  let out = '';
+  let bits = 0;
+  let buffer = 0;
+  for (let i = 0; i < byteArray.length; ++i) {
+    buffer = buffer << 8 | byteArray[i];
+    bits += 8;
+    while (bits > bitsPerChar) {
+      bits -= bitsPerChar;
+      out += alphabet[mask & buffer >> bits];
+    }
+  }
+  if (bits) {
+    out += alphabet[mask & buffer << bitsPerChar - bits];
+  }
+
+  return `${prefix}${out}`;
+}
+
+function calculateCID(metadata) {
+  const { ipfs, solc } = metadata;
+
+  const solcVersion = `${solc[0]}.${solc[1]}.${solc[2]}`;
+  const cidV0 = solcMultihashToCIDv0(ipfs);
+  const cidV1 = solcMultihashToCIDv1(ipfs)
+
+  return { solcVersion, cidV0, cidV1 };
+}
+
+function fetchJSONMetadata(cid, ipfs) {
+  let response;
+  if (cid[0] === 'Q') { // v0
+    response = fetch(`${ipfs.origin}/ipfs/${cid}${ipfs.search}`);
+  }
+  else { // v1
+    if (ipfs.host.split('.')[0] === 'ipfs') {
+      response = fetch(`${ipfs.protocol}//${cid}.${ipfs.host}${ipfs.pathname}${ipfs.search}`);
+    }
+    else {
+      response = fetch(`${ipfs.protocol}//${cid}.ipfs.${ipfs.host}${ipfs.pathname}${ipfs.search}`);
+    }
+  }
+  return response.then(r => r.json());
+}
+
+export { fetchBytecode, extractCBOR, decodeCBOR, calculateCID, fetchJSONMetadata };
This page took 0.038678 seconds and 4 git commands to generate.