From 3afce8292285ca913ce9bb51d255094a6e94e33a Mon Sep 17 00:00:00 2001 From: Ashutosh Kumar Date: Sun, 12 May 2024 05:59:06 +0530 Subject: [PATCH] WIP - Arbitrary Contract Interactions --- .gitignore | 3 +- components/aci/index.js | 24 +++ components/daos/index.js | 1 - routes/aci.js | 42 +++++ routes/aci.test.js | 43 +++++ server.js | 1 + services/aci.service.js | 388 ++++++++++++++++++++++++++++++++++++++ services/response.util.js | 2 + 8 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 components/aci/index.js create mode 100644 routes/aci.js create mode 100644 routes/aci.test.js create mode 100644 services/aci.service.js diff --git a/.gitignore b/.gitignore index 3ca0e34..369b002 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ yarn-error.log* .vscode .idea -.cosine \ No newline at end of file +.cosine +yarn.lock \ No newline at end of file diff --git a/components/aci/index.js b/components/aci/index.js new file mode 100644 index 0000000..ff284c0 --- /dev/null +++ b/components/aci/index.js @@ -0,0 +1,24 @@ +const { catchAsync } = require("../../services/response.util"); +const { getContractEndpoints } = require("../../services/aci.service"); + +const getContractEndpointsController = catchAsync(async(req, response) => { + const {network} = req.body; + const contractId = req.params.contract_id; + if(!contractId) throw new Error("Contract ID is required"); + + let [endpoints, error] = await getContractEndpoints(network, contractId) + if(error) throw new Error(error) + + if(endpoints){ + endpoints = { + ...endpoints, + operations: endpoints.children.map(x => x.name) + } + } + + return response.json(endpoints); +}) + +module.exports = { + getContractEndpointsController +} \ No newline at end of file diff --git a/components/daos/index.js b/components/daos/index.js index 9a5bcb7..05683e6 100644 --- a/components/daos/index.js +++ b/components/daos/index.js @@ -9,7 +9,6 @@ const { } = require("../../utils"); const dbo = require("../../db/conn"); -const { response } = require("express"); const { getPkhfromPk } = require("@taquito/utils"); const getAllLiteOnlyDAOs = async (req, response) => { diff --git a/routes/aci.js b/routes/aci.js new file mode 100644 index 0000000..9aba1a3 --- /dev/null +++ b/routes/aci.js @@ -0,0 +1,42 @@ +const express = require("express"); +const { getContractEndpointsController } = require("../components/aci"); + +/** + * TEST Contracts + * + * KT1MzN5jLkbbq9P6WEFmTffUrYtK8niZavzH + * KT1VG3ynsnyxFGzw9mdBwYnyZAF8HZvqnNkw + * + */ + +const aciRoutes = express.Router(); + +/** + * @swagger + * /aci/{contract_id}: + * post: + * summary: Get contract endpoints + * tags: [ACI] + * parameters: + * - in: path + * name: contract_id + * required: true + * description: The ID of the contract + * schema: + * type: string + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * network: + * type: string + * responses: + * 200: + * description: Contract ACI Endpoints + */ +aciRoutes.post('/aci/:contract_id', getContractEndpointsController) + +module.exports = aciRoutes; \ No newline at end of file diff --git a/routes/aci.test.js b/routes/aci.test.js new file mode 100644 index 0000000..8db4207 --- /dev/null +++ b/routes/aci.test.js @@ -0,0 +1,43 @@ +const request = require("supertest"); +const express = require("express"); +const aciRoutes = require("./aci"); + +const app = express(); +app.use(express.json()); +app.use("/", aciRoutes); + +const contractIds = [ + "KT1MzN5jLkbbq9P6WEFmTffUrYtK8niZavzH", + "KT1VG3ynsnyxFGzw9mdBwYnyZAF8HZvqnNkw", + "I_AM_INVALID_ID" +] + +describe("ACI Routes", () => { + it("should return bad status on invalid address ", async () => { + await request(app) + .post(`/aci/${contractIds[2]}`) + .expect(400) + .expect("Content-Type", /json/) + }); + it("should return valid status on valid address ", async () => { + await request(app) + .post(`/aci/${contractIds[0]}`) + .send({network:"ghostnet"}) + .expect(200) + .expect("Content-Type", /json/) + }); + it("should return valid JSON on valid address", async () => { + const res = await request(app) + .post(`/aci/${contractIds[0]}`) + .send({network:"ghostnet"}) + .expect(200) + .expect("Content-Type", /json/) + + expect(res.body).toHaveProperty("counter"); + expect(res.body).toHaveProperty("name"); + expect(res.body).toHaveProperty("type"); + expect(res.body).toHaveProperty("children"); + expect(res.body).toHaveProperty("operations"); + }); + }); + \ No newline at end of file diff --git a/server.js b/server.js index 4570800..bda67eb 100644 --- a/server.js +++ b/server.js @@ -25,6 +25,7 @@ app.use(require("./routes/daos")); app.use(require("./routes/polls")); app.use(require("./routes/tokens")); app.use(require("./routes/choices")); +app.use(require("./routes/aci")); app.listen(port, async () => { // perform a database connection when server starts diff --git a/services/aci.service.js b/services/aci.service.js new file mode 100644 index 0000000..da3eabf --- /dev/null +++ b/services/aci.service.js @@ -0,0 +1,388 @@ +const axios = require("axios"); +const TezosToolkit = require('@taquito/taquito').TezosToolkit; +const { Schema } = require('@taquito/michelson-encoder'); + +const networkNameMap = { + mainnet: "mainnet", + ghostnet: "ghostnet", +}; + +const rpcNodes = { + mainnet: "https://mainnet.api.tez.ie", + ghostnet: "https://ghostnet.tezos.marigold.dev", +}; + +function getFieldName(id) { + return `input-${id.toString()}`; +} + +function initTokenTable( + init, + counter, + defaultInit = "" +) { + init[getFieldName(counter)] = defaultInit; +} + + +function parseSchema(counter, token, init, name) { + switch (token.__michelsonType) { + case "bls12_381_fr": + case "bls12_381_g1": + case "bls12_381_g2": + case "chain_id": + case "key_hash": + case "key": + case "bytes": + case "signature": + case "string": + initTokenTable(init, counter); + return [ + { + counter, + name, + type: token.__michelsonType, + children: [], + placeholder: token.__michelsonType, + initValue: "", + }, + counter, + ]; + case "address": + initTokenTable(init, counter); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + placeholder: token.__michelsonType, + validate(value) { + if (validateAddress(value) !== 3) { + return `invalid address ${value}`; + } + }, + initValue: "", + }, + counter, + ]; + case "contract": + initTokenTable(init, counter); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + placeholder: "contract", + validate(value) { + if (validateAddress(value) !== 3) { + return `invalid address ${value}`; + } + }, + initValue: "", + }, + counter, + ]; + case "bool": + initTokenTable(init, counter, false); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + placeholder: token.__michelsonType, + initValue: false, + }, + counter, + ]; + case "int": + case "nat": + initTokenTable(init, counter); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + placeholder: token.__michelsonType, + validate(value) { + if (value && isNaN(Number(value))) { + return `Invalid number, got: ${value}`; + } + }, + initValue: "", + }, + counter, + ]; + case "mutez": + case "timestamp": + initTokenTable(init, counter); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + placeholder: token.__michelsonType, + validate(value) { + const n = Number(value); + if (isNaN(n)) { + return `Invalid number, got: ${value}`; + } + if (n < 0) { + return `Number should be greater or equal to 0, got ${value}`; + } + }, + initValue: "", + }, + counter, + ]; + case "never": + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + initValue: "", + }, + counter, + ]; + case "operation": + throw new Error("can't happen: operation is forbidden in the parameter"); + case "chest": + case "chest_key": + throw new Error( + "can't happen(Tezos bug): time lock related instructions is disabled in the client because of a vulnerability" + ); + case "unit": + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + initValue: "", + }, + counter, + ]; + case "tx_rollup_l2_address": + throw new Error("can't happen: this type has been disable"); + case "or": { + const schemas = Object.entries(token.schema); + let new_counter = counter; + const children = []; + let child + schemas.forEach(([k, v]) => { + [child, new_counter] = parseSchema(new_counter + 1, v, init, k); + children.push(child); + }); + initTokenTable(init, counter, schemas[0][0]); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children, + initValue: schemas[0][0], + }, + new_counter, + ]; + } + case "set": + case "list": { + initTokenTable(init, counter, []); + const [child, new_counter] = parseSchema(counter + 1, token.schema, init); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [child], + initValue: [], + }, + new_counter, + ]; + } + case "pair": { + const schemas = Object.entries(token.schema); + let new_counter = counter; + const children = []; + let child; + schemas.forEach(([k, v]) => { + [child, new_counter] = parseSchema(new_counter + 1, v, init, k); + children.push(child); + }); + initTokenTable(init, counter, []); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children, + initValue: [], + }, + new_counter, + ]; + } + case "map": + case "big_map": { + const schemas = Object.entries(token.schema); + let new_counter = counter; + const children = []; + let child; + schemas.forEach(([k, v]) => { + [child, new_counter] = parseSchema(new_counter + 1, v, init, k); + children.push(child); + }); + initTokenTable(init, counter, []); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children, + initValue: [], + }, + new_counter, + ]; + } + case "option": { + const [child, new_counter] = parseSchema(counter + 1, token.schema, init); + + initTokenTable(init, counter, "none"); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [child], + initValue: "none", + }, + new_counter, + ]; + } + case "constant": + throw new Error("can't happen: constant will never be in parameter"); + case "lambda": + initTokenTable(init, counter); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + placeholder: "lambda", + children: [], + initValue: "", + }, + counter, + ]; + case "sapling_transaction_deprecated": + case "sapling_transaction": + case "sapling_state": + initTokenTable(init, counter); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + placeholder: token.__michelsonType + " " + token.schema.memoSize, + children: [], + initValue: "", + }, + counter, + ]; + case "ticket_deprecated": + case "ticket": + initTokenTable(init, counter); + return [ + { + counter: counter, + name, + type: token.__michelsonType, + children: [], + initValue: "", + }, + counter, + ]; + default: + return assertNever(token); + } +} + +async function parseContractScript(c, initTokenTable = {}) { + let token, counter = 0; + const entryponts = Object.entries(c.entrypoints.entrypoints).reverse(); + if (entryponts.length == 0) { + console.log('Case 1') + // handle the case of only "default" entrypoint + [token, counter] = parseSchema( + 0, + c.parameterSchema.generateSchema(), + initTokenTable, + "entrypoint" + ); + console.log('Token:', token) + } else { + console.log('Case 2') + // handle the case of multiple entrypoints + const childrenToken = []; + let childToken; + let init; + let setInit = false; + for (let i = 0; i < entryponts.length; i++) { + const [entrypoint, type] = entryponts[i]; + const schema = new Schema(type).generateSchema(); + if (schema.__michelsonType !== "or") { + + if (!setInit) { + init = entrypoint; + setInit = true; + } + let new_counter; + [childToken, new_counter] = parseSchema( + counter, + schema, + initTokenTable, + entrypoint + ); + counter = new_counter + 1; + childrenToken.push(childToken); + } + } + counter = counter + 1; + if (typeof init === "undefined") + throw new Error("internal error: initial entrypoint is undefined"); + token = { + counter, + name: "entrypoint", + type: "or", + children: childrenToken, + initValue: init, + }; + initTokenTable[getFieldName(token.counter)] = token.initValue; + } + initTokenTable["counter"] = counter; + return token; +} + +async function getContractEndpoints(network, contractAddress) { + try { + const tezosNetwork = network === 'ghostnet' ? 'ghostnet' : 'mainnet' + const tezos = new TezosToolkit(rpcNodes[tezosNetwork]); + const contract = await tezos.contract.at(contractAddress); + const endpoints = await parseContractScript(contract); + console.log('Endpoints:', endpoints) + return [endpoints, null]; + } catch (error) { + console.error('Error fetching contract:', error); + return [null, error] + } +} + + +module.exports = { + getContractEndpoints +} \ No newline at end of file diff --git a/services/response.util.js b/services/response.util.js index 3f1b970..0d2129c 100644 --- a/services/response.util.js +++ b/services/response.util.js @@ -17,6 +17,8 @@ const catchAsync = (fn) => (req, res, next) => { let responseStatusCode = 500; if (err.statusCode) responseStatusCode = err.statusCode + if(errMessage.includes("InvalidContractAddressError")) responseStatusCode = 400 + try { errMessage = JSON.parse(errMessage) errMessage = errMessage.map(ex => ex.message).join(",")