/
account.ts
449 lines (405 loc) · 15.5 KB
/
account.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
import { types } from "@algo-builder/web";
import { Address, generateAccount, modelsv2 } from "algosdk";
import { RUNTIME_ERRORS } from "./errors/errors-list";
import { RuntimeError } from "./errors/runtime-errors";
import { checkAndSetASAFields } from "./lib/asa";
import {
ALGORAND_ACCOUNT_MIN_BALANCE, APPLICATION_BASE_FEE,
ASSET_CREATION_FEE, MAX_ALGORAND_ACCOUNT_APPS,
MAX_ALGORAND_ACCOUNT_ASSETS,
SSC_VALUE_BYTES, SSC_VALUE_UINT
} from "./lib/constants";
import { keyToBytes } from "./lib/parsing";
import { assertValidSchema } from "./lib/stateful";
import {
AccountStoreI, AppDeploymentFlags, AppLocalStateM,
AssetHoldingM, CreatedAppM, RuntimeAccount,
SSCAttributesM, StackElem
} from "./types";
const StateMap = "key-value";
const globalState = "global-state";
const localStateSchema = "local-state-schema";
const globalStateSchema = "global-state-schema";
export class AccountStore implements AccountStoreI {
readonly account: RuntimeAccount;
readonly address: string;
minBalance: number; // required minimum balance for account
assets: Map<number, AssetHoldingM>;
amount: bigint;
appsLocalState: Map<number, AppLocalStateM>;
appsTotalSchema: modelsv2.ApplicationStateSchema;
createdApps: Map<number, SSCAttributesM>;
createdAssets: Map<number, modelsv2.AssetParams>;
constructor (balance: number | bigint, account?: RuntimeAccount | string) {
if (typeof account === 'string') {
this.account = generateAccount();
this.account.name = account;
this.address = this.account.addr;
} else if (account) {
// set config if account is passed by user
this.account = account;
this.address = account.addr;
} else {
// generate new account if not passed by user
this.account = generateAccount();
this.address = this.account.addr;
}
this.assets = new Map<number, AssetHoldingM>();
this.amount = BigInt(balance);
this.minBalance = ALGORAND_ACCOUNT_MIN_BALANCE;
this.appsLocalState = new Map<number, AppLocalStateM>();
this.appsTotalSchema = <modelsv2.ApplicationStateSchema>{};
this.createdApps = new Map<number, SSCAttributesM>();
this.createdAssets = new Map<number, modelsv2.AssetParams>();
}
// returns account balance in microAlgos
balance (): bigint {
return this.amount;
}
/**
* Fetches local state value for key present in account
* returns undefined otherwise
* @param appID: current application id
* @param key: key to fetch value of from local state
*/
getLocalState (appID: number, key: Uint8Array | string): StackElem | undefined {
const localState = this.appsLocalState;
const data = localState.get(appID)?.[StateMap]; // can be undefined (eg. app opted in)
const localKey = keyToBytes(key);
return data?.get(localKey.toString());
}
/**
* Set new key-value pair or update pair with existing key in account
* for application id: appID, throw error otherwise
* @param appID: current application id
* @param key: key to fetch value of from local state
* @param value: value of key to put in local state
* @param line line number in TEAL file
* Note: if user is accessing this function directly through runtime,
* then line number is unknown
*/
setLocalState (appID: number, key: Uint8Array | string, value: StackElem, line?: number): AppLocalStateM {
const lineNumber = line ?? 'unknown';
const localState = this.appsLocalState.get(appID);
const localApp = localState?.[StateMap];
if (localState && localApp) {
const localKey = keyToBytes(key);
localApp.set(localKey.toString(), value);
localState[StateMap] = localApp; // save updated state
assertValidSchema(localState[StateMap], localState.schema); // verify if updated schema is valid by config
return localState;
}
throw new RuntimeError(RUNTIME_ERRORS.GENERAL.APP_NOT_FOUND, {
appID: appID,
line: lineNumber
});
}
/**
* Queries app global state value. Returns `undefined` if the key is not present.
* @param appID: current application id
* @param key: key to fetch value of from local state
*/
getGlobalState (appID: number, key: Uint8Array | string): StackElem | undefined {
const app = this.getApp(appID);
if (!app) return undefined;
const appGlobalState = app[globalState];
const globalKey = keyToBytes(key);
return appGlobalState.get(globalKey.toString());
}
/**
* Updates app global state.
* Throws error if app is not found.
* @param appID: application id
* @param key: app global state key
* @param value: value associated with a key
*/
setGlobalState (appID: number, key: Uint8Array | string, value: StackElem, line?: number): void {
const app = this.getApp(appID);
if (app === undefined) throw new RuntimeError(RUNTIME_ERRORS.GENERAL.APP_NOT_FOUND, { appID: appID, line: line ?? 'unknown' });
const appGlobalState = app[globalState];
const globalKey = keyToBytes(key);
appGlobalState.set(globalKey.toString(), value); // set new value in global state
app[globalState] = appGlobalState; // save updated state
assertValidSchema(app[globalState], app[globalStateSchema]); // verify if updated schema is valid by config
}
/**
* Queries application by application index from account's global state.
* Returns undefined if app is not found.
* @param appID application index
*/
getApp (appID: number): SSCAttributesM | undefined {
return this.createdApps.get(appID);
}
/**
* Queries application by application index from account's local state.
* Returns undefined if app is not found.
* @param appID application index
*/
getAppFromLocal (appID: number): AppLocalStateM | undefined {
return this.appsLocalState.get(appID);
}
/**
* Queries asset definition by assetId
* @param assetId asset index
*/
getAssetDef (assetId: number): modelsv2.AssetParams | undefined {
return this.createdAssets.get(assetId);
}
/**
* Queries asset holding by assetId
* @param assetId asset index
*/
getAssetHolding (assetId: number): AssetHoldingM | undefined {
return this.assets.get(assetId);
}
/**
* Creates Asset in account's state
* @param name Asset Name
* @param asaDef Asset Definitions
*/
addAsset (assetId: number, name: string, asaDef: types.ASADef): modelsv2.AssetParams {
if (this.createdAssets.size === MAX_ALGORAND_ACCOUNT_ASSETS) {
throw new RuntimeError(RUNTIME_ERRORS.ASA.MAX_LIMIT_ASSETS,
{ name: name, address: this.address, max: MAX_ALGORAND_ACCOUNT_ASSETS });
}
this.minBalance += ASSET_CREATION_FEE;
const asset = new Asset(assetId, asaDef, this.address, name);
this.createdAssets.set(asset.id, asset.definitions);
// set holding in creator account. note: for creator default-frozen is always false
// https://developer.algorand.org/docs/reference/rest-apis/algod/v2/#assetparams
const assetHolding: AssetHoldingM = {
amount: BigInt(asaDef.total), // for creator opt-in amount is total assets
'asset-id': assetId,
creator: this.address,
'is-frozen': false
};
this.assets.set(assetId, assetHolding);
return asset.definitions;
}
/**
* Modifies Asset fields
* @param assetId Asset Index
* @param fields Fields for modification
*/
modifyAsset (assetId: number, fields: types.AssetModFields): void {
const asset = this.getAssetDef(assetId);
if (asset === undefined) {
throw new RuntimeError(RUNTIME_ERRORS.ASA.ASSET_NOT_FOUND, { assetId: assetId });
}
// check for blank fields
checkAndSetASAFields(fields, asset);
}
/**
* removes asset holding from account
* @param assetId asset index
*/
closeAsset (assetId: number): void {
/**
* NOTE: We don't throw error/warning here if asset holding is not found, because this code
* will not be executed if asset holding doesn't exist (as need to empty this.account to closeRemTo
* in runtime via ctx.transferAsset before removing asset holding)
*/
if (this.assets.has(assetId)) {
this.minBalance -= ASSET_CREATION_FEE;
// https://developer.algorand.org/docs/reference/transactions/#asset-transfer-transaction
this.assets.delete(assetId); // remove asset holding from account
}
}
/**
* Freeze asset
* @param assetId Asset Index
* @state new freeze state
*/
setFreezeState (assetId: number, state: boolean): void {
const holding = this.assets.get(assetId);
if (holding === undefined) {
throw new RuntimeError(RUNTIME_ERRORS.TRANSACTION.ASA_NOT_OPTIN,
{ address: this.address, assetId: assetId });
}
holding["is-frozen"] = state;
}
/**
* Destroys asset
* @param assetId Asset Index
*/
destroyAsset (assetId: number): void {
const holding = this.assets.get(assetId);
const asset = this.getAssetDef(assetId);
if (holding === undefined || asset === undefined) {
throw new RuntimeError(RUNTIME_ERRORS.ASA.ASSET_NOT_FOUND, { assetId: assetId });
}
if (holding.amount !== asset.total) {
throw new RuntimeError(RUNTIME_ERRORS.ASA.ASSET_TOTAL_ERROR);
}
this.minBalance -= ASSET_CREATION_FEE;
this.createdAssets.delete(assetId);
this.assets.delete(assetId);
}
/**
* Add application in account's state
* check maximum account creation limit
* @param appID application index
* @param params SSCDeployment Flags
* @param approvalProgram application approval program
* @param clearProgram application clear program
* NOTE - approval and clear program must be the TEAL code as string
*/
addApp (appID: number, params: AppDeploymentFlags,
approvalProgram: string, clearProgram: string): CreatedAppM {
if (this.createdApps.size === MAX_ALGORAND_ACCOUNT_APPS) {
throw new RuntimeError(RUNTIME_ERRORS.GENERAL.MAX_LIMIT_APPS, {
address: this.address,
max: MAX_ALGORAND_ACCOUNT_APPS
});
};
// raise minimum balance
// https://developer.algorand.org/docs/features/asc1/stateful/#minimum-balance-requirement-for-a-smart-contract
this.minBalance += (
APPLICATION_BASE_FEE +
(SSC_VALUE_UINT * params.globalInts) + (SSC_VALUE_BYTES * params.globalBytes)
);
const app = new App(appID, params, approvalProgram, clearProgram);
this.createdApps.set(app.id, app.attributes);
return app;
}
// opt in to application
optInToApp (appID: number, appParams: SSCAttributesM): void {
const localState = this.appsLocalState.get(appID); // fetch local state from account
if (localState) {
console.warn(`${this.address} is already opted in to app ${appID}`);
} else {
if (this.appsLocalState.size === 10) {
throw new Error('Maximum Opt In applications per account is 10');
}
// https://developer.algorand.org/docs/features/asc1/stateful/#minimum-balance-requirement-for-a-smart-contract
this.minBalance += (
APPLICATION_BASE_FEE +
(SSC_VALUE_UINT * Number(appParams[localStateSchema].numUint)) +
(SSC_VALUE_BYTES * Number(appParams[localStateSchema].numByteSlice))
);
// create new local app attribute
const localParams: AppLocalStateM = {
id: appID,
"key-value": new Map<string, StackElem>(),
schema: appParams[localStateSchema]
};
this.appsLocalState.set(appID, localParams);
}
}
// opt-in to asset
optInToASA (assetIndex: number, assetHolding: AssetHoldingM): void {
const accAssetHolding = this.assets.get(assetIndex); // fetch asset holding of account
if (accAssetHolding) {
console.warn(`${this.address} is already opted in to asset ${assetIndex}`);
} else {
if ((this.createdAssets.size + this.assets.size) === MAX_ALGORAND_ACCOUNT_ASSETS) {
throw new RuntimeError(RUNTIME_ERRORS.ASA.MAX_LIMIT_ASSETS,
{ address: assetHolding.creator, max: MAX_ALGORAND_ACCOUNT_ASSETS });
}
this.minBalance += ASSET_CREATION_FEE;
this.assets.set(assetIndex, assetHolding);
}
}
// delete application from account's global state (createdApps)
deleteApp (appID: number): void {
const app = this.createdApps.get(appID);
if (!app) {
throw new RuntimeError(RUNTIME_ERRORS.GENERAL.APP_NOT_FOUND, { appID: appID, line: 'unknown' });
}
// reduce minimum balance
this.minBalance -= (
APPLICATION_BASE_FEE +
(SSC_VALUE_UINT * Number(app[globalStateSchema].numUint)) +
(SSC_VALUE_BYTES * Number(app[globalStateSchema].numByteSlice))
);
this.createdApps.delete(appID);
}
// close(delete) application from account's local state (appsLocalState)
closeApp (appID: number): void {
const localApp = this.appsLocalState.get(appID);
if (!localApp) {
throw new RuntimeError(RUNTIME_ERRORS.GENERAL.APP_NOT_FOUND, { appID: appID, line: 'unknown' });
}
// decrease min balance
this.minBalance -= (
APPLICATION_BASE_FEE +
(SSC_VALUE_UINT * Number(localApp.schema.numUint)) +
(SSC_VALUE_BYTES * Number(localApp.schema.numByteSlice))
);
this.appsLocalState.delete(appID);
}
}
// represents stateful application
class App {
readonly id: number;
readonly attributes: SSCAttributesM;
// NOTE - approval and clear program must be the TEAL code as string
constructor (appID: number, params: AppDeploymentFlags,
approvalProgram: string, clearProgram: string) {
this.id = appID;
const base: BaseModel = new BaseModelI();
this.attributes = {
'approval-program': approvalProgram,
'clear-state-program': clearProgram,
creator: params.sender.addr,
'global-state': new Map<string, StackElem>(),
'global-state-schema': { ...base, numByteSlice: params.globalBytes, numUint: params.globalInts },
'local-state-schema': { ...base, numByteSlice: params.localBytes, numUint: params.localInts }
};
}
}
// represents asset
class Asset {
readonly id: number;
readonly definitions: modelsv2.AssetParams;
constructor (assetId: number, def: types.ASADef, creator: string, assetName: string) {
this.id = assetId;
const base: BaseModel = new BaseModelI();
this.definitions = {
creator: creator,
total: BigInt(def.total),
decimals: def.decimals,
defaultFrozen: def.defaultFrozen ?? false,
unitName: def.unitName,
url: def.url,
metadataHash: typeof def.metadataHash === 'string'
? new Uint8Array(Buffer.from(def.metadataHash, 'base64'))
: def.metadataHash,
manager: def.manager,
reserve: def.reserve,
freeze: def.freeze,
clawback: def.clawback,
...base
};
}
}
export interface BaseModel {
attribute_map: Record<string, string>
_is_primitive: (val: any) => val is string | boolean | number | bigint
_is_address: (val: any) => val is Address
/* eslint-disable*/
_get_obj_for_encoding(val: Function): Record<string, any>;
_get_obj_for_encoding(val: any[]): any[];
_get_obj_for_encoding(val: Record<string, any>): Record<string, any>;
get_obj_for_encoding(): Record<string, any>;
}
export class BaseModelI implements BaseModel {
attribute_map: Record<string, string>;
public constructor () {
this.attribute_map = {};
}
_is_primitive(val: any): val is string | boolean | number | bigint {
return true;
}
_is_address(val: any): val is Address {
throw new Error("_is_address Not Implemented");
}
_get_obj_for_encoding(val: Function): Record<string, any>;
_get_obj_for_encoding(val: any[]): any[];
_get_obj_for_encoding(val: Record<string, any>): Record<string, any> {
throw new Error("_get_obj_for_encoding Not Implemented");
}
get_obj_for_encoding(): Record<string, any> {
throw new Error("get_obj_for_encoding Not Implemented");
}
}