Repository: shazow/whatsabi
Pull request #54 (2023-09-11 22:23:54)

Detect interfaces
Re: #21 

## Usage

```ts
const address = "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d";
const result = await whatsabi.autoload(address, { provider });
const detectedInterfaces = whatsabi.interfaces.abiToInterfaces(result.abi);
console.log(detectedInterfaces);
// [ 'ERC-165', 'ERC-721', 'Ownable' ]
```


Patch:
diff --git a/Makefile b/Makefile
index e8efcac..3233709 100644
--- a/Makefile
+++ b/Makefile
@@ -38,4 +38,11 @@ run-examples:
 	./examples/dot.ts $(ADDRESS)
 	./examples/resolveproxy.ts $(ADDRESS)
 
+./src/_generated-interfaces.ts: ./examples/index-interfaces.ts
+	# Has to be done in two steps because of circular import
+	$< > $@_
+	mv $@_ $@
+
+generated: ./src/_generated-interfaces.ts
+
 .PHONY: docs
diff --git a/examples/autoload.ts b/examples/autoload.ts
index 4a1a4bd..f97e5bb 100755
--- a/examples/autoload.ts
+++ b/examples/autoload.ts
@@ -46,6 +46,8 @@ async function main() {
         const iface = new ethers.Interface(abi);
         console.log("autoload", iface.format());
         if (unresolved) console.log("unresolved", unresolved);
+        const detectedInterfaces = whatsabi.interfaces.selectorsToInterfaces(abi);
+        if (detectedInterfaces.length) console.log("detected interfaces:", detectedInterfaces);
 
         if (!r.followProxies) break;
 
diff --git a/examples/index-interfaces.ts b/examples/index-interfaces.ts
new file mode 100755
index 0000000..f350ee6
--- /dev/null
+++ b/examples/index-interfaces.ts
@@ -0,0 +1,88 @@
+#!/usr/bin/env -S tsx
+// Return an IndexedInterfaces object of known interfaces.
+// This is hard-coded for now, we can make it pull in from other sources later.
+
+import { whatsabi } from "../src/index.js";
+import type { KnownInterfaces } from "../src/whatsabi.js";
+
+const interfaces : KnownInterfaces = {
+    "ERC-20": [
+        "function totalSupply() view returns (uint256)",
+        "function balanceOf(address) view returns (uint256)",
+        "function transfer(address, uint256) returns (bool)",
+        "function allowance(address, address) returns (uint256)",
+        "function approve(address, uint256) returns (bool)",
+        "function transferFrom(address, address, uint256) returns (bool)",
+    ],
+    "ERC-165": [
+        "function supportsInterface(bytes4 interfaceId) view returns (bool)",
+    ],
+    "ERC-721": [
+        "function balanceOf(address owner) view returns (uint256 balance)",
+        "function ownerOf(uint256 tokenId) view returns (address owner)",
+        "function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data)",
+        "function safeTransferFrom(address from, address to, uint256 tokenId)",
+        "function transferFrom(address from, address to, uint256 tokenId)",
+        "function approve(address to, uint256 tokenId)",
+        "function setApprovalForAll(address operator, bool _approved)",
+        "function getApproved(uint256 tokenId) view returns (address operator)",
+        "function isApprovedForAll(address owner, address operator) view returns (bool)",
+    ],
+    "ERC-777": [
+        "function name() view returns (string memory)",
+        "function symbol() view returns (string memory)",
+        "function granularity() view returns (uint256)",
+        "function totalSupply() view returns (uint256)",
+        "function balanceOf(address owner) view returns (uint256)",
+        "function send(address recipient, uint256 amount, bytes calldata data)",
+        "function burn(uint256 amount, bytes calldata data)",
+        "function isOperatorFor(address operator, address tokenHolder) view returns (bool)",
+        "function authorizeOperator(address operator)",
+        "function revokeOperator(address operator)",
+        "function defaultOperators() view returns (address[] memory)",
+        "function operatorSend(address sender, address recipient, uint256 amount, bytes calldata data, bytes calldata operatorData)",
+        "function operatorBurn(address account, uint256 amount, bytes calldata data, bytes calldata operatorData)",
+    ],
+    "ERC-1155": [
+        "function balanceOf(address account, uint256 id) view returns (uint256)",
+        "function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids) view returns (uint256[] memory)",
+        "function setApprovalForAll(address operator, bool approved)",
+        "function isApprovedForAll(address account, address operator) view returns (bool)",
+        "function safeTransferFrom( address from, address to, uint256 id, uint256 amount, bytes calldata data)",
+        "function safeBatchTransferFrom( address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data)",
+    ],
+    "ERC-4626": [
+    ],
+    "Ownable": [
+        "function owner() view returns (address)",
+        "function renounceOwnership()",
+        "function transferOwnership(address)",
+    ],
+    "Multicall": [
+        "function multicall(bytes[]) returns (bytes[] memory)",
+    ],
+    "ISafe": [
+        "function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool success)",
+        "function checkSignatures(bytes32 dataHash, bytes data, bytes signatures) view",
+        "function domainSeparator() view returns (bytes32)",
+        "function nonce() view returns (uint256)",
+    ],
+    "EIP-1271": ["function isValidSignature(bytes32 _hash, bytes memory _signature) public view returns (bytes4 magicValue);"],
+};
+
+main().then().catch(err => {
+    console.error("Failed:", err)
+    process.exit(2);
+})
+
+async function main() {
+    const r = whatsabi.interfaces.createInterfaceIndex(interfaces);
+    // Print typescript structure
+    console.log("// Generated using examples/index-interfaces.ts");
+    console.log("export default {");
+    for (const [key, setValue] of Object.entries(r)) {
+        if (setValue.size === 0) { continue; }
+        console.log(`  "${key}": new Set([${Array.from(setValue).map(v => `"${v}"`).join(", ")}]),`);
+    }
+    console.log("};");
+};
diff --git a/src/_generated-interfaces.ts b/src/_generated-interfaces.ts
new file mode 100644
index 0000000..ed36f96
--- /dev/null
+++ b/src/_generated-interfaces.ts
@@ -0,0 +1,12 @@
+// Generated using examples/index-interfaces.ts
+export default {
+  "ERC-20": new Set(["18160ddd", "70a08231", "a9059cbb", "dd62ed3e", "095ea7b3", "23b872dd"]),
+  "ERC-165": new Set(["01ffc9a7"]),
+  "ERC-721": new Set(["70a08231", "6352211e", "b88d4fde", "42842e0e", "23b872dd", "095ea7b3", "a22cb465", "081812fc", "e985e9c5"]),
+  "ERC-777": new Set(["06fdde03", "95d89b41", "556f0dc7", "18160ddd", "70a08231", "9bd9bbc6", "fe9d9303", "d95b6371", "959b8c3f", "fad8b32a", "06e48538", "62ad1b83", "fc673c4f"]),
+  "ERC-1155": new Set(["00fdd58e", "4e1273f4", "a22cb465", "e985e9c5", "996cd54f", "680fd525"]),
+  "Ownable": new Set(["8da5cb5b", "715018a6", "f2fde38b"]),
+  "Multicall": new Set(["ac9650d8"]),
+  "ISafe": new Set(["6a761202", "934f3a11", "f698da25", "affed0e0"]),
+  "EIP-1271": new Set(["1626ba7e"]),
+};
diff --git a/src/interfaces.ts b/src/interfaces.ts
new file mode 100644
index 0000000..ed0d431
--- /dev/null
+++ b/src/interfaces.ts
@@ -0,0 +1,79 @@
+import * as AbiFunction from 'ox/AbiFunction';
+
+import defaultKnownInterfaces from './_generated-interfaces.js';
+
+export { defaultKnownInterfaces };
+
+/// KnownInterfaces is a mapping from interface names to lists of function signatures that belong to that interface.
+export type KnownInterfaces = Record<string, Array<string>>;
+
+/// IndexedInterfaces is an optimized mapping of interface names to sorted selectors.
+// NOTE: The definition of this type may change to improve efficiency, please use helpers like createInterfaceIndex to produce it.
+export type IndexedInterfaces = Record<string, Set<string>>;
+
+/** Given a lookup of known interfaces, produce a lookup index to use with selectorsToInterfaces.
+ * @example
+ * ```ts
+ * const myInterfaceIndex = whatsabi.interfaces.createInterfaceIndex(
+ *   Object.assign({},
+ *     // Include defaults?
+ *     whatsabi.interfaces.defaultKnownInterfaces,
+ *     // Our special secret interface we want to detect
+ *     {
+ *       "MyInterface": [ "function Foo() returns (uint256)", "function Bar()" ],
+ *     },
+ *   ),
+ * );
+ * 
+ * const detectedInterfaces = whatsabi.interfaces.abiToInterfaces(abi, myInterfaceIndex);
+ * ```
+ */
+export function createInterfaceIndex(known: KnownInterfaces): IndexedInterfaces {
+    const r : IndexedInterfaces = {};
+    for (const [name, signatures] of Object.entries(known)) {
+        const selectors = signatures.map(sig => AbiFunction.getSelector(sig).slice(2));
+        if (selectors.length === 0) continue;
+        r[name] = new Set(selectors)
+    }
+    return r;
+}
+
+/** Given a list of selectors, return a mapping of interfaces it implements to a list of present function signatures that belong to it.
+ * @param {string[]} abiOrSelectors - ABI or a list of selectors or signatures to match against.
+ * @param {KnownInterfaces?} knownInterfaces - A mapping of known interfaces to function signatures that belong to them. Use {@link createInterfaceIndex} to produce your own, or omit to use a default collection.
+ * @returns {string[]} A list of interfaces that the given selectors implement.
+ * @example
+ * ```ts
+ * const result = await whatsabi.autoload(address, { provider });
+ * const detectedInterfaces = whatsabi.interfaces.abiToInterfaces(result.abi);
+ * ```
+ */
+export function abiToInterfaces(abiOrSelectors: any[], knownInterfaces?: IndexedInterfaces): string[] {
+    const r: string[] = [];
+    if (abiOrSelectors.length === 0) return r;
+    if (!knownInterfaces) {
+        knownInterfaces = defaultKnownInterfaces;
+    }
+    const selectorSet = new Set(abiOrSelectors.map(s => {
+        if (s.length === 8) return s;
+        if (s.length === 10) return s.slice(2);
+        return AbiFunction.getSelector(s).slice(2);
+    }));
+    for (const [name, interfaceSet] of Object.entries(knownInterfaces)) {
+        // Find interfaces where we have all the selectors.
+        if (isSupersetOf(selectorSet, interfaceSet)) {
+            r.push(name);
+        }
+    }
+    return r;
+}
+
+// TODO: Replace this with native Set.isSupersetOf once available
+function isSupersetOf<T>(a: Set<T>, b: Set<T>): boolean {
+    for (const elem of b) {
+        if (!a.has(elem)) {
+            return false;
+        }
+    }
+    return true;
+}
diff --git a/src/selectors.ts b/src/selectors.ts
index 23d4553..e5dd4f2 100644
--- a/src/selectors.ts
+++ b/src/selectors.ts
@@ -3,7 +3,7 @@ import * as AbiFunction from 'ox/AbiFunction';
 
 import { disasm } from "./disasm.js";
 
-// Load function selectors mapping from ABI, parsed using ethers.js
+// Load function selectors mapping from ABI, parsed using oxlib
 // Mapping is selector hash to signature
 export function selectorsFromABI(abi: any[]): {[key: string]: string} {
     const r: {[key: string]: string} = {};
diff --git a/src/whatsabi.ts b/src/whatsabi.ts
index 04ec134..3930035 100644
--- a/src/whatsabi.ts
+++ b/src/whatsabi.ts
@@ -31,3 +31,10 @@ export { abi };
 
 import * as errors from "./errors.js";
 export { errors };
+
+import * as interfaces from "./interfaces.js";
+export { interfaces };
+export type {
+    KnownInterfaces,
+    IndexedInterfaces,
+} from "./interfaces.js";


Test patch:
diff --git a/src/__tests__/interfaces.test.ts b/src/__tests__/interfaces.test.ts
new file mode 100644
index 0000000..db8f540
--- /dev/null
+++ b/src/__tests__/interfaces.test.ts
@@ -0,0 +1,62 @@
+import { expect, test } from 'vitest';
+
+import { abiToInterfaces, createInterfaceIndex } from '../interfaces';
+
+
+test('createInterfaceIndex', async ({ }) => {
+    const known = {
+        "ERC-20": [
+            "function totalSupply() view returns (uint256)",
+            "function balanceOf(address) view returns (uint256)",
+            "function transfer(address, uint256) returns (bool)",
+            "function allowance(address, address) returns (uint256)",
+            "function approve(address, uint256) returns (bool)",
+            "function transferFrom(address, address, uint256) returns (bool)",
+        ],
+    };
+
+    const got = createInterfaceIndex(known);
+    expect(got).toEqual({
+        "ERC-20": new Set([
+            "18160ddd",
+            "70a08231",
+            "a9059cbb",
+            "dd62ed3e",
+            "095ea7b3",
+            "23b872dd",
+        ]),
+    });
+
+});
+
+test('abiToInterfaces', async ({ }) => {
+    // Given a set of interfaces, get the interfaces that it implements
+    const selectors = [
+        // bunch of stuff
+        "0x02751cec", "0x054d50d4", "0x18cbafe5", "0x1f00ca74", "0x2195995c", "0x38ed1739", "0x4a25d94a", "0x5b0d5984", "0x5c11d795", "0x791ac947", "0x7ff36ab5", "0x85f8c259", "0x8803dbee", "0xad5c4648", "0xad615dec", "0xaf2979eb", "0xb6f9de95", "0xbaa2abde", "0xc45a0155", "0xd06ca61f", "0xded9382a", "0xe8e33700", "0xf305d719", "0xfb3bdb41",
+
+        // erc20
+        "0x18160ddd", "0x70a08231", "0xa9059cbb", "0xdd62ed3e", "0x095ea7b3", "0x23b872dd",
+
+        // erc165, no 0x prefix
+        "01ffc9a7"
+    ];
+
+    const got = abiToInterfaces(selectors);
+    expect(got).toEqual(["ERC-20", "ERC-165"]);
+});
+
+test('abiToInterfaces with signatures', async ({ }) => {
+    const sigs = [
+        "function totalSupply() view returns (uint256)",
+        "function balanceOf(address) view returns (uint256)",
+        "function transfer(address, uint256) returns (bool)",
+        "function allowance(address, address) returns (uint256)",
+        "function approve(address, uint256) returns (bool)",
+        "function transferFrom(address, address, uint256) returns (bool)",
+    ];
+
+    const got = abiToInterfaces(sigs);
+    expect(got).toEqual(["ERC-20"]);
+});
+