ichaingov-contract-generator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/cli.js +182 -0
- package/core/encodeParameter.js +10 -0
- package/core/loadConfig.js +21 -0
- package/core/validateConfig.js +83 -0
- package/index/base-contracts/GatewayRegistry.sol +346 -0
- package/index/base-contracts/Governance.sol +152 -0
- package/index/base-contracts/GovernanceWithTimelock.sol +198 -0
- package/index/base-contracts/PolicyRegistry.sol +112 -0
- package/index/base-contracts/Timelock.sol +19 -0
- package/index/base-contracts/Token.sol +49 -0
- package/index/generateDeploy.js +59 -0
- package/index/generator.js +37 -0
- package/index/mappers.js +19 -0
- package/index/templates/deploy.js.hbs +136 -0
- package/index/templates/template-config.json +63 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ana Ferreira
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ichaingov-contract-generator
|
|
2
|
+
|
|
3
|
+
DAO CLI — generates and deploys governance contracts for gateway-based interoperability systems from a single `config.json` file.
|
|
4
|
+
|
|
5
|
+
This tool is part of the Master's thesis **IChainGov: Governance Protocols in Blockchain Interoperability Mechanisms**.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
`ichaingov-contract-generator` automates the creation of a complete on-chain governance framework designed for gateway interoperability systems like those described in the Secure Asset Transfer Protocol (SATP). It produces all the necessary Solidity contracts, configuration, and deployment scripts, allowing a DAO to manage protocol parameters and gateway registration.
|
|
12
|
+
|
|
13
|
+
The generated contracts use and extend OpenZeppelin's contracts, it implements multiple voting systems and an optional timelock.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Generated Contracts
|
|
18
|
+
|
|
19
|
+
The CLI creates the following smart contracts:
|
|
20
|
+
|
|
21
|
+
| Contract | Description |
|
|
22
|
+
|----------|-------------|
|
|
23
|
+
| **Token** | ERC20 token with vote delegation (ERC20Votes). Used for governance voting power. |
|
|
24
|
+
| **GovernanceTL** | Main governor contract inheriting `Governor`, `GovernorSettings`, `GovernorCountingSimple`, `GovernorVotes`, `GovernorVotesQuorumFraction`, and `GovernorTimelockControl`. Supports three voting systems: TokenBased, Quadratic, and WeightedReputation. |
|
|
25
|
+
| **Timelock** | OpenZeppelin TimelockController that enforces a delay between proposal passing and execution. Optional — controlled by `config.json`. |
|
|
26
|
+
| **GatewayRegistry** | Ownable contract storing on-chain identities of organizations and their gateways. |
|
|
27
|
+
| **PolicyRegistry** | Simple key-value store for protocol parameters. Only the governor can modify it, ensuring all changes go through on-chain governance. |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
ichaingov-contract-generator
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If no `config.json` is present in the current directory, a template will be copied automatically. Edit it to match your DAO's requirements and run the command again.
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
All settings are read from a `config.json` file. Below is a complete example:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"name": "MyDAO",
|
|
46
|
+
"tokenomics": {
|
|
47
|
+
"name": "MyToken",
|
|
48
|
+
"symbol": "MTK",
|
|
49
|
+
"supply": 1000000,
|
|
50
|
+
"defaultMemberTokens": 1000
|
|
51
|
+
},
|
|
52
|
+
"organizations": [
|
|
53
|
+
{
|
|
54
|
+
"address": "0x...",
|
|
55
|
+
"name": "Org A",
|
|
56
|
+
"reputation": 0,
|
|
57
|
+
"gateways": ["0x..."]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"governance": {
|
|
61
|
+
"votingDelay": 1,
|
|
62
|
+
"votingPeriod": 5,
|
|
63
|
+
"proposalThreshold": 0,
|
|
64
|
+
"quorumFraction": 0,
|
|
65
|
+
"votingSystem": "token-based"
|
|
66
|
+
},
|
|
67
|
+
"timelock": {
|
|
68
|
+
"enabled": true,
|
|
69
|
+
"minDelay": 0
|
|
70
|
+
},
|
|
71
|
+
"protocolParameters": [
|
|
72
|
+
{ "key": "satp.version", "value": "v02" }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `votingSystem` accepts: `token-based`, `quadratic`, `weighted-reputation`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Important Notes
|
|
82
|
+
|
|
83
|
+
- **EVM target**: The generated Hardhat config uses `evmVersion: "cancun"`. This is required because OpenZeppelin v5.6+ uses the `mcopy` opcode. If you have an existing config, ensure the same EVM version is set.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Thesis & Academic Context
|
|
88
|
+
|
|
89
|
+
This CLI was developed as part of the Master's dissertation:
|
|
90
|
+
|
|
91
|
+
**IChainGov: Governance Protocols in Blockchain Interoperability Mechanisms**
|
|
92
|
+
*Ana Catarina Ferreira de Sá*
|
|
93
|
+
Instituto Superior Técnico, Universidade de Lisboa
|
|
94
|
+
2025
|
|
95
|
+
|
|
96
|
+
The thesis addresses governance challenges in gateway-based interoperability systems, proposing a decentralized governance framework for protocols like SATP. The smart contracts generated by this tool form the on-chain layer of that framework, while an external integration layer listens to DAO events to update gateway configurations automatically.
|
|
97
|
+
|
|
98
|
+
For more details on the architecture, voting systems, and the integration with SATP, refer to the full thesis document.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
+
import { validateConfig } from "../core/validateConfig.js";
|
|
5
|
+
import { generate } from "../index/generator.js";
|
|
6
|
+
import { generateDeploy } from "../index/generateDeploy.js";
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
import readline from "readline/promises";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
|
|
13
|
+
const rl = readline.createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stdout,
|
|
16
|
+
});
|
|
17
|
+
async function ask(q) {
|
|
18
|
+
return rl.question(q);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const shouldDeploy = args.includes("--deploy");
|
|
23
|
+
const network = args.find(a => a.startsWith("--network="))?.split("=")[1] ?? "localhost";
|
|
24
|
+
|
|
25
|
+
const defaultHardhatConfig = `
|
|
26
|
+
require("@nomicfoundation/hardhat-toolbox");
|
|
27
|
+
module.exports = {
|
|
28
|
+
solidity: {
|
|
29
|
+
version: "0.8.28",
|
|
30
|
+
settings: {
|
|
31
|
+
evmVersion: "cancun",
|
|
32
|
+
optimizer: { enabled: true, runs: 200 },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
networks: {
|
|
36
|
+
hardhat: {},
|
|
37
|
+
localhost: { url: "http://127.0.0.1:8545" },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
`.trim();
|
|
41
|
+
|
|
42
|
+
function isESMProject() {
|
|
43
|
+
const pkgPath = path.resolve(process.cwd(), "package.json");
|
|
44
|
+
if (fs.existsSync(pkgPath)) {
|
|
45
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
46
|
+
return pkg.type === "module";
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ensureDeployScript() {
|
|
52
|
+
if (!isESMProject()) return;
|
|
53
|
+
const js = path.resolve(process.cwd(), "scripts/deploy.js");
|
|
54
|
+
const cjs = path.resolve(process.cwd(), "scripts/deploy.cjs");
|
|
55
|
+
if (fs.existsSync(js)) {
|
|
56
|
+
const content = fs.readFileSync(js, "utf8");
|
|
57
|
+
if (content.includes("require(")) {
|
|
58
|
+
fs.writeFileSync(cjs, content);
|
|
59
|
+
fs.unlinkSync(js);
|
|
60
|
+
console.log("Renamed scripts/deploy.js → scripts/deploy.cjs (CommonJS required)");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getDeployScriptName() {
|
|
66
|
+
const cjs = path.resolve(process.cwd(), "scripts/deploy.cjs");
|
|
67
|
+
if (fs.existsSync(cjs)) return "scripts/deploy.cjs";
|
|
68
|
+
const js = path.resolve(process.cwd(), "scripts/deploy.js");
|
|
69
|
+
if (fs.existsSync(js)) return "scripts/deploy.js";
|
|
70
|
+
return "scripts/deploy.js";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
console.log("#################################");
|
|
75
|
+
console.log("# ichaingov:create-governance #");
|
|
76
|
+
console.log("#################################\n");
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
// ── Use template config if config.json is missing ──────────────────
|
|
80
|
+
const configFilePath = path.resolve(process.cwd(), "config.json");
|
|
81
|
+
if (!fs.existsSync(configFilePath)) {
|
|
82
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
83
|
+
const __dirname = path.dirname(__filename);
|
|
84
|
+
const templateConfigPath = path.resolve(__dirname, "..", "index/templates", "template-config.json");
|
|
85
|
+
if (!fs.existsSync(templateConfigPath)) {
|
|
86
|
+
console.error("Template config file not found. Please create a config.json manually.");
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
fs.copyFileSync(templateConfigPath, configFilePath);
|
|
90
|
+
console.log("No config.json found. A template has been created.");
|
|
91
|
+
console.log("You can edit config.json now, or continue with the defaults.\n");
|
|
92
|
+
|
|
93
|
+
const useTemplate = await ask("Proceed with the template values? (y/N) ");
|
|
94
|
+
if (useTemplate.trim().toLowerCase() !== "y" && useTemplate.trim().toLowerCase() !== "yes") {
|
|
95
|
+
console.log("Edit config.json and run again when ready.");
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
console.log("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let config;
|
|
102
|
+
try {
|
|
103
|
+
config = loadConfig();
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error("Could not load config.json:", err.message);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
validateConfig(config);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error("Invalid config.json:\n", err.message);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await generate(config);
|
|
117
|
+
await generateDeploy(config);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error("Generation failed:", err.message);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
ensureDeployScript();
|
|
124
|
+
|
|
125
|
+
if (shouldDeploy) {
|
|
126
|
+
const script = getDeployScriptName();
|
|
127
|
+
console.log(`\nDeploying to '${network}'...`);
|
|
128
|
+
try {
|
|
129
|
+
execSync(
|
|
130
|
+
`npx hardhat run ${script} --network ${network}`,
|
|
131
|
+
{ stdio: "inherit" }
|
|
132
|
+
);
|
|
133
|
+
} catch {
|
|
134
|
+
console.error("Deployment failed. Check Hardhat output above.");
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
rl.close();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log("\nGeneration complete. You can now deploy your contracts.");
|
|
142
|
+
const answer = (await ask("Deploy to local Hardhat network now? (y/N) ")).trim().toLowerCase();
|
|
143
|
+
|
|
144
|
+
if (answer === "y" || answer === "yes") {
|
|
145
|
+
const configPaths = [
|
|
146
|
+
path.resolve(process.cwd(), "hardhat.config.js"),
|
|
147
|
+
path.resolve(process.cwd(), "hardhat.config.ts"),
|
|
148
|
+
path.resolve(process.cwd(), "hardhat.config.cjs"),
|
|
149
|
+
];
|
|
150
|
+
const existing = configPaths.find(p => fs.existsSync(p));
|
|
151
|
+
if (existing) {
|
|
152
|
+
console.log("Warning: Hardhat config already exists – it will not be modified.");
|
|
153
|
+
console.log("If deployment fails due to the 'mcopy' opcode, ensure your config uses evmVersion: 'cancun'.");
|
|
154
|
+
} else {
|
|
155
|
+
fs.writeFileSync(path.resolve(process.cwd(), "hardhat.config.cjs"), defaultHardhatConfig);
|
|
156
|
+
console.log("Created hardhat.config.cjs (Cancun‑ready).");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const script = getDeployScriptName();
|
|
160
|
+
console.log(`Deploying '${config.name}' to Hardhat's built‑in network...`);
|
|
161
|
+
try {
|
|
162
|
+
execSync(
|
|
163
|
+
`npx hardhat run ${script} --network hardhat`,
|
|
164
|
+
{ stdio: "inherit" }
|
|
165
|
+
);
|
|
166
|
+
} catch {
|
|
167
|
+
console.error("Deployment failed. Check the output above and ensure all dependencies (npm install) are installed.");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
console.log("Skipping deployment. You can deploy later.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log("\nAvailable deployment commands:");
|
|
175
|
+
console.log(" npx create-governance --deploy (defaults to 'localhost')");
|
|
176
|
+
console.log(" npm run deploy (same as above)");
|
|
177
|
+
console.log("\nTo use a different network, edit hardhat.config.js and add your RPC URL.");
|
|
178
|
+
|
|
179
|
+
rl.close();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
main();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { keccak256, toUtf8Bytes } from "ethers";
|
|
2
|
+
|
|
3
|
+
export function encodeValue(value) {
|
|
4
|
+
if (typeof value === 'number' || (typeof value === 'string' && !isNaN(value) && value.trim() !== '')) {
|
|
5
|
+
return BigInt(value);
|
|
6
|
+
}
|
|
7
|
+
if (value === true || value === "true") return 1n;
|
|
8
|
+
if (value === false || value === "false") return 0n;
|
|
9
|
+
return BigInt(keccak256(toUtf8Bytes(value)));
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export function loadConfig(filePath = "config.json") {
|
|
5
|
+
const resolved = path.resolve(process.cwd(), filePath);
|
|
6
|
+
|
|
7
|
+
if (!fs.existsSync(resolved)) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`config.json not found at ${resolved}\n` +
|
|
10
|
+
"Create one based on config.example.json"
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const raw = fs.readFileSync(resolved, "utf-8");
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new Error(`config.json is not valid JSON: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
const GatewaySchema = z.object({
|
|
5
|
+
publicKey: z.string().regex(/^0x[a-fA-F0-9]{64,128}$/, "Invalid public key hex"),
|
|
6
|
+
name: z.string().min(1)
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const OrganizationSchema = z.object({
|
|
10
|
+
name: z.string().min(1),
|
|
11
|
+
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address"),
|
|
12
|
+
reputation: z.number().int().min(0).default(0),
|
|
13
|
+
gateways: z.array(GatewaySchema).optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const TokenomicsSchema = z.object({
|
|
17
|
+
treasury: z.boolean().optional().default(true),
|
|
18
|
+
name: z.string().min(1),
|
|
19
|
+
symbol: z.string().min(1).max(8),
|
|
20
|
+
supply: z.number().positive(),
|
|
21
|
+
defaultMemberTokens: z.number().positive().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const GovernanceSchema = z.object({
|
|
25
|
+
votingDelay: z.number().int().min(0),
|
|
26
|
+
votingPeriod: z.number().int().min(1),
|
|
27
|
+
proposalThreshold: z.number().int().min(0),
|
|
28
|
+
quorumFraction: z.number().int().min(0).max(100), // allow 0
|
|
29
|
+
votingSystem: z.enum(["token-based", "quadratic", "weighted-reputation"]),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const TimelockSchema = z.object({
|
|
33
|
+
enabled: z.boolean(),
|
|
34
|
+
minDelay: z.number().int().min(0),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ProtocolParameterSchema = z.object({
|
|
38
|
+
key: z.string().min(1),
|
|
39
|
+
value: z.union([z.string(), z.number(), z.boolean()]),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const ConfigSchema = z.object({
|
|
43
|
+
name: z.string().min(1),
|
|
44
|
+
tokenomics: TokenomicsSchema,
|
|
45
|
+
governance: GovernanceSchema,
|
|
46
|
+
timelock: TimelockSchema,
|
|
47
|
+
organizations: z.array(OrganizationSchema).min(1),
|
|
48
|
+
protocolParameters: z.array(ProtocolParameterSchema).optional().default([]),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export function validateConfig(config) {
|
|
52
|
+
const result = ConfigSchema.safeParse(config);
|
|
53
|
+
|
|
54
|
+
if (!result.success) {
|
|
55
|
+
const messages = result.error.errors
|
|
56
|
+
.map(e => ` • ${e.path.join(".")} — ${e.message}`)
|
|
57
|
+
.join("\n");
|
|
58
|
+
throw new Error(`Config validation failed:\n${messages}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { governance, tokenomics, organizations } = result.data;
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
governance.votingSystem === "weighted-reputation" &&
|
|
65
|
+
(!organizations || organizations.length === 0)
|
|
66
|
+
) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"governance.votingSystem 'weighted-reputation' requires at least one entry in organizations[]"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (tokenomics.defaultMemberTokens) {
|
|
73
|
+
const totalForOrgs = tokenomics.defaultMemberTokens * organizations.length;
|
|
74
|
+
if (totalForOrgs >= tokenomics.supply) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`tokenomics.defaultMemberTokens (${tokenomics.defaultMemberTokens} × ${organizations.length} orgs = ${totalForOrgs}) ` +
|
|
77
|
+
`must be less than total supply (${tokenomics.supply}) to leave tokens for the treasury`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result.data;
|
|
83
|
+
}
|