@agoric/fast-usdc 0.1.1-dev-02967de.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 +201 -0
- package/README.md +58 -0
- package/package.json +81 -0
- package/src/cli/bin.js +6 -0
- package/src/cli/cli.js +211 -0
- package/src/cli/config.js +101 -0
- package/src/cli/transfer.js +91 -0
- package/src/constants.js +27 -0
- package/src/exos/README.md +26 -0
- package/src/exos/advancer.js +240 -0
- package/src/exos/liquidity-pool.js +365 -0
- package/src/exos/operator-kit.js +120 -0
- package/src/exos/settler.js +97 -0
- package/src/exos/status-manager.js +176 -0
- package/src/exos/transaction-feed.js +180 -0
- package/src/fast-usdc.contract.js +216 -0
- package/src/fast-usdc.start.js +264 -0
- package/src/pool-share-math.js +189 -0
- package/src/type-guards.js +95 -0
- package/src/types-index.d.ts +1 -0
- package/src/types-index.js +1 -0
- package/src/types.ts +58 -0
- package/src/util/agoric.js +12 -0
- package/src/util/cctp.js +71 -0
- package/src/util/file.js +30 -0
- package/src/util/noble.js +110 -0
- package/src/utils/address.js +71 -0
- package/src/utils/config-marshal.js +130 -0
- package/src/utils/fees.js +58 -0
- package/src/utils/zoe.js +28 -0
package/src/util/file.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { dirname } from 'path';
|
|
2
|
+
|
|
3
|
+
/** @import { readFile as readAsync } from 'node:fs/promises' */
|
|
4
|
+
/** @import { writeFile as writeAsync } from 'node:fs/promises' */
|
|
5
|
+
/** @import { mkdirSync } from 'node:fs' */
|
|
6
|
+
/** @import { existsSync } from 'node:fs' */
|
|
7
|
+
|
|
8
|
+
export const makeFile = (
|
|
9
|
+
/** @type {string} */ path,
|
|
10
|
+
/** @type {readAsync} */ readFile,
|
|
11
|
+
/** @type {writeAsync} */ writeFile,
|
|
12
|
+
/** @type {mkdirSync} */ mkdir,
|
|
13
|
+
/** @type {existsSync} */ pathExists,
|
|
14
|
+
) => {
|
|
15
|
+
const read = () => readFile(path, 'utf-8');
|
|
16
|
+
|
|
17
|
+
const write = async (/** @type {string} */ data) => {
|
|
18
|
+
const dir = dirname(path);
|
|
19
|
+
if (!pathExists(dir)) {
|
|
20
|
+
mkdir(dir);
|
|
21
|
+
}
|
|
22
|
+
await writeFile(path, data);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const exists = () => pathExists(path);
|
|
26
|
+
|
|
27
|
+
return { read, write, exists, path };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** @typedef {ReturnType<typeof makeFile>} file */
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* global globalThis */
|
|
2
|
+
|
|
3
|
+
import { DirectSecp256k1HdWallet, Registry } from '@cosmjs/proto-signing';
|
|
4
|
+
import { AminoTypes, SigningStargateClient } from '@cosmjs/stargate';
|
|
5
|
+
import { nobleAminoConverters, nobleProtoRegistry } from '@nick134-bit/noblejs';
|
|
6
|
+
|
|
7
|
+
export const makeSigner = async (
|
|
8
|
+
/** @type {string} */ nobleSeed,
|
|
9
|
+
/** @type {string} */ nobleRpc,
|
|
10
|
+
out = console,
|
|
11
|
+
) => {
|
|
12
|
+
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(nobleSeed, {
|
|
13
|
+
prefix: 'noble',
|
|
14
|
+
});
|
|
15
|
+
out.log('got noble wallet from seed');
|
|
16
|
+
const accounts = await wallet.getAccounts();
|
|
17
|
+
const address = accounts[0].address;
|
|
18
|
+
const signer = await SigningStargateClient.connectWithSigner(
|
|
19
|
+
nobleRpc,
|
|
20
|
+
wallet,
|
|
21
|
+
{
|
|
22
|
+
aminoTypes: new AminoTypes({
|
|
23
|
+
...nobleAminoConverters,
|
|
24
|
+
}),
|
|
25
|
+
registry: new Registry([...nobleProtoRegistry]),
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
return { address, signer };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const createMsgRegisterAccount = (
|
|
32
|
+
/** @type {string} */ signer,
|
|
33
|
+
/** @type {string} */ recipient,
|
|
34
|
+
/** @type {string} */ channel,
|
|
35
|
+
) => {
|
|
36
|
+
return {
|
|
37
|
+
typeUrl: '/noble.forwarding.v1.MsgRegisterAccount',
|
|
38
|
+
value: {
|
|
39
|
+
signer,
|
|
40
|
+
recipient,
|
|
41
|
+
channel,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const registerFwdAccount = async (
|
|
47
|
+
/** @type {SigningStargateClient} */ nobleSigner,
|
|
48
|
+
/** @type {string} */ nobleAddress,
|
|
49
|
+
/** @type {string} */ nobleToAgoricChannel,
|
|
50
|
+
/** @type {string} */ recipient,
|
|
51
|
+
out = console,
|
|
52
|
+
) => {
|
|
53
|
+
out.log('registering fwd account on noble');
|
|
54
|
+
const msg = createMsgRegisterAccount(
|
|
55
|
+
nobleAddress,
|
|
56
|
+
recipient,
|
|
57
|
+
nobleToAgoricChannel,
|
|
58
|
+
);
|
|
59
|
+
const fee = {
|
|
60
|
+
amount: [
|
|
61
|
+
{
|
|
62
|
+
denom: 'uusdc',
|
|
63
|
+
amount: '20000',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
gas: '200000',
|
|
67
|
+
};
|
|
68
|
+
out.log('signing message', msg);
|
|
69
|
+
const txResult = await nobleSigner.signAndBroadcast(
|
|
70
|
+
nobleAddress,
|
|
71
|
+
[msg],
|
|
72
|
+
fee,
|
|
73
|
+
'Register Account Transaction',
|
|
74
|
+
);
|
|
75
|
+
if (txResult.code !== undefined && txResult.code !== 0) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Transaction failed with code ${txResult.code}: ${txResult.events || ''}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return `Transaction successful with hash: ${txResult.transactionHash}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const queryForwardingAccount = async (
|
|
84
|
+
/** @type {string} */ nobleApi,
|
|
85
|
+
/** @type {string} */ nobleToAgoricChannel,
|
|
86
|
+
/** @type {string} */ agoricAddr,
|
|
87
|
+
out = console,
|
|
88
|
+
fetch = globalThis.fetch,
|
|
89
|
+
) => {
|
|
90
|
+
/**
|
|
91
|
+
* https://github.com/noble-assets/forwarding/blob/9d7657a/proto/noble/forwarding/v1/query.proto
|
|
92
|
+
* v2.0.0 10 Nov 2024
|
|
93
|
+
*/
|
|
94
|
+
const query = `${nobleApi}/noble/forwarding/v1/address/${nobleToAgoricChannel}/${encodeURIComponent(agoricAddr)}/`;
|
|
95
|
+
out.log(`querying forward address details from noble api: ${query}`);
|
|
96
|
+
let forwardingAddressRes;
|
|
97
|
+
await null;
|
|
98
|
+
try {
|
|
99
|
+
forwardingAddressRes = await fetch(query).then(res => res.json());
|
|
100
|
+
} catch (e) {
|
|
101
|
+
out.error(`Error querying forwarding address from ${query}`);
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
/** @type {{ address: string, exists: boolean }} */
|
|
105
|
+
const { address, exists } = forwardingAddressRes;
|
|
106
|
+
out.log(
|
|
107
|
+
`got forwarding address details: ${JSON.stringify(forwardingAddressRes)}`,
|
|
108
|
+
);
|
|
109
|
+
return { address, exists };
|
|
110
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { makeError, q } from '@endo/errors';
|
|
2
|
+
import { M, mustMatch } from '@endo/patterns';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @import {Pattern} from '@endo/patterns';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default pattern matcher for `getQueryParams`.
|
|
10
|
+
* Does not assert keys exist, but ensures existing keys are strings.
|
|
11
|
+
*/
|
|
12
|
+
const QueryParamsShape = M.splitRecord(
|
|
13
|
+
{},
|
|
14
|
+
{},
|
|
15
|
+
M.recordOf(M.string(), M.string()),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Very minimal 'URL query string'-like parser that handles:
|
|
20
|
+
* - Query string delimiter (?)
|
|
21
|
+
* - Key-value separator (=)
|
|
22
|
+
* - Query parameter separator (&)
|
|
23
|
+
*
|
|
24
|
+
* Does not handle:
|
|
25
|
+
* - Subpaths (`agoric1bech32addr/opt/account?k=v`)
|
|
26
|
+
* - URI encoding/decoding (`%20` -> ` `)
|
|
27
|
+
* - note: `decodeURIComponent` seems to be available in XS
|
|
28
|
+
* - Multiple question marks (foo?bar=1?baz=2)
|
|
29
|
+
* - Empty parameters (foo=)
|
|
30
|
+
* - Array parameters (`foo?k=v1&k=v2` -> k: [v1, v2])
|
|
31
|
+
* - Parameters without values (foo&bar=2)
|
|
32
|
+
*/
|
|
33
|
+
export const addressTools = {
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} address
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
hasQueryParams: address => {
|
|
39
|
+
try {
|
|
40
|
+
const params = addressTools.getQueryParams(address);
|
|
41
|
+
return Object.keys(params).length > 0;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} address
|
|
48
|
+
* @param {Pattern} [shape]
|
|
49
|
+
* @returns {Record<string, string>}
|
|
50
|
+
* @throws {Error} if the address cannot be parsed or params do not match `shape`
|
|
51
|
+
*/
|
|
52
|
+
getQueryParams: (address, shape = QueryParamsShape) => {
|
|
53
|
+
const parts = address.split('?');
|
|
54
|
+
if (parts.length !== 2) {
|
|
55
|
+
throw makeError(`Unable to parse query params: ${q(address)}`);
|
|
56
|
+
}
|
|
57
|
+
/** @type {Record<string, string>} */
|
|
58
|
+
const result = {};
|
|
59
|
+
const paramPairs = parts[1].split('&');
|
|
60
|
+
for (const pair of paramPairs) {
|
|
61
|
+
const [key, value] = pair.split('=');
|
|
62
|
+
if (!key || !value) {
|
|
63
|
+
throw makeError(`Invalid parameter format in pair: ${q(pair)}`);
|
|
64
|
+
}
|
|
65
|
+
result[key] = value;
|
|
66
|
+
}
|
|
67
|
+
harden(result);
|
|
68
|
+
mustMatch(result, shape);
|
|
69
|
+
return result;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Fail } from '@endo/errors';
|
|
2
|
+
import { makeMarshal } from '@endo/marshal';
|
|
3
|
+
import { mustMatch } from '@endo/patterns';
|
|
4
|
+
|
|
5
|
+
// TODO(#7309): move to make available beyond fast-usdc.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @import {Marshal, CapData, Passable} from '@endo/marshal';
|
|
9
|
+
* @import { RemotableBrand } from '@endo/eventual-send';
|
|
10
|
+
* @import {TypedPattern} from '@agoric/internal'
|
|
11
|
+
*/
|
|
12
|
+
const { entries } = Object;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* To configure amounts such as terms or ratios,
|
|
16
|
+
* we need to refer to objects such as brands.
|
|
17
|
+
*
|
|
18
|
+
* If parties agree on names, any party that doesn't have
|
|
19
|
+
* an actual presence for an object can make one up:
|
|
20
|
+
*
|
|
21
|
+
* const remotes = { USDC: Far('USDC Brand') };
|
|
22
|
+
*
|
|
23
|
+
* and use it in local computation:
|
|
24
|
+
*
|
|
25
|
+
* const terms = { fee1: AmountMath.make(remotes.USDC, 1234n) }
|
|
26
|
+
*
|
|
27
|
+
* Then we can pass references across using marshal conventions, using
|
|
28
|
+
* the names as slots.
|
|
29
|
+
*
|
|
30
|
+
* @param {Record<string, Passable>} slotToVal a record that gives names to stand-ins for objects in another vat
|
|
31
|
+
* @returns {Marshal<string>}
|
|
32
|
+
*/
|
|
33
|
+
export const makeMarshalFromRecord = slotToVal => {
|
|
34
|
+
const convertSlotToVal = slot => {
|
|
35
|
+
slot in slotToVal || Fail`unknown slot ${slot}`;
|
|
36
|
+
return slotToVal[slot];
|
|
37
|
+
};
|
|
38
|
+
const valToSlot = new Map(entries(slotToVal).map(([k, v]) => [v, k]));
|
|
39
|
+
const convertValToSlot = v => {
|
|
40
|
+
valToSlot.has(v) || Fail`unknown value: ${v}`;
|
|
41
|
+
return valToSlot.get(v);
|
|
42
|
+
};
|
|
43
|
+
return makeMarshal(convertValToSlot, convertSlotToVal, {
|
|
44
|
+
serializeBodyFormat: 'smallcaps',
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {`\$${number}${string}`} SmallCapsSlotRef
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @template T
|
|
54
|
+
* @typedef {{ [KeyType in keyof T]: T[KeyType] } & {}} Simplify flatten the
|
|
55
|
+
* type output to improve type hints shown in editors
|
|
56
|
+
* https://github.com/sindresorhus/type-fest/blob/main/source/simplify.d.ts
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @template T
|
|
61
|
+
* @template R
|
|
62
|
+
* @typedef {T extends R
|
|
63
|
+
* ? SmallCapsSlotRef
|
|
64
|
+
* : T extends {}
|
|
65
|
+
* ? Simplify<SmallCapsStructureOf<T, R>>
|
|
66
|
+
* : Awaited<T>} SmallCapsStructureOf
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The smallCaps body is a string, which simplifies some usage.
|
|
71
|
+
* But it's hard to read and write.
|
|
72
|
+
*
|
|
73
|
+
* The parsed structure makes a convenient notation for configuration etc.
|
|
74
|
+
*
|
|
75
|
+
* @template {Passable} [T=Passable]
|
|
76
|
+
* @template [R=RemotableBrand]
|
|
77
|
+
* @typedef {{
|
|
78
|
+
* structure: SmallCapsStructureOf<T, R>;
|
|
79
|
+
* slots: string[];
|
|
80
|
+
* }} LegibleCapData
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @template {Passable} [T=Passable]
|
|
85
|
+
* @template [R=RemotableBrand]
|
|
86
|
+
* @param {CapData<string>} capData
|
|
87
|
+
* @returns {LegibleCapData<T, R>}
|
|
88
|
+
*/
|
|
89
|
+
export const toLegible = ({ body, slots }) =>
|
|
90
|
+
harden({ structure: JSON.parse(body.replace(/^#/, '')), slots });
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @template {Passable} [T=Passable]
|
|
94
|
+
* @template [R=RemotableBrand]
|
|
95
|
+
* @param {LegibleCapData<T,R>} legible
|
|
96
|
+
* @returns {CapData<string>}
|
|
97
|
+
*/
|
|
98
|
+
export const fromLegible = ({ structure, slots }) =>
|
|
99
|
+
harden({ body: `#${JSON.stringify(structure)}`, slots });
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @template {Passable} [T=Passable]
|
|
103
|
+
* @template [R=RemotableBrand]
|
|
104
|
+
* @param {T} config
|
|
105
|
+
* @param {Record<string, Passable>} context
|
|
106
|
+
* @param {TypedPattern<T>} [shape]
|
|
107
|
+
* @returns {LegibleCapData<T,R>}
|
|
108
|
+
*/
|
|
109
|
+
export const toExternalConfig = (config, context, shape) => {
|
|
110
|
+
if (shape) {
|
|
111
|
+
mustMatch(config, shape);
|
|
112
|
+
}
|
|
113
|
+
return toLegible(makeMarshalFromRecord(context).toCapData(config));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @template {Passable} [T=Passable]
|
|
118
|
+
* @template [R=RemotableBrand]
|
|
119
|
+
* @param {LegibleCapData<T,R>} repr
|
|
120
|
+
* @param {Record<string, Passable>} context
|
|
121
|
+
* @param {TypedPattern<T>} [shape]
|
|
122
|
+
* @returns {T}
|
|
123
|
+
*/
|
|
124
|
+
export const fromExternalConfig = (repr, context, shape) => {
|
|
125
|
+
const config = makeMarshalFromRecord(context).fromCapData(fromLegible(repr));
|
|
126
|
+
if (shape) {
|
|
127
|
+
mustMatch(config, shape);
|
|
128
|
+
}
|
|
129
|
+
return config;
|
|
130
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AmountMath } from '@agoric/ertp';
|
|
2
|
+
import { multiplyBy } from '@agoric/zoe/src/contractSupport/ratio.js';
|
|
3
|
+
import { Fail } from '@endo/errors';
|
|
4
|
+
import { mustMatch } from '@endo/patterns';
|
|
5
|
+
import { FeeConfigShape } from '../type-guards.js';
|
|
6
|
+
|
|
7
|
+
const { add, isGTE, min, subtract } = AmountMath;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @import {Amount} from '@agoric/ertp';
|
|
11
|
+
* @import {FeeConfig} from '../types.js';
|
|
12
|
+
* @import {RepayAmountKWR} from '../exos/liquidity-pool.js';
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @param {FeeConfig} feeConfig */
|
|
16
|
+
export const makeFeeTools = feeConfig => {
|
|
17
|
+
mustMatch(feeConfig, FeeConfigShape, 'Must provide feeConfig');
|
|
18
|
+
const { flat, variableRate, maxVariable } = feeConfig;
|
|
19
|
+
const feeTools = harden({
|
|
20
|
+
/**
|
|
21
|
+
* Calculate the net amount to advance after withholding fees.
|
|
22
|
+
*
|
|
23
|
+
* @param {Amount<'nat'>} requested
|
|
24
|
+
* @throws {Error} if requested does not exceed fees
|
|
25
|
+
*/
|
|
26
|
+
calculateAdvance(requested) {
|
|
27
|
+
const fee = feeTools.calculateAdvanceFee(requested);
|
|
28
|
+
return subtract(requested, fee);
|
|
29
|
+
},
|
|
30
|
+
/**
|
|
31
|
+
* Calculate the total fee to charge for the advance.
|
|
32
|
+
*
|
|
33
|
+
* @param {Amount<'nat'>} requested
|
|
34
|
+
* @throws {Error} if requested does not exceed fees
|
|
35
|
+
*/
|
|
36
|
+
calculateAdvanceFee(requested) {
|
|
37
|
+
const variable = min(multiplyBy(requested, variableRate), maxVariable);
|
|
38
|
+
const fee = add(variable, flat);
|
|
39
|
+
!isGTE(fee, requested) || Fail`Request must exceed fees.`;
|
|
40
|
+
return fee;
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* Calculate the split of fees between pool and contract.
|
|
44
|
+
*
|
|
45
|
+
* @param {Amount<'nat'>} requested
|
|
46
|
+
* @returns {RepayAmountKWR} an {@link AmountKeywordRecord}
|
|
47
|
+
* @throws {Error} if requested does not exceed fees
|
|
48
|
+
*/
|
|
49
|
+
calculateSplit(requested) {
|
|
50
|
+
const fee = feeTools.calculateAdvanceFee(requested);
|
|
51
|
+
const Principal = subtract(requested, fee);
|
|
52
|
+
const ContractFee = multiplyBy(fee, feeConfig.contractRate);
|
|
53
|
+
const PoolFee = subtract(fee, ContractFee);
|
|
54
|
+
return harden({ Principal, PoolFee, ContractFee });
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return feeTools;
|
|
58
|
+
};
|
package/src/utils/zoe.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { makeTracer } from '@agoric/internal';
|
|
2
|
+
|
|
3
|
+
const trace = makeTracer('ZoeUtils');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Used for "continuing offer" invitations in which the caller does not need
|
|
7
|
+
* anything in return. In those cases there is no Zoe offer safety and the
|
|
8
|
+
* invitation making function can perform the request itself.
|
|
9
|
+
*
|
|
10
|
+
* But smart-wallet expects an invitation maker to make an invitation, so this
|
|
11
|
+
* function abstracts making such an inert invitation and logs consistently when
|
|
12
|
+
* it is used.
|
|
13
|
+
*
|
|
14
|
+
* When this is used by an invitation maker that performs the operation, receiving
|
|
15
|
+
* one of these invitations is evidence that the operation took place.
|
|
16
|
+
*
|
|
17
|
+
* @param {ZCF} zcf
|
|
18
|
+
* @param {string} description @see {@link ZCF.makeInvitation}
|
|
19
|
+
* @returns {() => Promise<Invitation>} an arg-less invitation maker
|
|
20
|
+
*/
|
|
21
|
+
export const defineInertInvitation = (zcf, description) => {
|
|
22
|
+
return () =>
|
|
23
|
+
zcf.makeInvitation(seat => {
|
|
24
|
+
trace(`ℹ️ An offer was made on an inert invitation for ${description}`);
|
|
25
|
+
seat.exit();
|
|
26
|
+
return 'inert; nothing should be expected from this offer';
|
|
27
|
+
}, description);
|
|
28
|
+
};
|